Compare commits

105 Commits
main ... dev

Author SHA1 Message Date
7e2d02b643 Feat: Generiert wiederkehrende Transaktionen sofort, wenn sie neu erstellt wurde. 2026-01-25 17:40:31 +01:00
4c4a1c7f20 Feat: Fügt die Möglichkeit hinzu, einen Hintergrundtask sofort auszuführen 2026-01-25 17:39:15 +01:00
97c67376f5 Fügt uuid als Dependency hinzu 2026-01-25 17:38:52 +01:00
aa7f99b3a5 Feat: Wiederholt Tasks nur, wenn die Frequenz größer asl 0 ist. 2026-01-25 17:38:36 +01:00
78add73278 Fix: Passt an, dass bei monatlichen Transaktionen der Tag entsprechend hergenommen wird 2026-01-25 16:34:14 +01:00
c86c2dd888 Passt .gitignore an 2026-01-25 16:13:45 +01:00
68e302c600 Passt .gitignore an 2026-01-25 16:13:16 +01:00
94a055f21f Passt Readme-Release für Android an 2026-01-25 16:12:48 +01:00
68e4481f2e Feat: Macht Hintergrund der FloatingCreationButtons leicht geblurrt für bessere Lesbarkeit 2026-01-25 15:56:47 +01:00
1ee4458f6f Ref: Benennt die Knöpfe nur in "Löschen" um 2026-01-25 15:48:42 +01:00
516db8edc1 Fix: Behebt einen Fehler, welcher beim Öffnen der Tastatur die Werte löscht 2026-01-25 15:44:12 +01:00
4bf4843779 Fix: Entfernt die activity, um die App auf dem Sperrbildschirm anzuzeigen 2026-01-08 19:08:04 +01:00
531e819c69 Feat: Passt App für kleinere Bildschirme an 2026-01-05 17:22:08 +01:00
a535603924 Passt Formatierung an 2026-01-05 16:34:44 +01:00
8c5e120fff Feat: Fügt App-Icons hinzu 2026-01-05 14:05:17 +01:00
63fb59bb73 Fügt flutter_launcher_icons als dev-Dependency hinzu 2026-01-05 14:05:03 +01:00
1031e14153 Entfernt Datei 2026-01-05 02:13:14 +01:00
caf50aa8d2 Fix: Macht die BackgroundWorker für den IsolateManager on Web möglich 2026-01-05 02:12:38 +01:00
6f3d987d19 Feat: Fügt Benachrichtigungen für nicht überprüfte Transaktionen hinzu 2026-01-05 01:07:23 +01:00
40eaca3157 Konfiguriert Projekt für Benachrichtigungen 2026-01-04 22:52:30 +01:00
71cfae90f6 Fügt flutter_local_notifications als dependency hinzu 2026-01-04 22:51:43 +01:00
d68e8ec305 Feat: Speichert Transaktions-Datumswerte in der korrekten UTC-Zone 2026-01-04 19:51:46 +01:00
b4485915df Feat: Macht Transaktionen in der Transaktions-Liste prüf- bzw. editierbar 2026-01-04 18:29:53 +01:00
03ea5b717c Feat: Macht Transaktionen prüf- bzw. editierbar 2026-01-04 18:22:50 +01:00
a011b63fb1 Feat: Aktualisiert Transaktionen wenn die wiederkehrenden generiert wurden 2026-01-04 18:00:39 +01:00
c2baac6dc0 Feat: Generiert alle Transaktionen, die benötigt werden 2026-01-04 17:30:58 +01:00
8dc60b7f9d Feat: Zeigt Knöpfe zum Transaktion erstellen nur an, wenn ein Konto ausgewählt ist 2026-01-04 17:30:44 +01:00
51d0572c11 Feat: Zeigt einen Button zum Konto-Erstellen an, falls kein Konto existiert 2026-01-03 21:54:27 +01:00
7b3a1cfac6 Feat: Stellt den BackgroundManager auf IsolateManager um 2026-01-03 21:53:37 +01:00
baef163b68 Fügt IsolateManager als dependency hinzu 2026-01-03 21:52:42 +01:00
2011739c09 Fügt logger als dependency hinzu 2026-01-03 21:52:28 +01:00
6c64f59ceb Fix: Ersetzt sleep durch Future.delayed 2026-01-03 15:19:51 +01:00
474009942e Ref: Entfernt await 2026-01-03 14:17:37 +01:00
77da3be5d7 Fix: Entfernt Dauer-Loop 2026-01-03 14:05:18 +01:00
e543286bc5 Feat: Erstellt die Möglichkeit, Tasks als Hintergrundprozesse auszuführen 2026-01-03 14:00:53 +01:00
9eb8274907 Fügt workmanager als dependency hinzu 2026-01-03 14:00:36 +01:00
7f189ce86d Feat: Fügt einen Task zur Generierung der Transaktionen anhand der wiederkehrenden Transaktionen hinzu 2026-01-03 14:00:20 +01:00
8d291a0d39 Fix: Fügt die SyncLog-Tabelle zur Datenbank hinzu 2026-01-02 01:05:50 +01:00
24a9e3d027 Ref: Platziert Fehlermeldungen mittig 2026-01-02 01:03:23 +01:00
6a7588c3d4 Feat: Fügt eine Tabelle für Synchronisationsstatusmeldungen hinzu 2026-01-02 01:03:06 +01:00
52824a5459 Feat: Fügt das automatische Setzen der updatedAt in die Repositories hinzu 2026-01-02 00:53:36 +01:00
4d716ba40d Feat: Fügt Spalten updatedAt zu allen Tabellen hinzu 2026-01-02 00:45:24 +01:00
7324cd94f3 Feat: Fügt die Spalte "checked" für Transactions hinzu 2026-01-02 00:32:42 +01:00
58cbd2a462 Fix: Passt das Verhalten der InputFelder an, dass sich diese wirklich verändern 2026-01-01 23:19:49 +01:00
a937d34df2 Feat: Fügt eine Kasse mit Funktionen für den Umgang mit Datum-Werten 2026-01-01 23:19:29 +01:00
d3804865d8 Feat: Macht die Trend-Seite funktional 2026-01-01 17:05:10 +01:00
fcdc820c74 Fügt drift als Extension hinzu 2026-01-01 15:19:33 +01:00
58e0582304 Entfernt omit_obvious_local_variable_types aus den linter-Rules 2026-01-01 15:19:07 +01:00
b9a7ef0dfa Feat: Fügt mounted-Check hinzu 2026-01-01 15:18:42 +01:00
4014757319 Feat: Macht die Dashboard-Seite funktional 2025-12-31 16:44:29 +01:00
f765ba6268 Feat: Lagert die Versionsnummer in eine eigene Klasse aus und macht Settings zu einem StatelessWidget 2025-12-29 00:09:15 +01:00
dbccb6b33d Fix: Entfernt debugPrint 2025-12-28 02:42:39 +01:00
b61d2ad096 Fix: Wählt beim Erstellen des ersten Kontos dieses direkt im AccountSelect aus 2025-12-28 02:39:22 +01:00
2c30768746 Fix: Zeigt anstatt des Ladecircles nichts für den AccountSelect an, falls noch kein Konto erstellt ist 2025-12-28 02:33:17 +01:00
824855b9b6 Ref: Passt Formatierung an automatisches Format an 2025-12-28 02:27:22 +01:00
0c4c6d7c3d Feat: Fügt die Liste für wiederkehrende Transaktionen hinzu 2025-12-28 02:26:58 +01:00
8d7f6bc4d3 Fügt flutter_localizations als Dependency hinzu 2025-12-28 02:25:47 +01:00
5a12ff45c8 Fügt date_field als Dependency hinzu 2025-12-28 02:25:36 +01:00
3387d86578 Fügt intl als Dependency hinzu 2025-12-28 02:25:28 +01:00
c1010a8051 Feat: Fügt localization für Deutsch hinzu 2025-12-28 02:25:07 +01:00
93d4d7a854 Feat: Schließt den FloatingCreationButton beim Anklicken einer Option wieder. 2025-12-28 02:24:30 +01:00
7a2abc84cb Docs: Fügt Docs hinzu 2025-12-25 23:35:08 +01:00
a45169bf12 Feat: Macht die Kontoliste und Versionsanzeige funktional 2025-12-25 23:15:54 +01:00
7916161e4f Erstellt die Datenbank neu 2025-12-25 16:33:39 +01:00
8a612fc27c Docs: Schreib Docs 2025-12-25 16:32:50 +01:00
05a5bddf09 Feat: Macht die Kontoauswahl funktional 2025-12-25 16:23:01 +01:00
c11515d447 Feat: Ersetzt die Isar-Datenbank durch die drift-Datenbank 2025-12-24 01:04:38 +01:00
673d7de21c Feat: Fügt eine Verknüpfung der Transaktionen mit den Konten hinzu 2025-12-23 01:08:38 +01:00
39323e28ac Fix: Behebt Fehler mit Isar 2025-12-23 01:08:16 +01:00
533feb2668 Feat: Fügt das Erstellen von Konten hinzu 2025-12-23 01:01:21 +01:00
6bde42c815 Feat: Fügt die Möglichkeit hinzu, eine success-Farbe zu bekommen 2025-12-23 01:00:58 +01:00
246c0401cc Feat: Fügt einen dynamisch erstell- und anzeigbaren Dialog hinzu 2025-12-23 01:00:23 +01:00
016ba85416 Feat: Fügt eine 1:n Beziehung zwischen RecurringTransaction und Transaction hinzu 2025-12-22 20:56:13 +01:00
7a76f0d40e Ref: Passt Linter-Rules an 2025-12-22 03:34:15 +01:00
92fec89333 Feat: Fügt IsarService und Repositories hinzu 2025-12-22 03:33:37 +01:00
277800a578 Erstellt Isar-Schemata 2025-12-22 03:33:22 +01:00
2115b06b9a Fügt path_provider als dependency hinzu 2025-12-22 03:32:44 +01:00
4fd20fad22 Fix: Passt die enum für Isar an 2025-12-22 01:51:10 +01:00
34de70ab66 Feat: Fügt Entities für account, transaction und recurring_transaction hinzu 2025-12-22 01:46:47 +01:00
c4225759d8 Fügt Isar Datenbank als dependency hinzu 2025-12-22 01:46:15 +01:00
0f066c67d1 Feat: Fügt einen FloatingActionButton zum Hinzufügen von Konten und Transaktionen hinzu 2025-12-22 01:26:31 +01:00
721e7c4bdf Fügt flutter_expandable_fab als dependency hinzu 2025-12-22 01:26:06 +01:00
efe7b4a903 Ref: Passt den router_service an 2025-12-22 00:57:22 +01:00
69e7e9d817 Feat: Fügt eine Dummy-Settings-Seite hinzu 2025-12-22 00:56:58 +01:00
47f467e17c Feat: Fügt ensureInitialized zur main hinzu 2025-12-22 00:56:42 +01:00
46e7cb246c Fügt package_info_plus hinzu 2025-12-22 00:56:26 +01:00
25b8f2176c Feat: Fügt eine Dummy-Seite für den Verlauf ein 2025-12-22 00:24:40 +01:00
668e3ebc67 Fügt flutter_sticky_header als dependency hinzu 2025-12-22 00:24:18 +01:00
b42ba57570 Feat: Baut einen Dummy für die Homepage 2025-12-20 18:13:35 +01:00
19e8a0f3c7 Fügt fl_chart als dependency hinzu 2025-12-20 18:03:24 +01:00
7eae712808 Ref: Extrahiert den accountSelect in eine eigene Funktion 2025-12-20 16:15:19 +01:00
0b33b83a9c Passt Anzeigenamen an 2025-12-20 15:58:06 +01:00
9632cebf96 Docs: Passt Formatierung an 2025-12-20 15:21:55 +01:00
882d331488 Feat: Zieht die Tab-Auswahl nach unten und fügt oben eine Kontoauswahl hinzu 2025-12-20 15:19:58 +01:00
5cb76e5d59 Räumt den router_service.dart auf 2025-12-20 15:06:32 +01:00
2b605f27ce Fügt lang-Attribut hinzu 2025-12-20 15:03:32 +01:00
4d80dd8a64 Fügt dropdown_search als dependency hinzu 2025-12-20 14:53:34 +01:00
c7a084b3fd Entfernt diagnostic_describe_all_properties 2025-12-20 14:53:18 +01:00
6ef73b7921 Fix: Passt Web-Title an 2025-12-20 14:52:44 +01:00
c8035a6ba0 Feat: Fügt Dark/Light-Theme hinzu 2025-12-20 14:51:50 +01:00
9d8dc92d08 Fügt grundlegendes Routing mit Routemaster hinzu und ersetzt MyApp-Struktur 2025-09-25 23:44:40 +02:00
2eebfd7f5c Entfernt Widget-Test für die Anwendung 2025-09-25 23:44:31 +02:00
4714a3ed62 Aktualisiert Abhängigkeiten in pubspec.yaml und fügt routemaster sowie font_awesome_flutter hinzu 2025-09-25 23:44:22 +02:00
5d6a1c4bed Entfernt Regel "avoid_classes_with_only_static_members" aus analysis_options.yaml 2025-09-25 23:39:04 +02:00
8af2bb5be8 Initialisiert Flutter-App 2025-09-25 19:40:51 +02:00
129 changed files with 39430 additions and 2 deletions

1
.gitignore vendored
View File

@@ -196,3 +196,4 @@ doc/api/
.flutter-plugins
.flutter-plugins-dependencies
debug_info/

39
.metadata Normal file
View File

@@ -0,0 +1,39 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "d693b4b9dbac2acd4477aea4555ca6dcbea44ba2"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
- platform: android
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
- platform: linux
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
- platform: web
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
- platform: windows
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

View File

@@ -1,3 +1,18 @@
# dragon_ledger
# DragonLedger 🐉📒
Eine Flutter-App um den Überblick über wiederkehrende Transaktionen und den damit verbunden Kontostand zu behalten.
# Fürs Debuggen:
`flutter run --web-header=Cross-Origin-Opener-Policy=same-origin --web-header=Cross-Origin-Embedder-Policy=require-corp`
## Release:
Build Android:
- `flutter build apk --obfuscate --split-debug-info=debug_info`
Webserver Header:
- `Cross-Origin-Opener-Policy: same-origin`
- `Cross-Origin-Embedder-Policy: require-corp`
- sqlite3.wasm braucht `Content-Type: application/wasm`

252
analysis_options.yaml Normal file
View File

@@ -0,0 +1,252 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- "**/*.g.dart"
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
- always_declare_return_types
- always_put_control_body_on_new_line
- always_put_required_named_parameters_first
- annotate_overrides
- annotate_redeclares
- avoid_annotating_with_dynamic
- avoid_bool_literals_in_conditional_expressions
- avoid_catches_without_on_clauses
- avoid_catching_errors
- avoid_double_and_int_checks
- avoid_dynamic_calls
- avoid_empty_else
- avoid_equals_and_hash_code_on_mutable_classes
- avoid_escaping_inner_quotes
- avoid_field_initializers_in_const_classes
- avoid_function_literals_in_foreach_calls
- avoid_futureor_void
- avoid_implementing_value_types
- avoid_init_to_null
- avoid_js_rounded_ints
- avoid_multiple_declarations_per_line
- avoid_null_checks_in_equality_operators
- avoid_positional_boolean_parameters
- avoid_print
- avoid_private_typedef_functions
- avoid_redundant_argument_values
- avoid_relative_lib_imports
- avoid_renaming_method_parameters
- avoid_return_types_on_setters
- avoid_returning_null_for_void
- avoid_returning_this
- avoid_setters_without_getters
- avoid_shadowing_type_parameters
- avoid_single_cascade_in_expression_statements
- avoid_slow_async_io
- avoid_type_to_string
- avoid_types_as_parameter_names
- avoid_unnecessary_containers
- avoid_unused_constructor_parameters
- avoid_void_async
- avoid_web_libraries_in_flutter
- await_only_futures
- camel_case_extensions
- camel_case_types
- cancel_subscriptions
- cascade_invocations
- cast_nullable_to_non_nullable
- close_sinks
- collection_methods_unrelated_type
- combinators_ordering
- comment_references
- conditional_uri_does_not_exist
- constant_identifier_names
- control_flow_in_finally
- curly_braces_in_flow_control_structures
- dangling_library_doc_comments
- depend_on_referenced_packages
- deprecated_consistency
- deprecated_member_use_from_same_package
- directives_ordering
- discarded_futures
- do_not_use_environment
- document_ignores
- empty_catches
- empty_constructor_bodies
- empty_statements
- eol_at_end_of_file
- exhaustive_cases
- file_names
- flutter_style_todos
- hash_and_equals
- implementation_imports
- implicit_call_tearoffs
- implicit_reopen
- invalid_case_patterns
- invalid_runtime_check_with_js_interop_types
- join_return_with_assignment
- leading_newlines_in_multiline_strings
- library_annotations
- library_names
- library_prefixes
- library_private_types_in_public_api
- lines_longer_than_80_chars
- literal_only_boolean_expressions
- matching_super_parameters
- missing_code_block_language_in_doc_comment
- missing_whitespace_between_adjacent_strings
- no_adjacent_strings_in_list
- no_default_cases
- no_duplicate_case_values
- no_leading_underscores_for_library_prefixes
- no_leading_underscores_for_local_identifiers
- no_literal_bool_comparisons
- no_logic_in_create_state
- no_runtimeType_toString
- no_self_assignments
- no_wildcard_variable_uses
- non_constant_identifier_names
- noop_primitive_operations
- null_check_on_nullable_type_parameter
- null_closures
- one_member_abstracts
- only_throw_errors
- overridden_fields
- package_names
- package_prefixed_library_names
- parameter_assignments
- prefer_adjacent_string_concatenation
- prefer_asserts_in_initializer_lists
- prefer_asserts_with_message
- prefer_collection_literals
- prefer_conditional_assignment
- prefer_const_constructors
- prefer_const_constructors_in_immutables
- prefer_const_declarations
- prefer_const_literals_to_create_immutables
- prefer_constructors_over_static_methods
- prefer_contains
- prefer_expression_function_bodies
- prefer_final_fields
- prefer_final_in_for_each
- prefer_final_locals
- prefer_final_parameters
- prefer_for_elements_to_map_fromIterable
- prefer_foreach
- prefer_function_declarations_over_variables
- prefer_generic_function_type_aliases
- prefer_if_elements_to_conditional_expressions
- prefer_if_null_operators
- prefer_initializing_formals
- prefer_inlined_adds
- prefer_int_literals
- prefer_interpolation_to_compose_strings
- prefer_is_empty
- prefer_is_not_empty
- prefer_is_not_operator
- prefer_iterable_whereType
- prefer_mixin
- prefer_null_aware_method_calls
- prefer_null_aware_operators
- prefer_relative_imports
- prefer_single_quotes
- prefer_spread_collections
- prefer_typing_uninitialized_variables
- prefer_void_to_null
- provide_deprecation_message
- public_member_api_docs
- recursive_getters
- require_trailing_commas
- secure_pubspec_urls
- sized_box_for_whitespace
- sized_box_shrink_expand
- slash_for_doc_comments
- sort_child_properties_last
- sort_constructors_first
- sort_pub_dependencies
- sort_unnamed_constructors_first
- specify_nonobvious_local_variable_types
- specify_nonobvious_property_types
- strict_top_level_inference
- switch_on_type
- test_types_in_equals
- throw_in_finally
- tighten_type_of_initializing_formals
- type_annotate_public_apis
- type_init_formals
- type_literal_in_constant_pattern
- unawaited_futures
- unintended_html_in_doc_comment
- unnecessary_async
- unnecessary_await_in_return
- unnecessary_brace_in_string_interps
- unnecessary_breaks
- unnecessary_const
- unnecessary_constructor_name
- unnecessary_getters_setters
- unnecessary_ignore
- unnecessary_lambdas
- unnecessary_late
- unnecessary_library_directive
- unnecessary_library_name
- unnecessary_new
- unnecessary_null_aware_assignments
- unnecessary_null_aware_operator_on_extension_on_nullable
- unnecessary_null_checks
- unnecessary_null_in_if_null_operators
- unnecessary_nullable_for_final_variable_declarations
- unnecessary_overrides
- unnecessary_parenthesis
- unnecessary_raw_strings
- unnecessary_statements
- unnecessary_string_escapes
- unnecessary_string_interpolations
- unnecessary_this
- unnecessary_to_list_in_spreads
- unnecessary_unawaited
- unnecessary_underscores
- unreachable_from_main
- unrelated_type_equality_checks
- unsafe_variance
- use_build_context_synchronously
- use_colored_box
- use_decorated_box
- use_enums
- use_full_hex_values_for_flutter_colors
- use_function_type_syntax_for_parameters
- use_if_null_to_convert_nulls_to_bools
- use_is_even_rather_than_modulo
- use_key_in_widget_constructors
- use_late_for_private_fields_and_variables
- use_named_constants
- use_raw_strings
- use_rethrow_when_possible
- use_setters_to_change_properties
- use_string_buffers
- use_string_in_part_of_directives
- use_super_parameters
- use_test_throws_matchers
- use_to_and_as_if_applicable
- use_truncating_division
- valid_regexps
- void_checks
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

14
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@@ -0,0 +1,56 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "de.creativedragonslayer.dragon_ledger"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
// Flag to enable support for the new language APIs
isCoreLibraryDesugaringEnabled = true
// Sets Java compatibility to Java 11
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
}
defaultConfig {
multiDexEnabled = true
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "de.creativedragonslayer.dragon_ledger"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}
flutter {
source = "../.."
}

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,52 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="DragonLedger"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:turnScreenOn="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<receiver
android:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver"
android:exported="false" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
</manifest>

View File

@@ -0,0 +1,6 @@
package de.creativedragonslayer.dragon_ledger;
import io.flutter.embedding.android.FlutterActivity;
public class MainActivity extends FlutterActivity {
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#131636</color>
</resources>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

24
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip

View File

@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.9.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}
include(":app")

BIN
assets/icon/icon.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

4
devtools_options.yaml Normal file
View File

@@ -0,0 +1,4 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
- drift: true

View File

@@ -0,0 +1,34 @@
# flutter pub run flutter_launcher_icons
flutter_launcher_icons:
image_path: "assets/icon/icon.png"
android: true
# image_path_android: "assets/icon/icon.png"
min_sdk_android: 21 # android min sdk min:16, default 21
adaptive_icon_background: "#131636"
adaptive_icon_foreground: "assets/icon/icon.png"
# adaptive_icon_foreground_inset: 16
# adaptive_icon_monochrome: "assets/icon/monochrome.png"
ios: false
# image_path_ios: "assets/icon/icon.png"
remove_alpha_ios: true
# image_path_ios_dark_transparent: "assets/icon/icon_dark.png"
# image_path_ios_tinted_grayscale: "assets/icon/icon_tinted.png"
# desaturate_tinted_to_grayscale_ios: true
# background_color_ios: "#ffffff"
web:
generate: true
image_path: "assets/icon/icon.png"
background_color: "#50a7fa"
theme_color: "#50a7fa"
windows:
generate: true
image_path: "assets/icon/icon.png"
icon_size: 48 # min:48, max:256, default: 48
macos:
generate: false
image_path: "path/to/image.png"

View File

@@ -0,0 +1,204 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:flutter/material.dart';
import '../Entities/drift_database.dart';
import '../Pages/Dialog/dialog_action.dart';
import '../Pages/Dialog/dialog_input_field.dart';
import '../Pages/Dialog/dialog_type_enum.dart';
import '../Pages/Dialog/dynamic_dialog.dart';
import '../Repositories/account_repository.dart';
/// Steuert die Interaktion mit den Accounts
class AccountController {
/// Gibt die aktuell gültige Instanz der Klasse zurück
factory AccountController() => _instance;
/// Erstellt eine neue Instanz dieser Klasse
AccountController._internal() {
_newAccountDialog = DynamicDialog(
title: 'Neues Konto erstellen',
icon: Icons.account_balance_wallet,
inputFields: [
const DialogInputField(id: 'name', label: 'Name', autoFocus: true),
],
actions: [
DialogAction(label: 'Abbruch'),
DialogAction(
label: 'Speichern',
isPrimary: true,
onPressed: _saveNewAccount,
),
],
);
_errorNameEmptyDialog = DynamicDialog(
title: 'Fehler!',
icon: Icons.error,
content: const Text('Der Name des Kontos darf nicht leer sein!'),
dialogType: DialogTypeEnum.error,
);
_accountCreatedDialog = DynamicDialog(
title: 'Account erstellt!',
icon: Icons.check_circle,
content: const Text('Das Konto wurde erfolgreich erstellt!'),
dialogType: DialogTypeEnum.success,
);
}
static final AccountController _instance = AccountController._internal();
final AccountRepository _accountRepository = AccountRepository();
DynamicDialog? _newAccountDialog;
DynamicDialog? _errorNameEmptyDialog;
DynamicDialog? _accountCreatedDialog;
final ValueNotifier<Account?> _selected = ValueNotifier<Account?>(null);
/// Stellt das ausgewählte Konto dar, das angezeigt wird
ValueNotifier<Account?> get selected {
if (_selected.value == null) {
unawaited(
updateAccounts().then((_) {
_selected.value = accounts.value.firstOrNull;
}),
);
}
return _selected;
}
set selected(final Account selected) {
_selected.value = selected;
}
final ValueNotifier<List<Account>> _accounts = ValueNotifier<List<Account>>(
[],
);
/// Stellt die Liste der Konten dar
ValueNotifier<List<Account>> get accounts {
if (_accounts.value == []) {
unawaited(updateAccounts());
}
return _accounts;
}
set accounts(final List<Account> accounts) {
_accounts.value = accounts;
}
/// Gibt die gespeicherten Konten als Liste zurück
Future<void> updateAccounts() async {
final List<Account> accounts = await _accountRepository.findBy(
orderBy: 'nameAsc',
);
_accounts.value = accounts;
if (_selected.value == null && accounts.firstOrNull != null) {
_selected.value = accounts.firstOrNull;
}
}
/// Startet den Prozess, um ein neues Konto anzulegen
void newAccountHandler() {
unawaited(_newAccountDialog?.show());
}
/// Startet den Prozess, um ein Konto umzubenennen
Future<void> renameAccountHandler(final int accountId) async {
final Account? account = await _accountRepository.find(accountId);
if (account != null) {
final renameAccountDialog = DynamicDialog(
title: '${account.name} umbenennen',
icon: Icons.edit,
inputFields: [
DialogInputField(
id: 'name',
label: 'Name',
initialValue: account.name,
autoFocus: true,
),
],
actions: [
DialogAction(label: 'Abbruch'),
DialogAction(
label: 'Speichern',
isPrimary: true,
onPressed: _renameAccount,
),
],
hiddenValues: {'account': account},
);
unawaited(renameAccountDialog.show());
}
}
/// Startet den Prozess, um ein Konto zu löschen
Future<void> deleteAccountHandler(final int accountId) async {
final Account? account = await _accountRepository.find(accountId);
if (account != null) {
final deleteAccountDialog = DynamicDialog(
dialogType: DialogTypeEnum.error,
title: '${account.name} löschen',
content: Text('Willst du ${account.name} wirklich löschen?'),
icon: Icons.delete_forever,
actions: [
DialogAction(label: 'Abbruch', isPrimary: true),
DialogAction(label: 'Löschen', onPressed: _deleteAccount),
],
hiddenValues: {'account': account},
);
unawaited(deleteAccountDialog.show());
}
}
Future<void> _saveNewAccount(final Map<String, dynamic> values) async {
if (values['name'] == null || values['name'] == '') {
await _errorNameEmptyDialog?.show();
} else {
final account = AccountsCompanion(name: Value(values['name']!));
await _accountRepository.add(account);
await _accountCreatedDialog?.show();
await updateAccounts();
}
}
Future<void> _renameAccount(final Map<String, dynamic> values) async {
if (values['account'] != null && values['name'] != null) {
final Account account = values['account'];
final acc = AccountsCompanion(
id: Value(account.id),
name: Value(values['name']),
);
await _accountRepository.update(acc);
await updateAccounts();
if (account == selected.value) {
selected.value = await _accountRepository.find(account.id);
}
}
}
Future<void> _deleteAccount(final Map<String, dynamic> values) async {
if (values['account'] != null) {
final Account account = values['account'];
await _accountRepository.remove(account);
await updateAccounts();
if (account == _selected.value) {
_selected.value = _accounts.value.firstOrNull;
}
}
}
}

View File

@@ -0,0 +1,68 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:isolate_manager/isolate_manager.dart';
import 'package:uuid/uuid.dart';
import 'package:workmanager/workmanager.dart';
import '../Tasks/BackgroundHandler/workers.dart';
import '../Tasks/BackgroundHandler/workmanager_workers.dart';
/// Erstellt Hintergrundtasks und führt diese aus
class BackgroundTaskController {
/// Erstellt eine neue Instanz dieser Klasse
BackgroundTaskController() {
if (!kIsWeb && Platform.isAndroid) {
unawaited(Workmanager().initialize(callbackDispatcher));
unawaited(
Workmanager().registerPeriodicTask(
'generate-transactions',
'generate_transactions',
frequency: const Duration(minutes: 30),
initialDelay: const Duration(minutes: 1),
),
);
unawaited(
Workmanager().registerPeriodicTask(
'show-notifications',
'show_notifications',
frequency: const Duration(minutes: 120),
initialDelay: const Duration(minutes: 5),
),
);
} else {
unawaited(
IsolateManager.runFunction(runTask, {
'taskName': 'generate_transactions',
'initialDelayMinutes': 1,
'frequencyMinutes': 30,
}),
);
if (!kIsWeb) {
unawaited(
IsolateManager.runFunction(runTask, {
'taskName': 'show_notifications',
'initialDelayMinutes': 5,
'frequencyMinutes': 120,
}),
);
}
}
}
/// Führt den angegebenen Task sofort aus.
static Future<void> run(final String taskName) async {
if (!kIsWeb && Platform.isAndroid) {
final String uuid = const Uuid().v4();
unawaited(Workmanager().registerOneOffTask('$taskName-$uuid', taskName));
} else {
unawaited(IsolateManager.runFunction(runTask, {
'taskName': taskName,
'initialDelayMinutes': 0,
'frequencyMinutes': 0,
}));
}
}
}

View File

@@ -0,0 +1,204 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../Entities/drift_database.dart';
import '../Repositories/transaction_repository.dart';
import '../Services/date_service.dart';
import '../Services/initializer.dart';
import '../Services/transaction_service.dart';
import 'port_controller.dart';
import 'transaction_controller.dart';
/// Kümmert sich um die Verwendung von Benachrichtigungen auf verschiedenen
/// Plattformen
class LocalNotifications {
/// Gibt die aktuell gültige Instanz dieser Klasse zurück
factory LocalNotifications() => _instance;
LocalNotifications._internal() {
unawaited(_initialise());
}
static final _instance = LocalNotifications._internal();
final FlutterLocalNotificationsPlugin _localNotificationsPlugin =
FlutterLocalNotificationsPlugin();
final Initializer _initializer = Initializer();
Future<void> _initialise() async {
if (_initializer.initialized) {
return;
}
await _localNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.requestNotificationsPermission();
const AndroidInitializationSettings androidInitializationSettings =
AndroidInitializationSettings('ic_launcher_foreground');
const LinuxInitializationSettings linuxInitializationSettings =
LinuxInitializationSettings(defaultActionName: 'Öffnen');
const InitializationSettings initializationSettings =
InitializationSettings(
android: androidInitializationSettings,
linux: linuxInitializationSettings,
);
await _localNotificationsPlugin.initialize(
initializationSettings,
onDidReceiveNotificationResponse: _onDidReceiveNotificationResponse,
onDidReceiveBackgroundNotificationResponse: notificationTapBackground,
);
if (!kIsWeb && !Platform.isLinux) {
final NotificationAppLaunchDetails? notificationAppLaunchDetails =
await _localNotificationsPlugin.getNotificationAppLaunchDetails();
if (notificationAppLaunchDetails?.didNotificationLaunchApp ?? false) {
_onDidReceiveNotificationResponse(
NotificationResponse(
notificationResponseType:
NotificationResponseType.selectedNotification,
payload:
notificationAppLaunchDetails!.notificationResponse?.payload,
),
);
}
}
_initializer.setInitialized();
}
/// Zeigt eine Benachrichtigung an, die einen dazu auffordert,
/// automatisch generierte Transaktionen zu überprüfen
Future<void> showTransactionsToCheckNotification(
final List<Transaction> transactions,
) async {
await _initializer.waitUntilInitialized();
const AndroidNotificationDetails androidNotificationDetails =
AndroidNotificationDetails(
'transactions_to_check',
'Transaktionen zu prüfen',
channelDescription:
'Zeigt an, dass es zu prüfende Transaktionen gibt',
actions: <AndroidNotificationAction>[
AndroidNotificationAction('mark_checked', 'Als geprüft markieren'),
AndroidNotificationAction(
'show_transactions',
'Anzeigen',
showsUserInterface: true,
),
// TODO: Prüfen, ob die App geöffnet wird
],
);
const LinuxNotificationDetails linuxNotificationDetails =
LinuxNotificationDetails(
actions: [
LinuxNotificationAction(
key: 'mark_checked',
label: 'Als geprüft markieren',
),
LinuxNotificationAction(
key: 'show_transactions',
label: 'Anzeigen',
),
],
);
const NotificationDetails notificationDetails = NotificationDetails(
android: androidNotificationDetails,
linux: linuxNotificationDetails,
);
final String title;
final String body;
if (transactions.length == 1) {
title = 'Transaktion prüfen';
body =
'Es wurde eine neue Transaktion anhand einer '
'wiederkehrenden Transaktion erstellt:\n'
'${transactions[0].name} - '
'${DateService.dateFormat.format(transactions[0].date!)} - '
'${transactions[0].amount}\n\n'
'Diese muss überprüft werden!';
} else {
int counter = 0;
final List<String> transactionsToShow = [];
for (final Transaction transaction in transactions) {
if (counter >= 10) {
break;
}
transactionsToShow.add(
'${transaction.name} - '
'${DateService.dateFormat.format(transaction.date!)} - '
'${transaction.amount}',
);
counter++;
}
title = 'Transaktionen prüfen';
body =
'Es wurden neue Transaktionen anhand '
'wiederkehrender Transaktionen erstellt:\n'
'${transactionsToShow.join('\n')}\n\n'
'Diese müssen überprüft werden!';
}
await _localNotificationsPlugin.show(
0,
title,
body,
notificationDetails,
payload: TransactionService.transactionsToString(transactions),
);
}
void _onDidReceiveNotificationResponse(
final NotificationResponse notificationResponse,
) {
if (notificationResponse.actionId == 'mark_checked') {
TransactionRepository().markTransactionsAsChecked(
TransactionService.transactionsFromString(notificationResponse.payload),
);
if (kIsWeb) {
unawaited(TransactionController().updateTransactions());
} else {
PortController().getPort('update-transactions')?.send('ready');
}
} else {
if (kIsWeb) {
TransactionController().goToTransactions(
transactions: TransactionService.transactionsFromString(
notificationResponse.payload,
),
);
} else {
PortController()
.getPort('go-to-transactions')
?.send(notificationResponse.payload);
}
}
}
/// Wird von den LocalNotifications aufgerufen,
/// wenn eine Aktion im Hintergrund abgehandelt werden soll
@pragma('vm:entry-point')
static void notificationTapBackground(
final NotificationResponse notificationResponse,
) {
if (notificationResponse.actionId == 'mark_checked') {
TransactionRepository().markTransactionsAsChecked(
TransactionService.transactionsFromString(notificationResponse.payload),
);
}
}
}

View File

@@ -0,0 +1,81 @@
import 'dart:async';
import 'dart:isolate';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:logger/logger.dart';
/// Ein PortController mit verschiedenen Funktionen zur Kommunikation zwischen
/// main und anderen Isolates
class PortController {
/// Gibt eine Instanz dieser Klasse zurück
factory PortController() => _instance;
PortController._internal() {
if (!kIsWeb && ServicesBinding.rootIsolateToken != null) {
_registerRootIsolateTokenSender();
}
}
static final PortController _instance = PortController._internal();
final Logger _logger = Logger();
/// Fügt einen Port mit [name] zum NameServer hinzu
void addPort(final SendPort sendPort, final String name) {
IsolateNameServer.removePortNameMapping(name);
IsolateNameServer.registerPortWithName(sendPort, name);
}
/// Gibt einen Port mit [name] vom NameServer zurück, falls gefunden
SendPort? getPort(final String name) =>
IsolateNameServer.lookupPortByName(name);
/// Gibt das [RootIsolateToken] der main-Isolate zurück
Future<RootIsolateToken?> getRootIsolateToken() async {
_logger.d('Trying to retrieve RootIsolateToken...');
final ReceivePort receivePort = ReceivePort();
final SendPort? rootPort = IsolateNameServer.lookupPortByName(
'root-isolate-token',
);
if (rootPort == null) {
_logger.e("Couldn't get Port from IsolateNameServer!");
receivePort.close();
return null;
}
_logger.i('Sending communication attempt...');
rootPort.send(receivePort.sendPort);
try {
final dynamic message = await receivePort.first;
if (message is RootIsolateToken) {
_logger.i('Got RootIsolateToken, returning...');
return message;
}
_logger.w("Couldn't get RootIsolateToken!");
return null;
} finally {
_logger.i('Closing receivePort...');
receivePort.close();
}
}
void _registerRootIsolateTokenSender() {
final ReceivePort receivePort = ReceivePort()
..listen((final value) {
_logger.d('Received Message with $value');
if (value is SendPort) {
value.send(ServicesBinding.rootIsolateToken);
}
});
addPort(receivePort.sendPort, 'root-isolate-token');
}
}

View File

@@ -0,0 +1,313 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:flutter/material.dart';
import '../Entities/drift_database.dart';
import '../Entities/time_frame_enum.dart';
import '../Pages/Dialog/dialog_action.dart';
import '../Pages/Dialog/dialog_input_field.dart';
import '../Pages/Dialog/dialog_input_field_select_item.dart';
import '../Pages/Dialog/dialog_input_field_type_enum.dart';
import '../Pages/Dialog/dialog_type_enum.dart';
import '../Pages/Dialog/dynamic_dialog.dart';
import '../Repositories/recurring_transacation_repository.dart';
import 'account_controller.dart';
import 'background_task_controller.dart';
/// Steuert die Interaktion mit den wiederkehrenden Transaktionen
class RecurringTransactionController {
/// Gibt die aktuell gültige Instanz der Klasse zurück
factory RecurringTransactionController() => _instance;
/// Erstellt eine neue Instanz dieser Klasse
RecurringTransactionController._internal() {
_newRecurringTransactionDialog = DynamicDialog(
title: 'Neue wiederkehrende Transaktion erstellen',
icon: Icons.repeat,
inputFields: [
const DialogInputField(id: 'name', label: 'Name', autoFocus: true),
const DialogInputField(
id: 'startDate',
label: 'Anfangsdatum',
keyboardType: TextInputType.datetime,
inputType: DialogInputFieldTypeEnum.date,
),
DialogInputField(
id: 'timeFrame',
label: 'Zeitraum',
inputType: DialogInputFieldTypeEnum.select,
selectItems: TimeFrameEnum.values
.map(
(final value) => DialogInputFieldSelectItem(
id: value.index,
value: value.name,
),
)
.toList(),
),
const DialogInputField(
id: 'amount',
label: 'Betrag €',
keyboardType: TextInputType.number,
),
],
actions: [
DialogAction(label: 'Abbruch'),
DialogAction(
label: 'Speichern',
isPrimary: true,
onPressed: _saveNewRecurringTransaction,
),
],
);
_errorRecurringTransactionValueEmptyDialog = DynamicDialog(
title: 'Fehler!',
icon: Icons.error,
content: const Text('Es wurden nicht alle Werte eingetragen!'),
dialogType: DialogTypeEnum.error,
);
_recurringTransactionCreatedDialog = DynamicDialog(
title: 'Wiederkehrende Transaktion erstellt!',
icon: Icons.check_circle,
content: const Text(
'Die wiederkehrende Transaktion wurde erfolgreich erstellt!',
),
dialogType: DialogTypeEnum.success,
);
_selectedAccount = _accountController.selected.value;
_accountController.selected.addListener(() {
_selectedAccount = _accountController.selected.value;
unawaited(updateRecurringTransactions());
});
unawaited(updateRecurringTransactions());
}
static final RecurringTransactionController _instance =
RecurringTransactionController._internal();
final RecurringTransactionRepository _recurringTransactionRepository =
RecurringTransactionRepository();
final AccountController _accountController = AccountController();
DynamicDialog? _newRecurringTransactionDialog;
DynamicDialog? _errorRecurringTransactionValueEmptyDialog;
DynamicDialog? _recurringTransactionCreatedDialog;
final ValueNotifier<List<RecurringTransaction>> _recurringTransactions =
ValueNotifier<List<RecurringTransaction>>([]);
/// Stellt die Liste der wiederkehrenden Transaktionen dar
ValueNotifier<List<RecurringTransaction>> get recurringTransactions {
if (_recurringTransactions.value == []) {
unawaited(updateRecurringTransactions());
}
return _recurringTransactions;
}
set recurringTransactions(
final List<RecurringTransaction> recurringTransactions,
) {
_recurringTransactions.value = recurringTransactions;
}
Account? _selectedAccount;
/// Aktualisiert die gespeicherten wiederkehrenden Transaktionen
/// in der internen Liste.
Future<void> updateRecurringTransactions() async {
if (_selectedAccount != null) {
final List<RecurringTransaction> recurringTransactions =
await _recurringTransactionRepository.findBy(
account: _selectedAccount,
orderBy: 'nameAsc',
);
_recurringTransactions.value = recurringTransactions;
}
}
/// Startet den Prozess, um eine neue wiederkehrende Transaktion anzulegen
void newRecurringTransactionHandler() {
unawaited(_newRecurringTransactionDialog?.show());
}
/// Startet den Prozess, um eine wiederkehrende Transaktion zu bearbeiten
Future<void> editRecurringTransactionHandler(
final int recurringTransactionId,
) async {
final RecurringTransaction? recurringTransaction =
await _recurringTransactionRepository.find(recurringTransactionId);
if (recurringTransaction != null) {
final editRecurringTransactionDialog = DynamicDialog(
title: '${recurringTransaction.name} bearbeiten',
icon: Icons.edit,
inputFields: [
DialogInputField(
id: 'name',
label: 'Name',
autoFocus: true,
initialValue: recurringTransaction.name,
),
DialogInputField(
id: 'startDate',
label: 'Anfangsdatum',
keyboardType: TextInputType.datetime,
inputType: DialogInputFieldTypeEnum.date,
initialValue: recurringTransaction.startDate,
),
DialogInputField(
id: 'timeFrame',
label: 'Zeitraum',
inputType: DialogInputFieldTypeEnum.select,
selectItems: TimeFrameEnum.values
.map(
(final value) => DialogInputFieldSelectItem(
id: value.index,
value: value.name,
),
)
.toList(),
initialValue: recurringTransaction.timeFrame,
),
DialogInputField(
id: 'amount',
label: 'Betrag €',
keyboardType: TextInputType.number,
initialValue: recurringTransaction.amount.toString(),
),
],
actions: [
DialogAction(label: 'Abbruch'),
DialogAction(
label: 'Bearbeiten',
isPrimary: true,
onPressed: _editRecurringTransaction,
),
],
hiddenValues: {'recurringTransaction': recurringTransaction},
);
unawaited(editRecurringTransactionDialog.show());
}
}
/// Startet den Prozess, um eine wiederkehrende Transaktion zu löschen
Future<void> deleteRecurringTransactionHandler(
final int recurringTransactionId,
) async {
final RecurringTransaction? recurringTransaction =
await _recurringTransactionRepository.find(recurringTransactionId);
if (recurringTransaction != null) {
final deleteRecurringTransactionDialog = DynamicDialog(
dialogType: DialogTypeEnum.error,
title: '${recurringTransaction.name} löschen',
content: Text(
'Willst du ${recurringTransaction.name} wirklich löschen?',
),
icon: Icons.delete_forever,
actions: [
DialogAction(label: 'Abbruch', isPrimary: true),
DialogAction(
label: 'Löschen',
onPressed: _deleteRecurringTransaction,
),
],
hiddenValues: {'recurringTransaction': recurringTransaction},
);
unawaited(deleteRecurringTransactionDialog.show());
}
}
Future<void> _saveNewRecurringTransaction(
final Map<String, dynamic> values,
) async {
if (values['name'] == null ||
values['name'] == '' ||
values['startDate'] == null ||
values['startDate'] == '' ||
values['timeFrame'] == null ||
values['timeFrame'] == '' ||
values['amount'] == null ||
values['amount'] == '' ||
_selectedAccount == null) {
await _errorRecurringTransactionValueEmptyDialog?.show();
} else {
final DialogInputFieldSelectItem timeFrame = values['timeFrame'];
values['timeFrame'] = TimeFrameEnum.values[timeFrame.id];
final String amount = values['amount'];
values['amount'] = double.tryParse(amount);
if (values['amount'] == null || values['amount'] == 0) {
await _errorRecurringTransactionValueEmptyDialog?.show();
} else {
final recurringTransaction = RecurringTransactionsCompanion(
name: Value(values['name']),
startDate: Value(values['startDate']),
timeFrame: Value(values['timeFrame']),
amount: Value(values['amount']),
accountId: Value(_selectedAccount!.id),
);
await _recurringTransactionRepository.add(recurringTransaction);
await _recurringTransactionCreatedDialog?.show();
await updateRecurringTransactions();
unawaited(BackgroundTaskController.run('generate_transactions'));
}
}
}
Future<void> _editRecurringTransaction(
final Map<String, dynamic> values,
) async {
if (values['recurringTransaction'] != null &&
values['recurringTransaction'] != null &&
values['startDate'] != null &&
values['startDate'] != '' &&
values['timeFrame'] != null &&
values['timeFrame'] != '' &&
values['amount'] != null &&
values['amount'] != '') {
final DialogInputFieldSelectItem timeFrame = values['timeFrame'];
values['timeFrame'] = TimeFrameEnum.values[timeFrame.id];
final String amount = values['amount'];
values['amount'] = double.tryParse(amount);
if (values['amount'] != null && values['amount'] != 0) {
final RecurringTransaction recurringTransaction =
values['recurringTransaction'];
final rtc = RecurringTransactionsCompanion(
id: Value(recurringTransaction.id),
name: Value(values['name']),
startDate: Value(values['startDate']),
timeFrame: Value(values['timeFrame']),
amount: Value(values['amount']),
accountId: Value(recurringTransaction.accountId),
);
await _recurringTransactionRepository.update(rtc);
await updateRecurringTransactions();
}
}
}
Future<void> _deleteRecurringTransaction(
final Map<String, dynamic> values,
) async {
if (values['recurringTransaction'] != null) {
final RecurringTransaction recurringTransaction =
values['recurringTransaction'];
await _recurringTransactionRepository.remove(recurringTransaction);
await updateRecurringTransactions();
}
}
}

View File

@@ -0,0 +1,312 @@
import 'dart:async';
import 'dart:isolate';
import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:logger/logger.dart';
import 'package:routemaster/routemaster.dart';
import '../Entities/drift_database.dart';
import '../Pages/Dialog/dialog_action.dart';
import '../Pages/Dialog/dialog_input_field.dart';
import '../Pages/Dialog/dialog_input_field_type_enum.dart';
import '../Pages/Dialog/dialog_type_enum.dart';
import '../Pages/Dialog/dynamic_dialog.dart';
import '../Repositories/transaction_repository.dart';
import '../Services/navigation_service.dart';
import '../Services/transaction_service.dart';
import 'account_controller.dart';
import 'port_controller.dart';
/// Steuert die Interaktion mit den Transaktionen
class TransactionController {
/// Gibt die aktuell gültige Instanz der Klasse zurück
factory TransactionController() => _instance;
/// Erstellt eine neue Instanz dieser Klasse
TransactionController._internal() {
_newTransactionDialog = DynamicDialog(
title: 'Neue Transaktion erstellen',
icon: Icons.swap_horiz,
inputFields: [
const DialogInputField(id: 'name', label: 'Name', autoFocus: true),
DialogInputField(
id: 'date',
label: 'Transaktionsdatum',
keyboardType: TextInputType.datetime,
inputType: DialogInputFieldTypeEnum.date,
initialValue: DateTime.now(),
),
const DialogInputField(
id: 'amount',
label: 'Betrag €',
keyboardType: TextInputType.number,
),
],
actions: [
DialogAction(label: 'Abbruch'),
DialogAction(
label: 'Speichern',
isPrimary: true,
onPressed: _saveNewTransaction,
),
],
);
_errorTransactionValueEmptyDialog = DynamicDialog(
title: 'Fehler!',
icon: Icons.error,
content: const Text('Es wurden nicht alle Werte eingetragen!'),
dialogType: DialogTypeEnum.error,
);
_transactionCreatedDialog = DynamicDialog(
title: 'Transaktion erstellt!',
icon: Icons.check_circle,
content: const Text('Die Transaktion wurde erfolgreich erstellt!'),
dialogType: DialogTypeEnum.success,
);
_selectedAccount = _accountController.selected.value;
_accountController.selected.addListener(() {
_selectedAccount = _accountController.selected.value;
unawaited(updateTransactions());
});
if (!kIsWeb) {
final ReceivePort updateTransactionsReceivePort = ReceivePort()
..listen((_) {
Logger().i('Received update-transactions signal');
unawaited(updateTransactions());
});
PortController().addPort(
updateTransactionsReceivePort.sendPort,
'update-transactions',
);
final ReceivePort gotToTransactionsReceivePort = ReceivePort()
..listen((final value) {
Logger().i('Received go-to-transactions signal');
final List<Transaction> transactions =
TransactionService.transactionsFromString(value);
goToTransactions(transactions: transactions);
});
PortController().addPort(
gotToTransactionsReceivePort.sendPort,
'go-to-transactions',
);
}
unawaited(updateTransactions());
}
static final TransactionController _instance =
TransactionController._internal();
final TransactionRepository _transactionRepository = TransactionRepository();
final AccountController _accountController = AccountController();
DynamicDialog? _newTransactionDialog;
DynamicDialog? _errorTransactionValueEmptyDialog;
DynamicDialog? _transactionCreatedDialog;
final ValueNotifier<List<Transaction>> _transactions =
ValueNotifier<List<Transaction>>([]);
/// Stellt die Liste der Transaktionen dar
ValueNotifier<List<Transaction>> get transactions {
if (_transactions.value == []) {
unawaited(updateTransactions());
}
return _transactions;
}
set transactions(final List<Transaction> transactions) {
_transactions.value = transactions;
}
Account? _selectedAccount;
/// Aktualisiert die Transaktionen in der internen Liste
Future<void> updateTransactions() async {
if (_selectedAccount != null) {
final List<Transaction> transactions = await _transactionRepository
.findBy(account: _selectedAccount, orderBy: 'dateDesc');
_transactions.value = transactions;
}
}
/// Wechselt zur Übersicht über die Transaktionen
void goToTransactions({final List<Transaction>? transactions}) {
final BuildContext? context = NavigationService.getCurrentBuildContext();
if (context != null && transactions != null) {
if (transactions.length == 1) {
Routemaster.of(
context,
).push('/trend', queryParameters: {'name': transactions.first.name});
}
Routemaster.of(context).replace('/trend');
}
}
/// Startet den Prozess, um eine neue Transaktion anzulegen
void newTransactionHandler() {
unawaited(_newTransactionDialog?.show());
}
/// Startet den Prozess, um eine Transaktion zu bearbeiten
Future<void> editTransactionHandler(final int transactionId) async {
final Transaction? transaction = await _transactionRepository.find(
transactionId,
);
if (transaction != null) {
final String title;
final String submitButtonText;
if (transaction.checked) {
title = '${transaction.name} bearbeiten';
submitButtonText = 'Bearbeiten';
} else {
title = '${transaction.name} prüfen';
submitButtonText = 'Prüfen';
}
final editTransactionDialog = DynamicDialog(
title: title,
icon: Icons.edit,
inputFields: [
DialogInputField(
id: 'name',
label: 'Name',
autoFocus: true,
initialValue: transaction.name,
),
DialogInputField(
id: 'date',
label: 'Transaktionsdatum',
keyboardType: TextInputType.datetime,
inputType: DialogInputFieldTypeEnum.date,
initialValue: transaction.date,
),
DialogInputField(
id: 'amount',
label: 'Betrag €',
keyboardType: TextInputType.number,
initialValue: transaction.amount.toString(),
),
],
actions: [
DialogAction(label: 'Abbruch'),
DialogAction(
label: submitButtonText,
isPrimary: true,
onPressed: _editTransaction,
),
],
hiddenValues: {'transaction': transaction},
);
unawaited(editTransactionDialog.show());
}
}
/// Startet den Prozess, um eine Transaktion zu löschen
Future<void> deleteTransactionHandler(final int transactionId) async {
final Transaction? transaction = await _transactionRepository.find(
transactionId,
);
if (transaction != null) {
final deleteTransactionDialog = DynamicDialog(
dialogType: DialogTypeEnum.error,
title: '${transaction.name} löschen',
content: Text('Willst du ${transaction.name} wirklich löschen?'),
icon: Icons.delete_forever,
actions: [
DialogAction(label: 'Abbruch', isPrimary: true),
DialogAction(
label: 'Löschen',
onPressed: _deleteTransaction,
),
],
hiddenValues: {'transaction': transaction},
);
unawaited(deleteTransactionDialog.show());
}
}
Future<void> _saveNewTransaction(final Map<String, dynamic> values) async {
if (values['name'] == null ||
values['name'] == '' ||
values['date'] == null ||
values['date'] == '' ||
values['amount'] == null ||
values['amount'] == '' ||
_selectedAccount == null) {
await _errorTransactionValueEmptyDialog?.show();
} else {
final String amount = values['amount'];
values['amount'] = double.tryParse(amount);
if (values['amount'] == null || values['amount'] == 0) {
await _errorTransactionValueEmptyDialog?.show();
} else {
final transaction = TransactionsCompanion(
name: Value(values['name']),
date: Value(values['date']),
amount: Value(values['amount']),
accountId: Value(_selectedAccount!.id),
);
await _transactionRepository.add(transaction);
await _transactionCreatedDialog?.show();
await updateTransactions();
}
}
}
Future<void> _editTransaction(final Map<String, dynamic> values) async {
if (values['transaction'] != null &&
values['transaction'] != null &&
values['date'] != null &&
values['date'] != '' &&
values['amount'] != null &&
values['amount'] != '') {
final String amount = values['amount'];
values['amount'] = double.tryParse(amount);
if (values['amount'] != null && values['amount'] != 0) {
final Transaction transaction = values['transaction'];
final rtc = TransactionsCompanion(
id: Value(transaction.id),
name: Value(values['name']),
date: Value(values['date']),
amount: Value(values['amount']),
accountId: Value(transaction.accountId),
checked: const Value(true),
);
await _transactionRepository.update(rtc);
await updateTransactions();
}
}
}
Future<void> _deleteTransaction(final Map<String, dynamic> values) async {
if (values['transaction'] != null) {
final Transaction transaction = values['transaction'];
await _transactionRepository.remove(transaction);
await updateTransactions();
}
}
}

View File

@@ -0,0 +1,116 @@
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'sync_log_type_enum.dart';
import 'time_frame_enum.dart';
part 'drift_database.g.dart';
/// Eine Tabelle für Konten.
class Accounts extends Table {
/// Eindeutige ID des Kontos
IntColumn get id => integer().autoIncrement()();
/// Name des Kontos
TextColumn get name => text().withDefault(const Constant(''))();
/// Der externe Identifier, wenn woanders gespeichert
IntColumn get externalIdentifier => integer().nullable()();
/// Wann das Konto das letzte mal geupdated wurde
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
}
/// Eine Tabelle für einzelne Transaktionen.
class Transactions extends Table {
/// Eindeutige ID der Transaktion
IntColumn get id => integer().autoIncrement()();
/// Name/Beschreibung der Transaktion
TextColumn get name => text().withDefault(const Constant(''))();
/// Datum der Transaktion
DateTimeColumn get date => dateTime().nullable()();
/// Betrag der Transaktion
RealColumn get amount => real().withDefault(const Constant(0))();
/// Ob diese Transaktion bereits geprüft wurde
BoolColumn get checked => boolean().withDefault(const Constant(true))();
/// Fremdschlüssel zum zugehörigen Konto
IntColumn get accountId => integer().references(Accounts, #id)();
/// Fremdschlüssel zur zugehörigen wiederkehrenden Transaktion,
/// falls vorhanden
IntColumn get recurringTransactionId =>
integer().nullable().references(RecurringTransactions, #id)();
/// Der externe Identifier, wenn woanders gespeichert
IntColumn get externalIdentifier => integer().nullable()();
/// Wann die Transaktion das letzte mal geupdated wurde
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
}
/// Eine Tabelle für wiederkehrende Transaktionen.
class RecurringTransactions extends Table {
/// Eindeutige ID der wiederkehrenden Transaktion
IntColumn get id => integer().autoIncrement()();
/// Name/Beschreibung der wiederkehrenden Transaktion
TextColumn get name => text().withDefault(const Constant(''))();
/// Startdatum der wiederkehrenden Transaktion
DateTimeColumn get startDate => dateTime().nullable()();
/// Zeitlicher Rahmen für die Wiederholung
IntColumn get timeFrame => intEnum<TimeFrameEnum>()();
/// Betrag der wiederkehrenden Transaktion
RealColumn get amount => real().withDefault(const Constant(0))();
/// Fremdschlüssel zum zugehörigen Konto
IntColumn get accountId => integer().references(Accounts, #id)();
/// Der externe Identifier, wenn woanders gespeichert
IntColumn get externalIdentifier => integer().nullable()();
/// Wann die wiederkehrende Transaktion das letzte mal geupdated wurde
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
}
/// Eine Tabelle um den Status der Synchronisation zu loggen
class SyncLog extends Table {
/// Eindeutige ID des SyncLogs
IntColumn get id => integer().autoIncrement()();
/// Der Typ des SyncLogs
IntColumn get type => intEnum<SyncLogTypeEnum>()();
/// Die Beschreibung der Eintragung des SyncLogs
TextColumn get description => text().withDefault(const Constant(''))();
/// Wann dieser SyncLog das letzte mal geupdated wurde
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
}
/// Hauptklasse für die Drift-Datenbank der Anwendung.
@DriftDatabase(tables: [Accounts, Transactions, RecurringTransactions, SyncLog])
class AppDatabase extends _$AppDatabase {
/// Erstellt eine neue Datenbankinstanz
AppDatabase() : super(_openConnection());
@override
int get schemaVersion => 1;
/// Öffnet die Datenbankverbindung
static QueryExecutor _openConnection() => driftDatabase(
name: 'dragon_ledger',
web: DriftWebOptions(
sqlite3Wasm: Uri.parse('sqlite3.wasm'),
driftWorker: Uri.parse('drift_worker.js'),
),
native: const DriftNativeOptions(shareAcrossIsolates: true)
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
/// Ein Listenitem, verwendet um editierbare Listen zu erstellen
class ListItem {
/// Erstellt eine neue Instanz dieser Klasse
ListItem({required this.id, required this.name});
/// Die Id des Eintrags
int id;
/// Der Name des Eintrags
String name;
}

View File

@@ -0,0 +1,15 @@
/// Eine Enum um den Typ der Lognachrichten
/// für den Synchronisationsstatus festzulegen
enum SyncLogTypeEnum {
/// Eine Info
info,
/// Ein Fehler
error,
/// Der Start des Prozesses
start,
/// Das Ende des Prozesses
end,
}

View File

@@ -0,0 +1,14 @@
/// Ein Enum, das bestimmte Zeitspannen darstellt
enum TimeFrameEnum {
/// Für eine tägliche Zeitspanne
daily,
/// Für eine wöchentliche Zeitspanne
weekly,
/// Für eine monatliche Zeitspanne
monthly,
/// Für eine jährliche Zeitspanne
yearly,
}

View File

@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import '../../Controller/account_controller.dart';
import '../../Controller/transaction_controller.dart';
import '../../Repositories/transaction_repository.dart';
/// Gibt eine Übersicht über den aktuellen Kontostand
/// und der Veränderung zum Vormonat zurück
class CurrentBalance extends StatefulWidget {
/// Erstellt eine neue Instanz dieser Klasse
const CurrentBalance({super.key});
@override
State<StatefulWidget> createState() => _CurrentBalanceState();
}
class _CurrentBalanceState extends State<CurrentBalance> {
final TransactionController _transactionController = TransactionController();
final AccountController _accountController = AccountController();
final TransactionRepository _transactionRepository = TransactionRepository();
@override
void initState() {
super.initState();
_transactionController.transactions.addListener(() {
if (mounted) {
setState(() {});
}
});
}
@override
Widget build(final BuildContext context) => FutureBuilder(
future: _getBalance(),
builder:
(
final BuildContext context,
final AsyncSnapshot<(double, double, double)> snapshot,
) {
final ThemeData theme = Theme.of(context);
Widget widget;
if (snapshot.hasData) {
final double balanceOfLastMonth = snapshot.data!.$1;
final double balanceNow = snapshot.data!.$2;
final double balanceOfThisMonth = snapshot.data!.$3;
final double diff = balanceOfThisMonth - balanceOfLastMonth;
widget = Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('Aktuell', style: theme.textTheme.bodyMedium),
const SizedBox(height: 8),
Text(
'${balanceNow.toStringAsFixed(2)}',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text(
'Differenz zum Vormonat',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Row(
children: <Widget>[
Icon(
diff >= 0 ? Icons.arrow_upward : Icons.arrow_downward,
color: diff >= 0 ? Colors.green : Colors.red,
),
const SizedBox(width: 4),
Text(
'${diff.abs().toStringAsFixed(2)}',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: diff >= 0 ? Colors.green : Colors.red,
),
),
],
),
],
),
],
);
} else if (snapshot.hasError) {
widget = Column(
children: [
Icon(Icons.error, color: theme.colorScheme.error),
const Text('Fehler beim holen der letzten Transaktionen!'),
],
);
} else {
widget = const Center(child: CircularProgressIndicator());
}
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: widget,
);
},
);
Future<(double, double, double)> _getBalance() async {
final now = DateTime.now();
final dateOfLastMonth = DateTime(now.year, now.month, 0);
final dateOfThisMonth = DateTime(now.year, now.month + 1, 0);
final double balanceOfLastMonth = await _transactionRepository.balance(
account: _accountController.selected.value,
until: dateOfLastMonth,
);
final double balanceNow = await _transactionRepository.balance(
account: _accountController.selected.value,
until: now,
);
final double balanceOfThisMonth = await _transactionRepository.balance(
account: _accountController.selected.value,
until: dateOfThisMonth,
);
return (balanceOfLastMonth, balanceNow, balanceOfThisMonth);
}
}

View File

@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import '../Misc/monthly_balance_chart.dart';
import 'current_balance.dart';
import 'recent_transactions_list.dart';
/// Eine Seite, die das Dashboard der App darstellt.
///
/// Diese Seite zeigt eine Übersicht über den aktuellen Kontostand,
/// die Entwicklung des Kontostands der letzten Monate sowie
/// die letzten Transaktionen.
class Dashboard extends StatelessWidget {
/// Erstellt eine neue Instanz der Dashboard-Seite.
const Dashboard({super.key});
/// Baut das Dashboard-Widget auf.
/// [context] ist der Build-Kontext
@override
Widget build(final BuildContext context) => const Scaffold(
body: SafeArea(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 24),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
CurrentBalance(),
SizedBox(height: 32),
Text(
'Kontostand pro Monat',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
SizedBox(height: 12),
MonthlyBalanceChart(),
SizedBox(height: 32),
Text(
'Letzte Transaktionen',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
SizedBox(height: 12),
RecentTransactionsList(),
],
),
),
),
),
);
}

View File

@@ -0,0 +1,144 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../Controller/account_controller.dart';
import '../../Controller/transaction_controller.dart';
import '../../Entities/drift_database.dart';
import '../../Repositories/transaction_repository.dart';
/// Eine Liste mit den zuletzt getätigten Transaktionen
class RecentTransactionsList extends StatefulWidget {
/// Erstellt eine neue Instanz dieser Klasse
const RecentTransactionsList({super.key});
@override
State<StatefulWidget> createState() => _RecentTransactionsListState();
}
class _RecentTransactionsListState extends State<RecentTransactionsList> {
final TransactionController _transactionController = TransactionController();
final AccountController _accountController = AccountController();
final TransactionRepository _transactionRepository = TransactionRepository();
@override
void initState() {
super.initState();
_transactionController.transactions.addListener(() {
if (mounted) {
setState(() {});
}
});
}
@override
Widget build(final BuildContext context) {
final Future<List<Transaction>> recentTransactions = _transactionRepository
.findBy(
account: _accountController.selected.value,
dateTo: DateTime.now(),
limit: 5,
orderBy: 'dateDesc',
);
return FutureBuilder(
future: recentTransactions,
builder:
(
final BuildContext context,
final AsyncSnapshot<List<Transaction>> snapshot,
) {
final ThemeData theme = Theme.of(context);
if (snapshot.hasData) {
final List<Widget>? recentTransactionsWidgetList = snapshot.data
?.map(
(final Transaction transaction) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onTap: () {
unawaited(
_transactionController.editTransactionHandler(
transaction.id,
),
);
},
title: Text(
transaction.name,
style: (transaction.checked)
? const TextStyle()
: TextStyle(color: theme.colorScheme.error),
),
trailing: Text(
'${transaction.amount.abs().toStringAsFixed(2)}',
style: TextStyle(
color: transaction.amount == 0
? null
: (transaction.amount < 0
? Colors.green
: Colors.red),
fontWeight: FontWeight.bold,
),
),
),
),
)
.toList();
if (recentTransactionsWidgetList != null &&
recentTransactionsWidgetList.isNotEmpty) {
recentTransactionsWidgetList.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: TextButton(
onPressed: _transactionController.newTransactionHandler,
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.swap_horiz),
SizedBox(width: 10),
Text('Transaktion hinzufügen'),
],
),
),
),
);
return Column(children: [...recentTransactionsWidgetList]);
} else {
if (_accountController.selected.value != null) {
return TextButton(
onPressed: _transactionController.newTransactionHandler,
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.swap_horiz),
SizedBox(width: 10),
Text('Transaktion hinzufügen'),
],
),
);
} else {
return const Text('');
}
}
} else if (snapshot.hasError) {
return Center(
child: Column(
children: [
Icon(Icons.error, color: theme.colorScheme.error),
const Text('Fehler beim holen der letzten Transaktionen!'),
],
),
);
} else {
return const Center(child: CircularProgressIndicator());
}
},
);
}
}

View File

@@ -0,0 +1,14 @@
/// Action/Knopf für den Dialog
class DialogAction {
/// Erstellt eine neue Aktion für den Dialog
DialogAction({required this.label, this.isPrimary = false, this.onPressed});
/// Das Label der Aktion
final String label;
/// Ob es die primäre Aktion ist
final bool isPrimary;
/// Was bei einem Knopfdruck passieren soll
final void Function(Map<String, dynamic> inputValues)? onPressed;
}

View File

@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'dialog_input_field_select_item.dart';
import 'dialog_input_field_type_enum.dart';
/// Ein Input-Feld für den dynamischen Dialog
class DialogInputField {
/// Erstellt ein neues Input-Feld
const DialogInputField({
required this.id,
this.label,
this.initialValue,
this.keyboardType = TextInputType.text,
this.obscureText = false,
this.autoFocus = false,
this.onChanged,
this.inputType = DialogInputFieldTypeEnum.text,
this.selectItems = const [],
});
/// Die Id des InputFelds
final String id;
/// Der Label des InputFelds
final String? label;
/// Der initiale Wert des InputFelds
final dynamic initialValue;
/// Der InputTyp des InputFeld
final TextInputType keyboardType;
/// Ob der Text obskur angezeigt werden soll
final bool obscureText;
/// Ob das Textfeld automatisch fokussiert werden soll
final bool autoFocus;
/// Was bei Veränderung des Textfeldes geschehen soll
final ValueChanged<dynamic>? onChanged;
/// Stellt den Eingabetypen des Inputfeldes dar
final DialogInputFieldTypeEnum inputType;
/// Die Auswahlmöglichkeiten für den Select-Input-Typen
final List<DialogInputFieldSelectItem> selectItems;
}

View File

@@ -0,0 +1,11 @@
/// Eine Klasse, um den DialogInputFeldern des Select-Typs die Werte zu geben
class DialogInputFieldSelectItem {
/// Erstellt eine neue Instanz dieser Klasse
DialogInputFieldSelectItem({required this.id, required this.value});
/// Die id des Feldes
final int id;
/// Der Wert des Feldes
final String value;
}

View File

@@ -0,0 +1,13 @@
import 'dialog_input_field.dart';
/// Eine Enum um [DialogInputField] sagen zu können, welcher Typ er ist
enum DialogInputFieldTypeEnum {
/// Der Standard Text-Typ
text,
/// Der Datums-Typ
date,
/// Ein Select-Feld
select,
}

View File

@@ -0,0 +1,11 @@
/// Eine Enum, um den Typ eines Dialogs festzulegen
enum DialogTypeEnum {
/// Der Standarddialog
info,
/// Der Errordialog
error,
/// Der Erfolgreichdialog
success,
}

View File

@@ -0,0 +1,266 @@
import 'package:dropdown_search/dropdown_search.dart';
import 'package:flutter/material.dart';
import '../../Services/navigation_service.dart';
import '../../Services/theme_service.dart';
import '../Misc/InputFields/dynamic_date_time_field.dart';
import 'dialog_action.dart';
import 'dialog_input_field.dart';
import 'dialog_input_field_select_item.dart';
import 'dialog_input_field_type_enum.dart';
import 'dialog_type_enum.dart';
/// Erstellt einen neuen dynamischen Dialog
class DynamicDialog {
/// Erstellt eine neue Instanz dieser Klasse
DynamicDialog({
this.title,
this.icon,
this.content,
final List<DialogInputField>? inputFields,
final List<DialogAction>? actions,
this.backgroundColor,
this.borderRadius = 16,
this.barrierDismissible = true,
this.dialogType = DialogTypeEnum.info,
this.hiddenValues,
}) : inputFields = inputFields ?? const [],
actions = actions ?? [DialogAction(label: 'Schließen')],
_values = hiddenValues ?? {};
/// Der Titel des Dialogs
final String? title;
/// Der Icon des Dialogs
final IconData? icon;
/// Der Inhalt des Dialogs
final Widget? content;
/// Die Hintergrundfarbe des Dialogs, Standard wenn nicht gesetzt
final Color? backgroundColor;
/// Der BorderRadius des Dialogs
final double borderRadius;
/// Ob der Dialog bei einem Klick auf die Barriere geschlossen werden kann
final bool barrierDismissible;
/// Die InputFelder des Dialogs
final List<DialogInputField> inputFields;
/// Die Aktionen des Dialogs
final List<DialogAction> actions;
/// Der Typ des Dialogs
final DialogTypeEnum dialogType;
/// Versteckte Werte, die beim Abschicken mit zurückgegeben werden
final Map<String, dynamic>? hiddenValues;
final Map<String, dynamic> _values;
final Map<String, TextEditingController> _controllers = {};
BuildContext? _dialogContext;
/// Zeigt den vorher zusammengebauten Dialog an
Future<void> show() async {
final BuildContext? context = NavigationService.getCurrentBuildContext();
if (context != null) {
final ThemeData theme = Theme.of(context);
await showDialog(
context: context,
barrierDismissible: barrierDismissible,
builder: (final BuildContext ctx) {
_dialogContext = ctx;
final DialogAction primaryAction = actions.firstWhere(
(final a) => a.isPrimary,
orElse: () => actions.first,
);
Color backgroundColor =
this.backgroundColor ?? theme.colorScheme.surface;
if (dialogType == DialogTypeEnum.error) {
backgroundColor = theme.colorScheme.errorContainer;
} else if (dialogType == DialogTypeEnum.success) {
backgroundColor = ThemeService.getSuccessColor(
brightness: theme.brightness,
);
}
return SingleChildScrollView(
child: AlertDialog(
backgroundColor: backgroundColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(borderRadius),
),
title: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null)
Icon(icon, size: 48, color: theme.colorScheme.primary),
if (title != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
title!,
style: theme.textTheme.titleLarge,
textAlign: TextAlign.center,
),
),
],
),
content: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(ctx).size.height * 0.7,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (content != null) content!,
...inputFields.map(
(final DialogInputField field) => Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: _getInputField(field, primaryAction),
),
),
],
),
),
actions: actions.map(_getAction).toList(),
),
);
},
);
}
}
/// Schließt den dynamischen Dialog
void close() {
if (_dialogContext != null) {
Navigator.of(_dialogContext!).pop();
}
_values.clear();
_controllers.clear();
}
void _submit(final DialogAction action) {
final Map<String, dynamic> values = {}
..addEntries(_values.entries);
_controllers.forEach((
final String id,
final TextEditingController controller,
) {
values[id] = controller.text;
});
close();
action.onPressed?.call(values);
}
Widget _getInputField(
final DialogInputField inputField,
final DialogAction primaryAction,
) {
if (_values[inputField.id] == null) {
_values[inputField.id] = inputField.initialValue;
}
if (inputField.inputType == DialogInputFieldTypeEnum.date) {
return DynamicDateTimeField(
initialValue: _values[inputField.id],
autofocus: inputField.autoFocus,
onChanged: (final value) {
inputField.onChanged?.call(value);
_values[inputField.id] = value;
},
decoration: InputDecoration(
labelText: inputField.label,
border: const OutlineInputBorder(),
isDense: true,
),
);
} else if (inputField.inputType == DialogInputFieldTypeEnum.select) {
if (_values[inputField.id] is Enum) {
final Enum inputFieldInitialValue = _values[inputField.id];
for (final DialogInputFieldSelectItem value in inputField.selectItems) {
if (value.id == inputFieldInitialValue.index) {
_values[inputField.id] = value;
}
}
}
return DropdownSearch<DialogInputFieldSelectItem>(
items: (final f, final cs) => inputField.selectItems,
itemAsString: (final DialogInputFieldSelectItem value) => value.value,
selectedItem: _values[inputField.id],
onChanged: (final DialogInputFieldSelectItem? value) {
inputField.onChanged?.call(value);
_values[inputField.id] = value;
},
decoratorProps: DropDownDecoratorProps(
decoration: InputDecoration(
labelText: inputField.label,
border: const OutlineInputBorder(),
isDense: true,
),
),
compareFn:
(
final DialogInputFieldSelectItem v1,
final DialogInputFieldSelectItem v2,
) => v1.id == v2.id,
);
} else {
if (_controllers[inputField.id] == null) {
_controllers[inputField.id] = TextEditingController(
text: (_values[inputField.id] ?? '').toString()
);
}
return TextField(
controller: _controllers[inputField.id],
autofocus: inputField.autoFocus,
keyboardType: inputField.keyboardType,
obscureText: inputField.obscureText,
onChanged: (final value) {
inputField.onChanged?.call(value);
_values[inputField.id] = value;
},
decoration: InputDecoration(
labelText: inputField.label,
border: const OutlineInputBorder(),
isDense: true,
),
onSubmitted: (_) {
_submit(primaryAction);
},
);
}
}
Widget _getAction(final DialogAction action) {
if (action.isPrimary) {
return ElevatedButton(
onPressed: () {
_submit(action);
},
child: Text(action.label),
);
} else {
return TextButton(
onPressed: () {
_submit(action);
},
child: Text(action.label),
);
}
}
}

View File

@@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
import '../../../Services/date_service.dart';
import '../../Dialog/dialog_action.dart';
import '../../Dialog/dialog_input_field.dart';
import '../../Dialog/dialog_input_field_type_enum.dart';
import '../../Dialog/dynamic_dialog.dart';
/// Stellt einen DateRange-Picker als Input-Feld dar
class DateRangePicker extends StatefulWidget {
/// Initialisiert eine neue Instanz dieser Klasse
const DateRangePicker({
this.controller,
this.autofocus = false,
super.key,
this.decoration,
this.onChanged,
});
/// Ob das Feld automatisch ausgewählt werden soll
final bool autofocus;
/// Die Funktion, die bei Veränderung des Wertes aufgerufen wird
final Function(DateTimeRange?)? onChanged;
/// Die Dekoration, wie das Feld aussehen soll
final InputDecoration? decoration;
/// Der Controller über den der Wert des Inputfeldes gesetzt
/// und ausgelesen werden kann
final TextEditingController? controller;
@override
State<StatefulWidget> createState() => _DateRangePicker();
}
class _DateRangePicker extends State<DateRangePicker> {
final TextEditingController _valueController = TextEditingController();
@override
void dispose() {
_valueController.dispose();
super.dispose();
}
@override
Widget build(final BuildContext context) => TextField(
readOnly: true,
controller: widget.controller ?? _valueController,
decoration: widget.decoration,
onTap: _pickDateRange,
);
Future<void> _pickDateRange() async {
final TextEditingController controller =
widget.controller ?? _valueController;
final DateTimeRange<DateTime>? dateTimeRange =
DateService.stringToDateTimeRange(controller.text);
await DynamicDialog(
title: 'Zeitraum auswählen',
inputFields: [
DialogInputField(
id: 'dateFrom',
label: 'Startdatum',
keyboardType: TextInputType.datetime,
inputType: DialogInputFieldTypeEnum.date,
initialValue: dateTimeRange?.start,
),
DialogInputField(
id: 'dateTo',
label: 'Enddatum',
keyboardType: TextInputType.datetime,
inputType: DialogInputFieldTypeEnum.date,
initialValue: dateTimeRange?.end,
),
],
actions: [
DialogAction(label: 'Abbrechen'),
DialogAction(
label: 'Leeren',
onPressed: (_) {
controller.text = '';
widget.onChanged?.call(null);
},
),
DialogAction(
label: 'Auswählen',
isPrimary: true,
onPressed: (final Map<String, dynamic> values) {
if (values['dateFrom'] != null &&
values['dateFrom'] is DateTime &&
values['dateTo'] != null &&
values['dateTo'] is DateTime) {
final DateTimeRange<DateTime> timeRange = DateTimeRange(
start: values['dateFrom'],
end: values['dateTo'],
);
controller.text = DateService.dateTimeRangeToString(timeRange)!;
widget.onChanged?.call(timeRange);
}
},
),
],
).show();
}
}

View File

@@ -0,0 +1,62 @@
import 'package:date_field/date_field.dart';
import 'package:flutter/material.dart';
import '../../../Services/date_service.dart';
/// Ein Feld mit Popup über welches man Datumsfelder auswählen kann
class DynamicDateTimeField extends StatefulWidget {
/// Initialisiert eine neue Instanz dieser Klasse
const DynamicDateTimeField({
this.initialValue,
this.mode = DateTimeFieldPickerMode.date,
this.autofocus = false,
this.onChanged,
this.decoration,
super.key,
});
/// Der Initiale Wert des Input-Feldes
final DateTime? initialValue;
/// Der Modus des Datums-Feldes
final DateTimeFieldPickerMode mode;
/// Ob das Feld automatisch ausgewählt werden soll
final bool autofocus;
/// Die Funktion, die bei Veränderung des Wertes aufgerufen wird
final Function(DateTime?)? onChanged;
/// Die Dekoration, wie das Feld aussehen soll
final InputDecoration? decoration;
@override
State<StatefulWidget> createState() => _DynamicDateTimeFieldState();
}
class _DynamicDateTimeFieldState extends State<DynamicDateTimeField> {
DateTime? _value;
@override
void initState() {
super.initState();
_value = widget.initialValue;
}
@override
Widget build(final BuildContext context) => DateTimeField(
initialPickerDateTime: widget.initialValue,
dateFormat: DateService.dateFormat,
value: _value,
mode: widget.mode,
autofocus: widget.autofocus,
onChanged: (final value) {
widget.onChanged?.call(value);
setState(() {
_value = value;
});
},
decoration: widget.decoration,
);
}

View File

@@ -0,0 +1,85 @@
import 'package:dropdown_search/dropdown_search.dart';
import 'package:flutter/material.dart';
import '../../Controller/account_controller.dart';
import '../../Entities/drift_database.dart';
/// Ein Dropdown, mit welchem man das Konto auswählen kann
class AccountSelect extends StatefulWidget {
/// Erstellt eine neue Instanz dieser Klasse
const AccountSelect({super.key});
@override
State<AccountSelect> createState() => _AccountSelectState();
}
class _AccountSelectState extends State<AccountSelect> {
final AccountController _accountController = AccountController();
Account? _selected;
List<Account> _accounts = [];
@override
void initState() {
super.initState();
_selected = _accountController.selected.value;
_accounts = _accountController.accounts.value;
_accountController.selected.addListener(() {
setState(() {
if (mounted) {
_selected = _accountController.selected.value;
}
});
});
_accountController.accounts.addListener(() {
setState(() {
if (mounted) {
_accounts = _accountController.accounts.value;
}
});
});
}
@override
Widget build(final BuildContext context) {
if (_selected != null && _accounts.isNotEmpty) {
return DropdownSearch<Account>(
items: (final f, final cs) => _accounts,
selectedItem: _selected,
onChanged: (final Account? account) {
if (account != null) {
_accountController.selected = account;
}
},
itemAsString: (final Account account) => account.name,
compareFn: (final Account a1, final Account a2) => a1.id == a2.id,
popupProps: const PopupProps<Account>.menu(
showSearchBox: true,
searchFieldProps: TextFieldProps(
decoration: InputDecoration(
hintText: 'Konto suchen...',
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
),
),
);
} else if (_selected == null && _accounts.isEmpty) {
return TextButton(
onPressed: _accountController.newAccountHandler,
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.account_balance_wallet),
SizedBox(width: 10),
Text('Konto hinzufügen'),
],
),
);
} else {
return const CircularProgressIndicator();
}
}
}

View File

@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import '../../Entities/list_item.dart';
/// Eine editierbare Liste mit Funktionen zur Bearbeitung
class EditableList extends StatelessWidget {
/// Erstellt eine neue Instanz dieser Klasse
const EditableList({
required this.name,
required this.items,
required this.onAdd,
required this.onEdit,
required this.onDelete,
this.icon,
this.addTooltip,
this.menuTooltip,
super.key,
});
/// Der Name, der oben angezeigt wird
final String name;
/// Die
final List<ListItem> items;
/// Die Funktion, die aufgerufen wird,
/// wenn ein neuer Eintrag hinzugefügt werden soll
final void Function() onAdd;
/// Die Funktion, die beim umbenennen aufgerufen wird
final void Function(int) onEdit;
///Die Funktion, die beim Löschen aufgerufen wird
final void Function(int) onDelete;
/// Das Icon, das angezeigt wird
final Icon? icon;
/// Der Tooltip, der beim erstellen-Button angezeigt wird
final String? addTooltip;
/// Der Tooltip, der auf dem Menü angezeigt wird
final String? menuTooltip;
@override
Widget build(final BuildContext context) => Expanded(
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
name,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
IconButton(
onPressed: onAdd,
icon: const Icon(Icons.add),
tooltip: addTooltip,
),
],
),
const SizedBox(height: 8),
Expanded(
child: ListView.separated(
itemCount: items.length,
separatorBuilder: (_, _) => const Divider(),
itemBuilder: (final context, final index) => ListTile(
contentPadding: EdgeInsets.zero,
title: Text(items[index].name),
leading: icon,
trailing: PopupMenuButton<String>(
tooltip: menuTooltip,
onSelected: (final value) {
if (value == 'edit') {
onEdit(items[index].id);
} else if (value == 'delete') {
onDelete(items[index].id);
}
},
itemBuilder: (_) => const [
PopupMenuItem(value: 'edit', child: Text('Bearbeiten')),
PopupMenuItem(value: 'delete', child: Text('Entfernen')),
],
),
),
),
),
],
),
);
}

View File

@@ -0,0 +1,129 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
import '../../Controller/account_controller.dart';
import '../../Controller/recurring_transaction_controller.dart';
import '../../Controller/transaction_controller.dart';
import '../../Services/navigation_service.dart';
/// Ein Floating Action Button, der beim Klicken ein expandierendes Menü öffnet,
/// um neue Transaktionen oder Konten anzulegen.
class FloatingCreationButton extends StatefulWidget {
/// Erstellt eine neue Instanz dieser Klasse
const FloatingCreationButton({super.key});
@override
State<FloatingCreationButton> createState() => _FloatingCreationButtonState();
}
class _FloatingCreationButtonState extends State<FloatingCreationButton> {
final AccountController _accountController = AccountController();
final RecurringTransactionController _recurringTransactionController =
RecurringTransactionController();
final TransactionController _transactionController = TransactionController();
final _key = GlobalKey<ExpandableFabState>();
@override
void initState() {
super.initState();
_accountController.selected.addListener(() {
if (mounted) {
setState(() {});
}
});
}
@override
Widget build(final BuildContext context) {
final List<Widget> fabs = [];
if (_accountController.selected.value != null) {
fabs
..add(
_expandableButton(
label: 'Neue Transaktion',
icon: Icons.swap_horiz,
onPressed: _transactionController.newTransactionHandler,
),
)
..add(
_expandableButton(
label: 'Neue wiederkehrende Transaktion',
icon: Icons.repeat,
onPressed:
_recurringTransactionController.newRecurringTransactionHandler,
),
);
}
fabs.add(
_expandableButton(
label: 'Neues Konto',
icon: Icons.account_balance_wallet,
onPressed: _accountController.newAccountHandler,
),
);
return ExpandableFab(
key: _key,
openButtonBuilder: RotateFloatingActionButtonBuilder(
child: const Icon(Icons.add),
),
type: ExpandableFabType.up,
childrenAnimation: ExpandableFabAnimation.none,
distance: 70,
children: fabs,
);
}
Widget _expandableButton({
required final String label,
required final IconData icon,
required final VoidCallback onPressed,
}) {
final ThemeData theme = Theme.of(
NavigationService.getCurrentBuildContext()!,
);
return GestureDetector(
onTap: onPressed,
child: Row(
children: <Widget>[
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 6, sigmaY: 6),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: theme.colorScheme.onPrimary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text(label),
),
),
),
const SizedBox(width: 12),
FloatingActionButton.small(
heroTag: null,
onPressed: () {
onPressed.call();
final ExpandableFabState? state = _key.currentState;
if (state != null && state.isOpen) {
state.close();
}
},
child: Icon(icon),
),
],
),
);
}
}

View File

@@ -0,0 +1,175 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../Controller/account_controller.dart';
import '../../Controller/transaction_controller.dart';
import '../../Repositories/transaction_repository.dart';
/// Stellt einen Chart des Monats-Kontostands dar
class MonthlyBalanceChart extends StatefulWidget {
/// Erstellt eine neue Instanz dieser Klasse
const MonthlyBalanceChart({
super.key,
this.amountMax,
this.amountMin,
this.dateFrom,
this.dateTo,
this.name,
});
/// Der Name der Transaktion, nach der gesucht werden soll
final String? name;
/// Der Mindestbetrag der Transaktion, nach der gesucht werden soll
final double? amountMin;
/// Der Maximalbetrag der Transaktion, nach der gesucht werden soll
final double? amountMax;
/// Das Datum der Transaktionen, ab dem gestartet wurde
final DateTime? dateFrom;
///Das Datum der Transaktionen, bis zu welchen beendet wurde
final DateTime? dateTo;
@override
State<StatefulWidget> createState() => _MonthlyBalanceChart();
}
class _MonthlyBalanceChart extends State<MonthlyBalanceChart> {
final TransactionRepository _transactionRepository = TransactionRepository();
final AccountController _accountController = AccountController();
final TransactionController _transactionController = TransactionController();
@override
void initState() {
super.initState();
_transactionController.transactions.addListener(() {
if (mounted) {
setState(() {});
}
});
}
@override
Widget build(final BuildContext context) => FutureBuilder(
future: _transactionRepository.monthlyBalances(
account: _accountController.selected.value,
name: widget.name,
amountMin: widget.amountMin,
amountMax: widget.amountMax,
dateFrom: widget.dateFrom,
dateTo: (widget.dateTo != null) ? widget.dateTo : DateTime.now(),
),
builder:
(
final BuildContext context,
final AsyncSnapshot<List<Map<String, dynamic>>> snapshot,
) {
final ThemeData theme = Theme.of(context);
final MediaQueryData mediaQuery = MediaQuery.of(context);
if (snapshot.hasData) {
final List<Map<String, dynamic>> monthlyBalances = snapshot.data!;
double maxBalance = 0;
double minBalance = 0;
for (final value in monthlyBalances) {
if (maxBalance < value['balance']) {
maxBalance = value['balance'];
}
if (minBalance > value['balance']) {
minBalance = value['balance'];
}
}
return SizedBox(
height: 180,
child: LineChart(
LineChartData(
minY: minBalance,
maxY: maxBalance,
titlesData: FlTitlesData(
topTitles: const AxisTitles(),
rightTitles: const AxisTitles(),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
interval: 1,
getTitlesWidget:
(final double value, final TitleMeta meta) {
final List<String> months = monthlyBalances.map((
final value,
) {
final DateTime date = value['date'];
final DateFormat format = DateFormat('MMMM');
String month = format.format(date);
if (mediaQuery.size.width < 470) {
month = month.substring(0, 1);
} else if (mediaQuery.size.width < 920) {
month = month.substring(0, 3);
}
return month;
}).toList();
if (value.toInt() >= 0 &&
value.toInt() < months.length) {
return Text(months[value.toInt()]);
}
return const Text('');
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 50,
getTitlesWidget:
(final double value, final TitleMeta meta) => Text(
'${value.toInt()}',
style: const TextStyle(fontSize: 12),
),
),
),
),
lineBarsData: <LineChartBarData>[
LineChartBarData(
spots: List<FlSpot>.generate(
monthlyBalances.length,
(final int index) => FlSpot(
index.toDouble(),
monthlyBalances[index]['balance'],
),
),
isCurved: true,
barWidth: 3,
color: theme.colorScheme.primary,
),
],
),
),
);
} else if (snapshot.hasError) {
return Center(
child: Column(
children: [
Icon(Icons.error, color: theme.colorScheme.error),
const Text('Fehler beim holen der Monatsübersicht!'),
],
),
);
} else {
return const Center(child: CircularProgressIndicator());
}
},
);
}

View File

@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import '../../Controller/account_controller.dart';
import '../../Entities/drift_database.dart';
import '../../Entities/list_item.dart';
import '../Misc/editable_list.dart';
/// Ein Widget, das die Liste mit vorhandenen Konten anzeigt
class AccountList extends StatefulWidget {
/// Erstellt eine neue Instanz dieser Klasse
const AccountList({super.key});
@override
State<StatefulWidget> createState() => _AccountListState();
}
class _AccountListState extends State<AccountList> {
final AccountController _accountController = AccountController();
List<Account> _accounts = [];
@override
void initState() {
super.initState();
_accounts = _accountController.accounts.value;
_accountController.accounts.addListener(() {
if (mounted) {
setState(() {
if (mounted) {
_accounts = _accountController.accounts.value;
}
});
}
});
}
@override
Widget build(final BuildContext context) {
if (_accounts != []) {
final List<ListItem> formatedAccounts = [];
for (final Account data in _accounts) {
formatedAccounts.add(ListItem(id: data.id, name: data.name));
}
return EditableList(
name: 'Konten',
items: formatedAccounts,
onAdd: _accountController.newAccountHandler,
onEdit: _accountController.renameAccountHandler,
onDelete: _accountController.deleteAccountHandler,
icon: const Icon(Icons.account_balance_wallet),
addTooltip: 'Konto hinzufügen',
menuTooltip: 'Menü anzeigen',
);
} else {
return const Center(child: CircularProgressIndicator());
}
}
}

View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import '../../Controller/recurring_transaction_controller.dart';
import '../../Entities/drift_database.dart';
import '../../Entities/list_item.dart';
import '../Misc/editable_list.dart';
/// Ein Widget,
/// das die Liste mit vorhandenen wiederkehrenden Transaktionen anzeigt
class RecurringTransactionList extends StatefulWidget {
/// Erstellt eine neue Instanz dieser Klasse
const RecurringTransactionList({super.key});
@override
State<StatefulWidget> createState() => _RecurringTransactionListState();
}
class _RecurringTransactionListState extends State<RecurringTransactionList> {
final RecurringTransactionController _recurringTransactionController =
RecurringTransactionController();
List<RecurringTransaction> _recurringTransactions = [];
@override
void initState() {
super.initState();
_recurringTransactions =
_recurringTransactionController.recurringTransactions.value;
_recurringTransactionController.recurringTransactions.addListener(() {
if (mounted) {
setState(() {
if (mounted) {
_recurringTransactions =
_recurringTransactionController.recurringTransactions.value;
}
});
}
});
}
@override
Widget build(final BuildContext context) {
if (_recurringTransactions != []) {
final List<ListItem> formatedRecurringTransactions = [];
for (final RecurringTransaction data in _recurringTransactions) {
formatedRecurringTransactions.add(
ListItem(id: data.id, name: data.name),
);
}
return EditableList(
name: 'Wiederkehrende Transaktionen',
items: formatedRecurringTransactions,
onAdd: _recurringTransactionController.newRecurringTransactionHandler,
onEdit: _recurringTransactionController.editRecurringTransactionHandler,
onDelete:
_recurringTransactionController.deleteRecurringTransactionHandler,
icon: const Icon(Icons.repeat),
addTooltip: 'Wiederkehrende Transaktion hinzufügen',
menuTooltip: 'Menü anzeigen',
);
} else {
return const Center(child: CircularProgressIndicator());
}
}
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'account_list.dart';
import 'recurring_transaction_list.dart';
import 'version_number.dart';
/// Eine Widget-Klasse, die die Einstellungsseite der Anwendung darstellt.
class Settings extends StatelessWidget {
/// Erstellt eine neue Instanz dieser Klasse.
const Settings({super.key});
@override
Widget build(final BuildContext context) => const Scaffold(
body: SafeArea(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Einstellungen',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
SizedBox(height: 24),
AccountList(),
SizedBox(height: 24),
RecurringTransactionList(),
SizedBox(height: 8),
VersionNumber(),
],
),
),
),
);
}

View File

@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
/// Ein Widget mit der aktuellen Versionsnummer
class VersionNumber extends StatefulWidget {
/// Erstellt eine neue Instanz dieser Klasse
const VersionNumber({super.key});
@override
State<StatefulWidget> createState() => _VersionNumber();
}
class _VersionNumber extends State<VersionNumber> {
@override
Widget build(final BuildContext context) => Align(
alignment: Alignment.bottomLeft,
child: FutureBuilder(
future: PackageInfo.fromPlatform(),
builder:
(
final BuildContext context,
final AsyncSnapshot<PackageInfo> snapshot,
) {
final ThemeData theme = Theme.of(context);
if (snapshot.hasData) {
return Text(
'${snapshot.data?.version}+${snapshot.data?.buildNumber}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(
(0.6 * 255).round(),
),
),
);
} else {
return const CircularProgressIndicator();
}
},
),
);
}

View File

@@ -0,0 +1,271 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:routemaster/routemaster.dart';
import '../../Services/date_service.dart';
import '../Misc/InputFields/date_range_picker.dart';
/// Stellt die Inputfelder für die Verlaufsübersicht dar
class InputFields extends StatefulWidget {
/// Erstellt eine neue Instanz dieser Klasse
const InputFields({
super.key,
this.amountMax,
this.amountMin,
this.dateFrom,
this.dateTo,
this.name,
});
/// Der Name der Transaktion, nach der gesucht werden soll
final String? name;
/// Der Mindestbetrag der Transaktion, nach der gesucht werden soll
final double? amountMin;
/// Der Maximalbetrag der Transaktion, nach der gesucht werden soll
final double? amountMax;
/// Das Datum der Transaktionen, ab dem gestartet wurde
final DateTime? dateFrom;
///Das Datum der Transaktionen, bis zu welchen beendet wurde
final DateTime? dateTo;
@override
State<StatefulWidget> createState() => _InputFields();
}
class _InputFields extends State<InputFields> {
final TextEditingController _nameController = TextEditingController();
final TextEditingController _amountMinController = TextEditingController();
final TextEditingController _amountMaxController = TextEditingController();
final TextEditingController _dateTimeController = TextEditingController();
static const double _filterBreakpoint = 600;
@override
void initState() {
super.initState();
if (widget.name != null) {
_nameController.text = widget.name!;
}
if (widget.amountMin != null) {
_amountMinController.text = widget.amountMin!.toString();
}
if (widget.amountMax != null) {
_amountMaxController.text = widget.amountMax!.toString();
}
if (widget.dateFrom != null && widget.dateTo != null) {
final DateTimeRange dateTimeRange = DateTimeRange(
start: widget.dateFrom!,
end: widget.dateTo!,
);
_dateTimeController.text = DateService.dateTimeRangeToString(
dateTimeRange,
)!;
}
}
@override
void dispose() {
_nameController.dispose();
_amountMinController.dispose();
_amountMaxController.dispose();
_dateTimeController.dispose();
super.dispose();
}
@override
Widget build(final BuildContext context) {
final ThemeData theme = Theme.of(context);
return LayoutBuilder(
builder: (final context, final constraints) {
if (constraints.maxWidth < _filterBreakpoint) {
return _buildFilterButton(context, theme);
}
return _buildInlineFilters(theme);
},
);
}
Widget _buildInlineFilters(final ThemeData theme) => Row(
children: <Widget>[
Expanded(
child: TextField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(),
),
onChanged: (_) => _updateUrl(),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _amountMinController,
decoration: const InputDecoration(
labelText: 'Min Betrag €',
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (_) => _updateUrl(),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _amountMaxController,
decoration: const InputDecoration(
labelText: 'Max Betrag €',
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (_) => _updateUrl(),
),
),
const SizedBox(width: 8),
Expanded(
child: DateRangePicker(
controller: _dateTimeController,
onChanged: (_) => _updateUrl(),
decoration: InputDecoration(
labelText: 'Zeitraum',
border: const OutlineInputBorder(),
suffixIcon: Icon(
Icons.calendar_month,
color: theme.colorScheme.onSurface,
),
),
),
),
const SizedBox(width: 8),
IconButton(onPressed: _clear, icon: const Icon(Icons.clear)),
],
);
Widget _buildFilterButton(
final BuildContext context,
final ThemeData theme,
) => Align(
alignment: Alignment.centerLeft,
child: ElevatedButton.icon(
icon: const Icon(Icons.filter_alt),
label: const Text('Filter'),
onPressed: () => _openFilterDialog(context),
),
);
void _openFilterDialog(final BuildContext context) {
unawaited(
showDialog(
context: context,
builder: (final context) => AlertDialog(
title: const Text('Filter'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _amountMinController,
decoration: const InputDecoration(
labelText: 'Min Betrag €',
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
),
const SizedBox(height: 12),
TextField(
controller: _amountMaxController,
decoration: const InputDecoration(
labelText: 'Max Betrag €',
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
),
const SizedBox(height: 12),
DateRangePicker(
controller: _dateTimeController,
onChanged: (_) {},
decoration: const InputDecoration(
labelText: 'Zeitraum',
border: OutlineInputBorder(),
),
),
],
),
),
actions: [
TextButton(
onPressed: () {
_clear();
Navigator.of(context).pop();
},
child: const Text('Zurücksetzen'),
),
ElevatedButton(
onPressed: () {
_updateUrl();
Navigator.of(context).pop();
},
child: const Text('Anwenden'),
),
],
),
),
);
}
void _clear() {
_nameController.clear();
_amountMinController.clear();
_amountMaxController.clear();
_dateTimeController.clear();
_updateUrl();
}
void _updateUrl() {
final params = <String, String>{
if (_nameController.text != '') 'name': _nameController.text,
if (_amountMinController.text != '')
'amountMin': _amountMinController.text,
if (_amountMaxController.text != '')
'amountMax': _amountMaxController.text,
};
if (_dateTimeController.text != '') {
final DateTimeRange<DateTime>? range = DateService.stringToDateTimeRange(
_dateTimeController.text,
);
if (range != null) {
params['dateFrom'] = range.start.toIso8601String();
params['dateTo'] = range.end.toIso8601String();
}
}
Routemaster.of(context).replace('/trend', queryParameters: params);
}
}

View File

@@ -0,0 +1,174 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_sticky_header/flutter_sticky_header.dart';
import 'package:intl/intl.dart';
import '../../Controller/account_controller.dart';
import '../../Controller/transaction_controller.dart';
import '../../Entities/drift_database.dart';
import '../../Repositories/transaction_repository.dart';
/// Stellt eine filterbare Liste der Transaktionen dar
class TransactionList extends StatefulWidget {
/// Erstellt eine neue Instanz dieser Klasse
const TransactionList({
super.key,
this.amountMax,
this.amountMin,
this.dateFrom,
this.dateTo,
this.name,
});
/// Der Name der Transaktion, nach der gesucht werden soll
final String? name;
/// Der Mindestbetrag der Transaktion, nach der gesucht werden soll
final double? amountMin;
/// Der Maximalbetrag der Transaktion, nach der gesucht werden soll
final double? amountMax;
/// Das Datum der Transaktionen, ab dem gestartet wurde
final DateTime? dateFrom;
///Das Datum der Transaktionen, bis zu welchen beendet wurde
final DateTime? dateTo;
@override
State<StatefulWidget> createState() => _TransactionListState();
}
class _TransactionListState extends State<TransactionList> {
final TransactionController _transactionController = TransactionController();
final AccountController _accountController = AccountController();
final TransactionRepository _transactionRepository = TransactionRepository();
@override
void initState() {
super.initState();
_transactionController.transactions.addListener(() {
if (mounted) {
setState(() {});
}
});
}
@override
Widget build(final BuildContext context) => FutureBuilder(
future: _transactionRepository.findBy(
name: widget.name,
amountMin: widget.amountMin,
amountMax: widget.amountMax,
dateFrom: widget.dateFrom,
dateTo: (widget.dateTo != null) ? widget.dateTo : DateTime.now(),
account: _accountController.selected.value,
orderBy: 'dateDesc',
),
builder:
(
final BuildContext context,
final AsyncSnapshot<List<Transaction>> snapshot,
) {
final ThemeData theme = Theme.of(context);
if (snapshot.hasData) {
final transactionsByMonth = <String, List<Transaction>>{};
for (final Transaction transaction in snapshot.data!) {
final DateTime date = transaction.date!;
final DateFormat format = DateFormat('MMMM');
final monthName = '${format.format(date)} ${date.year}';
transactionsByMonth
.putIfAbsent(monthName, () => [])
.add(transaction);
}
return Expanded(
child: CustomScrollView(
slivers: transactionsByMonth.entries
.map(
(
final MapEntry<String, List<Transaction>> entry,
) => SliverStickyHeader(
header: Container(
color: theme.colorScheme.surface,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
alignment: Alignment.centerLeft,
child: Text(
entry.key,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((
final BuildContext context,
final int index,
) {
final Transaction transaction = entry.value[index];
return ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onTap: () {
unawaited(
_transactionController.editTransactionHandler(
transaction.id,
),
);
},
contentPadding: EdgeInsets.zero,
title: Text(
transaction.name,
style: (transaction.checked)
? const TextStyle()
: TextStyle(color: theme.colorScheme.error),
),
subtitle: Text(
'${transaction.date?.day}'
'.${transaction.date?.month}'
'.${transaction.date?.year}',
),
trailing: Text(
'${transaction.amount.abs().toStringAsFixed(2)}'
'',
style: TextStyle(
color: transaction.amount >= 0
? Colors.red
: Colors.green,
fontWeight: FontWeight.bold,
),
),
);
}, childCount: entry.value.length),
),
),
)
.toList(),
),
);
} else if (snapshot.hasError) {
return Center(
child: Column(
children: [
Icon(Icons.error, color: theme.colorScheme.error),
const Text('Fehler beim holen der Transaktionen!'),
],
),
);
} else {
return const Center(child: CircularProgressIndicator());
}
},
);
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:routemaster/routemaster.dart';
import '../Misc/monthly_balance_chart.dart';
import 'input_fields.dart';
import 'transaction_list.dart';
/// Eine Seite, die den Trend von Transaktionen zeigt.
///
/// Die Seite zeigt einen Liniendiagramm für den Kontostand über die Zeit,
/// sowie eine Liste aller Transaktionen, die nach Monaten gruppiert sind.
/// Die Transaktionen können nach Name, Betrag und Zeitraum gefiltert werden.
class Trend extends StatefulWidget {
/// Erstellt eine neue Instanz der Trend-Seite.
const Trend({super.key});
@override
State<Trend> createState() => _TrendState();
}
class _TrendState extends State<Trend> {
@override
Widget build(final BuildContext context) {
final Map<String, String> query = Routemaster.of(
context,
).currentRoute.queryParameters;
final String? name = query['name'];
final double? amountMin = double.tryParse(query['amountMin'] ?? '');
final double? amountMax = double.tryParse(query['amountMax'] ?? '');
final DateTime? dateFrom = DateTime.tryParse(query['dateFrom'] ?? '');
final DateTime? dateTo = DateTime.tryParse(query['dateTo'] ?? '');
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
InputFields(
name: name,
amountMin: amountMin,
amountMax: amountMax,
dateFrom: dateFrom,
dateTo: dateTo,
),
const SizedBox(height: 24),
MonthlyBalanceChart(
name: name,
amountMin: amountMin,
amountMax: amountMax,
dateFrom: (dateFrom != null)
? DateTime(dateFrom.year, dateFrom.month, dateFrom.day - 1)
: null,
dateTo: (dateTo != null)
? DateTime(dateTo.year, dateTo.month, dateTo.day + 1)
: null,
),
const SizedBox(height: 24),
const Text(
'Transaktionen',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
TransactionList(
name: name,
amountMin: amountMin,
amountMax: amountMax,
dateFrom: (dateFrom != null)
? DateTime(dateFrom.year, dateFrom.month, dateFrom.day - 1)
: null,
dateTo: (dateTo != null)
? DateTime(dateTo.year, dateTo.month, dateTo.day + 1)
: null,
),
],
),
),
),
);
}
}

63
lib/Pages/home_page.dart Normal file
View File

@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
import 'package:routemaster/routemaster.dart';
import 'Misc/account_select.dart';
import 'Misc/floating_creation_button.dart';
/// Eine Seite, die als Container für die verschiedenen Tabs der App dient.
///
/// Diese Seite enthält eine App-Bar mit einer Kontoauswahl sowie ein
/// Bottom-Navigation-Bar für die Navigation zwischen
/// Dashboard, Verlauf und Einstellungen.
class HomePage extends StatelessWidget {
/// Erstellt eine neue Instanz dieser Klasse
const HomePage({super.key});
@override
Widget build(final BuildContext context) {
final TabPageState tabPage = TabPage.of(context);
return Scaffold(
appBar: AppBar(
centerTitle: true,
titleSpacing: 0,
title: const Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: AccountSelect(),
),
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: TabBarView(
controller: tabPage.controller,
children: <Widget>[
for (final PageStack stack in tabPage.stacks)
PageStackNavigator(stack: stack),
],
),
),
),
bottomNavigationBar: _bottomNav(tabPage),
floatingActionButtonLocation: ExpandableFab.location,
floatingActionButton: const FloatingCreationButton(),
);
}
Widget _bottomNav(final TabPageState tabPage) => BottomNavigationBar(
currentIndex: tabPage.index,
onTap: tabPage.controller.animateTo,
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(icon: Icon(Icons.dashboard), label: 'Dashboard'),
BottomNavigationBarItem(icon: Icon(Icons.history), label: 'Verlauf'),
BottomNavigationBarItem(
icon: Icon(Icons.settings),
label: 'Einstellungen',
),
],
);
}

View File

@@ -0,0 +1,6 @@
import '../../Controller/port_controller.dart';
/// Aktualisiert die Transaktionen, die angezeigt werden
void updateTransactions() {
PortController().getPort('update-transactions')?.send('ready');
}

View File

@@ -0,0 +1,47 @@
/// Utility functions for working with dates.
class DateUtils {
/// Returns a [DateTime] that is [monthDate] with the added number
/// of months and the day set to 1 and time set to midnight.
///
/// For example:
///
/// ```dart
/// DateTime date = DateTime(2019, 1, 15);
/// DateTime futureDate = DateUtils.addMonthsToMonthDate(date, 3);
/// ```
///
/// `date` would be January 15, 2019.
/// `futureDate` would be April 1, 2019 since it adds 3 months.
static DateTime addMonthsToMonthDate(
final DateTime monthDate,
final int monthsToAdd,
) => DateTime(monthDate.year, monthDate.month + monthsToAdd);
/// Returns the number of days in a month, according to the proleptic
/// Gregorian calendar.
///
/// This applies the leap year logic introduced by the Gregorian reforms of
/// 1582. It will not give valid results for dates prior to that time.
static int getDaysInMonth(final int year, final int month) {
if (month == DateTime.february) {
final bool isLeapYear =
(year % 4 == 0) && (year % 100 != 0) || (year % 400 == 0);
return isLeapYear ? 29 : 28;
}
const List<int> daysInMonth = <int>[
31,
-1,
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
return daysInMonth[month - 1];
}
}

View File

@@ -0,0 +1,7 @@
/// Ein Stub
class LocalNotifications {
/// Ein Stub
Future<void> showTransactionsToCheckNotification(
final List<dynamic> transactions,
) async {}
}

View File

@@ -0,0 +1,2 @@
/// Ein Stub
void updateTransactions() {}

View File

@@ -0,0 +1,66 @@
import 'package:drift/drift.dart';
import '../Entities/drift_database.dart';
import '../Services/database_service.dart';
/// Funktionen zum interagieren mit der Datenbank für die Konten
class AccountRepository {
final AppDatabase _db = DatabaseService().database;
/// Fügt ein neues Konto zur Datenbank hinzu
Future<Account?> add(final AccountsCompanion account) async {
final int id = await _db.into(_db.accounts).insert(account);
return find(id);
}
/// Aktualisiert ein Konto in der Datenbank
Future<bool> update(final AccountsCompanion account) {
final AccountsCompanion accountToUpdate = AccountsCompanion(
id: account.id,
name: account.name,
updatedAt: Value(DateTime.now()),
);
return _db.update(_db.accounts).replace(accountToUpdate);
}
/// Entfernt ein Konto aus der Datenbank
Future<int> remove(final Account account) => (_db.delete(
_db.accounts,
)..where((final t) => t.id.equals(account.id))).go();
/// Sucht ein Konto anhand seiner Id aus der Datenbank
Future<Account?> find(final int id) => (_db.select(
_db.accounts,
)..where((final t) => t.id.equals(id))).getSingleOrNull();
/// Sucht Konten anhand der gegebenen Parameter aus der Datenbank
Future<List<Account>> findBy({
final int? id,
final String? name,
final String? orderBy,
}) {
final SimpleSelectStatement<$AccountsTable, Account> query = _db.select(
_db.accounts,
);
if (id != null) {
query.where((final t) => t.id.equals(id));
}
if (name != null && name.isNotEmpty) {
query.where((final t) => t.name.like('%$name%'));
}
if (orderBy != null) {
switch (orderBy) {
case 'nameAsc':
query.orderBy([(final t) => OrderingTerm.asc(t.name)]);
case 'nameDesc':
query.orderBy([(final t) => OrderingTerm.desc(t.name)]);
}
}
return query.get();
}
}

View File

@@ -0,0 +1,131 @@
import 'package:drift/drift.dart';
import '../Entities/drift_database.dart';
import '../Entities/time_frame_enum.dart';
import '../Services/database_service.dart';
/// Funktionen zum interagieren mit der Datenbank für die Transaktionen
class RecurringTransactionRepository {
final AppDatabase _db = DatabaseService().database;
/// Fügt eine neue wiederkehrende Transaktion zur Datenbank hinzu
Future<RecurringTransaction?> add(
final RecurringTransactionsCompanion recurringTransaction,
) async {
final int id = await _db
.into(_db.recurringTransactions)
.insert(recurringTransaction);
return find(id);
}
/// Aktualisiert eine wiederkehrende Transaktion in der Datenbank
Future<bool> update(
final RecurringTransactionsCompanion recurringTransaction,) {
final RecurringTransactionsCompanion recurringTransactionToUpdate =
RecurringTransactionsCompanion(
id: recurringTransaction.id,
name: recurringTransaction.name,
startDate: recurringTransaction.startDate,
timeFrame: recurringTransaction.timeFrame,
amount: recurringTransaction.amount,
accountId: recurringTransaction.accountId,
updatedAt: Value(DateTime.now()),
);
return _db
.update(_db.recurringTransactions)
.replace(recurringTransactionToUpdate);
}
/// Entfernt eine wiederkehrende Transaktion aus der Datenbank
Future<int> remove(final RecurringTransaction recurringTransaction) =>
(_db.delete(
_db.recurringTransactions,
)..where((final t) => t.id.equals(recurringTransaction.id))).go();
/// Sucht eine wiederkehrende Transaktion anhand seiner Id aus der Datenbank
Future<RecurringTransaction?> find(final int id) => (_db.select(
_db.recurringTransactions,
)..where((final t) => t.id.equals(id))).getSingleOrNull();
/// Sucht wiederkehrende Transaktionen anhand der gegebenen Parameter
/// aus der Datenbank
Future<List<RecurringTransaction>> findBy({
final int? id,
final String? name,
final DateTime? startDate,
final DateTime? startDateBefore,
final DateTime? startDateAfter,
final TimeFrameEnum? timeFrame,
final double? amount,
final double? amountMin,
final double? amountMax,
final Account? account,
final String? orderBy,
}) {
final SimpleSelectStatement<
$RecurringTransactionsTable,
RecurringTransaction
>
query = _db.select(_db.recurringTransactions);
if (id != null) {
query.where((final t) => t.id.equals(id));
}
if (name != null && name.isNotEmpty) {
query.where((final t) => t.name.like('%$name%'));
}
if (startDate != null) {
query.where((final t) => t.startDate.equals(startDate));
}
if (startDateAfter != null) {
query.where((final t) => t.startDate.isBiggerThanValue(startDateAfter));
}
if (startDateBefore != null) {
query.where((final t) => t.startDate.isSmallerThanValue(startDateBefore));
}
if (timeFrame != null) {
query.where((final t) => t.timeFrame.equals(timeFrame.index));
}
if (amount != null) {
query.where((final t) => t.amount.equals(amount));
}
if (amountMin != null) {
query.where((final t) => t.amount.isBiggerThanValue(amountMin));
}
if (amountMax != null) {
query.where((final t) => t.amount.isSmallerThanValue(amountMax));
}
if (account != null) {
query.where((final t) => t.accountId.equals(account.id));
}
if (orderBy != null) {
switch (orderBy) {
case 'nameAsc':
query.orderBy([(final t) => OrderingTerm.asc(t.name)]);
case 'nameDesc':
query.orderBy([(final t) => OrderingTerm.desc(t.name)]);
case 'amountAsc':
query.orderBy([(final t) => OrderingTerm.asc(t.amount)]);
case 'amountDesc':
query.orderBy([(final t) => OrderingTerm.desc(t.amount)]);
case 'startDateAsc':
query.orderBy([(final t) => OrderingTerm.asc(t.startDate)]);
case 'startDateDesc':
query.orderBy([(final t) => OrderingTerm.desc(t.startDate)]);
}
}
return query.get();
}
}

View File

@@ -0,0 +1,294 @@
import 'dart:async';
import 'package:drift/drift.dart';
import '../Entities/drift_database.dart';
import '../Services/database_service.dart';
/// Funktionen zum interagieren mit der Datenbank für die Transaktionen
class TransactionRepository {
final AppDatabase _db = DatabaseService().database;
/// Fügt eine neue Transaktion zur Datenbank hinzu
Future<Transaction?> add(final TransactionsCompanion transaction) async {
final DateTime date = transaction.date.value!.add(
transaction.date.value!.timeZoneOffset,
);
final TransactionsCompanion transactionToAdd = TransactionsCompanion(
id: transaction.id,
name: transaction.name,
date: Value(date),
amount: transaction.amount,
checked: transaction.checked,
accountId: transaction.accountId,
recurringTransactionId: transaction.recurringTransactionId,
updatedAt: Value(DateTime.now()),
);
final int id = await _db.into(_db.transactions).insert(transactionToAdd);
return find(id);
}
/// Aktualisiert eine Transaktion in der Datenbank
Future<bool> update(final TransactionsCompanion transaction) async {
final Transaction? transactionInDb = await find(transaction.id.value);
final Transaction? transactionData = transactionInDb?.copyWithCompanion(
transaction,
);
final DateTime date = transaction.date.value!.add(
transaction.date.value!.timeZoneOffset,
);
final TransactionsCompanion transactionToUpdate = TransactionsCompanion(
id: Value(transactionData?.id ?? transaction.id.value),
name: Value(transactionData?.name ?? transaction.name.value),
date: Value(date),
amount: Value(transactionData?.amount ?? transaction.amount.value),
checked: const Value(true),
accountId: Value(
transactionData?.accountId ?? transaction.accountId.value,
),
recurringTransactionId: Value(
transactionData?.recurringTransactionId ??
transaction.recurringTransactionId.value,
),
updatedAt: Value(DateTime.now()),
);
return _db.update(_db.transactions).replace(transactionToUpdate);
}
/// Entfernt eine Transaktion aus der Datenbank
Future<int> remove(final Transaction transaction) => (_db.delete(
_db.transactions,
)..where((final t) => t.id.equals(transaction.id))).go();
/// Sucht eine Transaktion anhand seiner Id aus der Datenbank
Future<Transaction?> find(final int id) => (_db.select(
_db.transactions,
)..where((final t) => t.id.equals(id))).getSingleOrNull();
/// Sucht Transaktionen anhand der gegebenen Parameter aus der Datenbank
Future<List<Transaction>> findBy({
final int? id,
final String? name,
final DateTime? date,
final DateTime? dateFrom,
final DateTime? dateTo,
final double? amount,
final double? amountMin,
final double? amountMax,
final Account? account,
final bool? checked,
final RecurringTransaction? recurringTransaction,
final int? limit,
final int? offset,
final String? orderBy,
}) {
final SimpleSelectStatement<$TransactionsTable, Transaction> query = _db
.select(_db.transactions);
if (id != null) {
query.where((final t) => t.id.equals(id));
}
if (name != null && name.isNotEmpty) {
query.where((final t) => t.name.like('%$name%'));
}
if (date != null) {
query.where((final t) => t.date.equals(date));
}
if (dateFrom != null) {
query.where((final t) => t.date.isBiggerThanValue(dateFrom));
}
if (dateTo != null) {
query.where((final t) => t.date.isSmallerThanValue(dateTo));
}
if (amount != null) {
query.where((final t) => t.amount.equals(amount));
}
if (amountMin != null) {
query.where((final t) => t.amount.isBiggerThanValue(amountMin));
}
if (amountMax != null) {
query.where((final t) => t.amount.isSmallerThanValue(amountMax));
}
if (account != null) {
query.where((final t) => t.accountId.equals(account.id));
}
if (checked != null) {
query.where((final t) => t.checked.equals(checked));
}
if (recurringTransaction != null) {
query.where(
(final t) => t.recurringTransactionId.equals(recurringTransaction.id),
);
}
if (limit != null) {
query.limit(limit, offset: offset);
}
if (orderBy != null) {
switch (orderBy) {
case 'nameAsc':
query.orderBy([(final t) => OrderingTerm.asc(t.name)]);
case 'nameDesc':
query.orderBy([(final t) => OrderingTerm.desc(t.name)]);
case 'dateAsc':
query.orderBy([(final t) => OrderingTerm.asc(t.date)]);
case 'dateDesc':
query.orderBy([(final t) => OrderingTerm.desc(t.date)]);
case 'amountAsc':
query.orderBy([(final t) => OrderingTerm.asc(t.amount)]);
case 'amountDesc':
query.orderBy([(final t) => OrderingTerm.desc(t.amount)]);
}
}
return query.get();
}
/// Gibt den Kontostand zurück
Future<double> balance({
final Account? account,
final DateTime? until,
}) async {
final JoinedSelectStatement<$TransactionsTable, Transaction> query =
_db.selectOnly(_db.transactions)
..addColumns([_db.transactions.amount.sum()]);
if (account != null) {
query.where(_db.transactions.accountId.equals(account.id));
}
if (until != null) {
query.where(_db.transactions.date.isSmallerOrEqualValue(until));
}
return (await query
.map((final row) => row.read(_db.transactions.amount.sum()) ?? 0)
.getSingle()) *
-1;
}
/// Gibt den Kontostand der letzten 12 Monate zurück
Future<List<Map<String, dynamic>>> monthlyBalances({
final Account? account,
final String? name,
final double? amountMin,
final double? amountMax,
DateTime? dateFrom,
DateTime? dateTo,
}) async {
final now = DateTime.now();
final monthStart = DateTime(now.year, now.month - 12);
final monthEnd = DateTime(now.year, now.month + 1, 0);
if (dateFrom == null || dateFrom.compareTo(monthStart) < 0) {
dateFrom = monthStart;
}
if (dateTo == null || dateTo.compareTo(monthEnd) > 0) {
dateTo = monthEnd;
}
final Expression<int> yearExpr = _db.transactions.date.year;
final Expression<int> monthExpr = _db.transactions.date.month;
final Expression<double> sumExpr = _db.transactions.amount.sum();
final JoinedSelectStatement<$TransactionsTable, Transaction> query =
_db.selectOnly(_db.transactions)
..addColumns([yearExpr, monthExpr, sumExpr])
..groupBy([yearExpr, monthExpr])
..orderBy([OrderingTerm.asc(yearExpr), OrderingTerm.asc(monthExpr)])
..where(_db.transactions.date.isBiggerOrEqualValue(dateFrom))
..where(_db.transactions.date.isSmallerOrEqualValue(dateTo));
if (account != null) {
query.where(_db.transactions.accountId.equals(account.id));
}
if (name != null && name.isNotEmpty) {
query.where(_db.transactions.name.like('%$name%'));
}
if (amountMin != null) {
query.where(_db.transactions.amount.isBiggerOrEqualValue(amountMin));
}
if (amountMax != null) {
query.where(_db.transactions.amount.isSmallerOrEqualValue(amountMax));
}
final List<Map<String, Object>> rows = (await query.get()).map((final row) {
final int year = row.read(yearExpr)!;
final int month = row.read(monthExpr)!;
return {
'date': DateTime(year, month),
'balance': (row.read(sumExpr) ?? 0) * -1,
};
}).toList();
double amount = await balance(account: account, until: dateFrom);
DateTime dateTimeLoop = dateFrom;
int loop = 0;
final List<Map<String, Object>> result = [];
while (dateTimeLoop.compareTo(monthEnd) < 0) {
Map<String, Object>? row;
for (final value in rows) {
final Object? rowDate = value['date'];
if (rowDate is DateTime) {
if (dateTimeLoop.compareTo(rowDate) == 0) {
row = value;
}
}
}
if (row == null) {
result.add({'date': dateTimeLoop, 'balance': 0.0});
} else {
result.add(row);
}
final double balance = double.parse(result[loop]['balance'].toString());
amount = balance + amount;
result[loop]['balance'] = amount;
loop = loop + 1;
dateTimeLoop = DateTime(dateTimeLoop.year, dateTimeLoop.month + 1);
}
return result;
}
/// Markiert die übergebenen Transaktionen als überprüft
void markTransactionsAsChecked(final List<Transaction> transactions) {
for (final value in transactions) {
final TransactionsCompanion transaction = TransactionsCompanion(
id: Value(value.id),
date: Value(value.date),
checked: const Value(true),
);
unawaited(update(transaction));
}
}
}

View File

@@ -0,0 +1,19 @@
import '../Entities/drift_database.dart';
/// Eine Klasse, um auf die Datenbank zugreifen zu können
class DatabaseService {
/// Gibt die aktuell gültige Instanz der Klasse zurück
factory DatabaseService() => _instance;
DatabaseService._internal();
static final DatabaseService _instance = DatabaseService._internal();
AppDatabase? _database;
/// Stellt die Datenbank dar
AppDatabase get database {
_database ??= AppDatabase();
return _database!;
}
}

View File

@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
/// Eine Service-Klasse um mit Datums-Werten umzugehen
class DateService {
/// Die Standard-Formatierung von einem Datum
static final DateFormat dateFormat = DateFormat('dd.MM.yyyy');
/// Wandelt einen String in eine DateTimeRange um
static DateTimeRange? stringToDateTimeRange(final String? value) {
if (value == null || value.trim().isEmpty) {
return null;
}
final List<String> parts = value.split(' - ');
if (parts.length != 2) {
return null;
}
try {
final DateTime start = DateService.dateFormat.parseStrict(
parts[0].trim(),
);
final DateTime end = DateService.dateFormat.parseStrict(parts[1].trim());
if (end.isBefore(start)) {
return null;
}
return DateTimeRange(start: start, end: end);
} on FormatException catch (_) {
return null;
}
}
/// Wandelt eine DateTimeRange in einen String um
static String? dateTimeRangeToString(final DateTimeRange? value) {
if (value != null) {
return '${dateFormat.format(value.start)}'
' - '
'${dateFormat.format(value.end)}';
}
return null;
}
}

View File

@@ -0,0 +1,25 @@
import 'dart:async';
/// Ein Service zur vereinfachung, um darauf zu warten,
/// dass etwas Initialisiert wurde
class Initializer {
bool _initialized = false;
final Completer<void> _initializedCompleter = Completer<void>();
/// Gibt zurück, ob bereits initialisiert wurde
bool get initialized => _initialized;
/// Auf diese Funktion kann gewartet werden,
/// bis [Initializer] initialisiert wurde
Future<void> waitUntilInitialized() => _initializedCompleter.future;
/// Setzt den [Initializer] auf initialisiert
void setInitialized() {
if (_initialized) {
return;
}
_initialized = true;
_initializedCompleter.complete();
}
}

View File

@@ -0,0 +1,11 @@
import 'package:flutter/material.dart';
/// Eine Klasse, die die Navigation innerhalb der App steuert
class NavigationService {
/// Der Navigator-Schlüssel, der für die Navigation verwendet wird
static final GlobalKey<NavigatorState> navigatorKey =
GlobalKey<NavigatorState>();
/// Gibt den aktuell gültigen BuildContext zurück
static BuildContext? getCurrentBuildContext() => navigatorKey.currentContext;
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:routemaster/routemaster.dart';
import '../Pages/Dashboard/dashboard.dart';
import '../Pages/Settings/settings.dart';
import '../Pages/Trend/trend.dart';
import '../Pages/home_page.dart';
/// Ein Service für das Routing innerhalb der App.
///
/// Diese Klasse definiert die verfügbaren Routen
/// und deren Zuordnung zu den entsprechenden Seiten.
class RouterService {
/// Die Routenkonfiguration der App.
static final RouteMap routes = RouteMap(
routes: <String, PageBuilder>{
'/': (_) => const TabPage(
child: HomePage(),
paths: <String>['/dashboard', '/trend', '/settings'],
),
'/dashboard': (_) => const MaterialPage<void>(child: Dashboard()),
'/trend': (_) => const MaterialPage<void>(child: Trend()),
'/settings': (_) => const MaterialPage<void>(child: Settings()),
},
onUnknownRoute: (_) => const Redirect('/'),
);
}

View File

@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
/// Ein Service für die Verwaltung der App-Themes.
///
/// Diese Klasse stellt Methoden bereit,
/// um das helle und dunkle Theme der App zu definieren.
class ThemeService {
/// Die Hauptfarbe der App, die als Basis für das Farbschema verwendet wird.
/// Ein helles Blau mit dem Hex-Wert #50A7FA.
static const Color color = Color(0xFF50A7FA);
/// Erstellt und gibt das helle Theme der App zurück.
static ThemeData getLightTheme() => ThemeData(
brightness: Brightness.light,
colorScheme: ColorScheme.fromSeed(seedColor: color),
);
/// Erstellt und gibt das dunkle Theme der App zurück.
static ThemeData getDarkTheme() => ThemeData(
brightness: Brightness.dark,
colorScheme: ColorScheme.fromSeed(
seedColor: color,
brightness: Brightness.dark,
),
);
/// Gibt die "Erfolgreich"-Farbe des Farbschemas zurück
static Color getSuccessColor({required final Brightness brightness}) {
final hsl = HSLColor.fromColor(color);
const double greenHue = 120;
final double saturation = (hsl.saturation * 0.7).clamp(0.0, 1.0);
final double lightness = brightness == Brightness.dark
? (hsl.lightness * 0.4).clamp(0.0, 1.0)
: (hsl.lightness * 1.2).clamp(0.0, 1.0);
final successHsl = HSLColor.fromAHSL(
hsl.alpha,
greenHue,
saturation,
lightness,
);
return successHsl.toColor();
}
}

View File

@@ -0,0 +1,29 @@
import 'dart:convert';
import '../Entities/drift_database.dart';
/// Ein Service um Transaktionen zu verarbeiten
class TransactionService {
/// Wandelt die übergebenen Transaktionen in einen String um
static String transactionsToString(final List<Transaction> transactions) {
final List<Map<String, dynamic>> jsonTransactions = transactions
.map((final value) => value.toJson())
.toList();
return jsonEncode(jsonTransactions);
}
/// Wandelt den String in eine Liste von Transaktionen um
static List<Transaction> transactionsFromString(final String? transactions) {
if (transactions == null) {
return [];
}
try {
final List<dynamic> decoded = jsonDecode(transactions);
return decoded.map((final item) => Transaction.fromJson(item)).toList();
} on Exception {
return [];
}
}
}

View File

@@ -0,0 +1,16 @@
import 'package:flutter/services.dart';
import 'package:logger/logger.dart';
import '../../Controller/port_controller.dart';
/// Initialisiert benötigte Services in Background-Isolates
Future<void> initBackground() async {
final Logger logger = Logger()..d('Init Background for Native');
final RootIsolateToken? rootIsolateToken = await PortController()
.getRootIsolateToken();
if (rootIsolateToken != null) {
logger.i('Initialising BackgroundIsolateBinaryMessenger...');
BackgroundIsolateBinaryMessenger.ensureInitialized(rootIsolateToken);
}
}

View File

@@ -0,0 +1,6 @@
import 'package:logger/logger.dart';
/// Initialisiert benötigte Services in Background-Isolates für PlatformDependent
Future<void> initBackground() async {
Logger().d('Init Background for PlatformDependent');
}

View File

@@ -0,0 +1,43 @@
import 'dart:async';
import 'package:isolate_manager/isolate_manager.dart';
import '../generate_transactions_task.dart';
import '../show_notifications_task.dart';
import 'background_init_web.dart' if (dart.library.io) 'background_init.dart';
@pragma('vm:entry-point')
@isolateManagerWorker
/// Führt eine Hintergrundtask lokal aus
Future<void> runTask(final Map<String, dynamic> params) async {
final String taskName = params['taskName'];
final int initialDelayMinutes = params['initialDelayMinutes'];
final int frequencyMinutes = params['frequencyMinutes'];
await Future.delayed(Duration(minutes: initialDelayMinutes));
await executeTask(taskName, null);
if (frequencyMinutes > 0) {
await Future.delayed(Duration(minutes: frequencyMinutes));
unawaited(runTask(params));
}
}
/// Funktion um Hintergrundaufgaben auszuführen
Future<bool> executeTask(
final String taskName,
final Map<String, dynamic>? inputData,
) async {
await initBackground();
switch (taskName) {
case 'generate_transactions':
return GenerateTransactionsTask().execute();
case 'show_notifications':
return ShowNotificationsTask().execute();
}
return Future.value(true);
}

View File

@@ -0,0 +1,9 @@
import 'package:workmanager/workmanager.dart';
import 'workers.dart';
/// Die Funktion wird von Hintergrundtasks ausgerufen, um diese auszuführen
@pragma('vm:entry-point')
void callbackDispatcher() {
Workmanager().executeTask(executeTask);
}

View File

@@ -0,0 +1,157 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:logger/logger.dart';
import '../Entities/drift_database.dart';
import '../Entities/time_frame_enum.dart';
import '../PlatformDependent/Web/date_utils_web.dart'
if (dart.library.io) 'package:flutter/material.dart';
import '../PlatformDependent/Web/update_transactions.dart'
if (dart.library.io) '../PlatformDependent/Native/update_transactions.dart';
import '../Repositories/recurring_transacation_repository.dart';
import '../Repositories/transaction_repository.dart';
import 'task.dart';
/// Generiert neue Transaktionen
/// anhand der erstellten wiederkehrenden Transaktionen
class GenerateTransactionsTask extends Task {
final TransactionRepository _transactionRepository = TransactionRepository();
final RecurringTransactionRepository _recurringTransactionRepository =
RecurringTransactionRepository();
final Logger _logger = Logger();
@override
Future<bool> execute() async {
_logger.i('Generating Transactions of recurring Transactions...');
final List<RecurringTransaction> recurringTransactions =
await _recurringTransactionRepository.findBy();
for (final recurringTransaction in recurringTransactions) {
_logger.i('Generating Transactions of ${recurringTransaction.name}...');
final List<Transaction> transactions = await _transactionRepository
.findBy(
recurringTransaction: recurringTransaction,
orderBy: 'dateDesc',
);
final Transaction? transaction = transactions.firstOrNull;
await _generateTransactions(
recurringTransaction,
transaction?.date,
number: transactions.length,
);
}
_logger.i('Generating transactions completed.');
updateTransactions();
return true;
}
Future<bool> _generateTransactions(
final RecurringTransaction recurringTransaction,
final DateTime? lastTransactionDate, {
final int number = 0,
}) async {
final DateTime newTransactionDate;
if (lastTransactionDate != null) {
switch (recurringTransaction.timeFrame) {
case TimeFrameEnum.daily:
newTransactionDate = lastTransactionDate.add(const Duration(days: 1));
case TimeFrameEnum.weekly:
newTransactionDate = lastTransactionDate.add(const Duration(days: 7));
case TimeFrameEnum.monthly:
final DateTime monthDate = DateUtils.addMonthsToMonthDate(
lastTransactionDate,
1,
);
final int day =
recurringTransaction.startDate!.day <
DateUtils.getDaysInMonth(
monthDate.year,
monthDate.day,
)
? recurringTransaction.startDate!.day
: DateUtils.getDaysInMonth(
monthDate.year,
monthDate.month,
);
newTransactionDate = DateTime(
monthDate.year,
monthDate.month,
day,
);
case TimeFrameEnum.yearly:
final DateTime monthDate = DateUtils.addMonthsToMonthDate(
lastTransactionDate,
12,
);
final int day =
recurringTransaction.startDate!.day <
DateUtils.getDaysInMonth(
monthDate.year,
monthDate.day,
)
? recurringTransaction.startDate!.day
: DateUtils.getDaysInMonth(
monthDate.year,
monthDate.month,
);
newTransactionDate = DateTime(
monthDate.year,
monthDate.month,
day,
);
}
} else {
newTransactionDate = recurringTransaction.startDate!;
}
_logger.i('New transaction-date is $newTransactionDate');
final DateTime endOfMonth = DateTime(
DateTime.now().year,
DateTime.now().month + 1,
0,
);
if (endOfMonth.compareTo(newTransactionDate) >= 0) {
_logger.i('$newTransactionDate is before $endOfMonth');
final TransactionsCompanion transaction = TransactionsCompanion(
name: Value('${recurringTransaction.name} #$number'),
date: Value(newTransactionDate),
amount: Value(recurringTransaction.amount),
checked: const Value(false),
accountId: Value(recurringTransaction.accountId),
recurringTransactionId: Value(recurringTransaction.id),
);
_logger.i(
'Adding transaction ${transaction.name.value}'
' on ${transaction.date.value}',
);
await _transactionRepository.add(transaction);
return _generateTransactions(
recurringTransaction,
newTransactionDate,
number: number + 1,
);
} else {
_logger.i('$newTransactionDate is after $endOfMonth');
return true;
}
}
}

View File

@@ -0,0 +1,30 @@
import 'package:logger/logger.dart';
import '../Entities/drift_database.dart';
import '../PlatformDependent/Web/local_notifications_web_stub.dart'
if (dart.library.io) '../Controller/local_notifications.dart';
import '../Repositories/transaction_repository.dart';
import 'task.dart';
/// Zeigt Benachrichtigungen für nicht überprüfte Transaktionen an.
class ShowNotificationsTask extends Task {
final TransactionRepository _transactionRepository = TransactionRepository();
@override
Future<bool> execute() async {
final List<Transaction> transactions = await _transactionRepository.findBy(
checked: false,
dateTo: DateTime.now(),
);
if (transactions.isNotEmpty) {
Logger().i('Showing notification for unchecked transactions...');
await LocalNotifications().showTransactionsToCheckNotification(
transactions,
);
}
return Future.value(true);
}
}

5
lib/Tasks/task.dart Normal file
View File

@@ -0,0 +1,5 @@
/// Ein Command, welcher ausgeführt werden kann
abstract class Task {
/// Führt den Command aus
Future<bool> execute();
}

43
lib/main.dart Normal file
View File

@@ -0,0 +1,43 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:routemaster/routemaster.dart';
import 'Controller/background_task_controller.dart';
import 'Controller/local_notifications.dart';
import 'Controller/port_controller.dart';
import 'Services/navigation_service.dart';
import 'Services/router_service.dart';
import 'Services/theme_service.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
PortController();
BackgroundTaskController();
if (!kIsWeb) {
LocalNotifications();
}
runApp(
MaterialApp.router(
routerDelegate: RoutemasterDelegate(
routesBuilder: (final BuildContext context) => RouterService.routes,
navigatorKey: NavigationService.navigatorKey,
),
routeInformationParser: const RoutemasterParser(),
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
title: 'DragonLedger 🐉📒',
theme: ThemeService.getLightTheme(),
darkTheme: ThemeService.getDarkTheme(),
),
);
}

1
linux/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
flutter/ephemeral

128
linux/CMakeLists.txt Normal file
View File

@@ -0,0 +1,128 @@
# Project-level configuration.
cmake_minimum_required(VERSION 3.13)
project(runner LANGUAGES CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.
set(BINARY_NAME "dragon_ledger")
# The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "de.creativedragonslayer.dragon_ledger")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
cmake_policy(SET CMP0063 NEW)
# Load bundled libraries from the lib/ directory relative to the binary.
set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
# Root filesystem for cross-building.
if(FLUTTER_TARGET_PLATFORM_SYSROOT)
set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
endif()
# Define build configuration options.
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE "Debug" CACHE
STRING "Flutter build mode" FORCE)
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
"Debug" "Profile" "Release")
endif()
# Compilation settings that should be applied to most targets.
#
# Be cautious about adding new options here, as plugins use this function by
# default. In most cases, you should add new options to specific targets instead
# of modifying this function.
function(APPLY_STANDARD_SETTINGS TARGET)
target_compile_features(${TARGET} PUBLIC cxx_std_14)
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
endfunction()
# Flutter library and tool build rules.
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
add_subdirectory(${FLUTTER_MANAGED_DIR})
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
# Application build; see runner/CMakeLists.txt.
add_subdirectory("runner")
# Run the Flutter tool portions of the build. This must not be removed.
add_dependencies(${BINARY_NAME} flutter_assemble)
# Only the install-generated bundle's copy of the executable will launch
# correctly, since the resources must in the right relative locations. To avoid
# people trying to run the unbundled copy, put it in a subdirectory instead of
# the default top-level location.
set_target_properties(${BINARY_NAME}
PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
)
# Generated plugin build rules, which manage building the plugins and adding
# them to the application.
include(flutter/generated_plugins.cmake)
# === Installation ===
# By default, "installing" just makes a relocatable bundle in the build
# directory.
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
endif()
# Start with a clean build bundle directory every time.
install(CODE "
file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
" COMPONENT Runtime)
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
COMPONENT Runtime)
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
COMPONENT Runtime)
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
install(FILES "${bundled_library}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endforeach(bundled_library)
# Copy the native assets provided by the build.dart from all packages.
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
# Fully re-copy the assets directory on each build to avoid having stale files
# from a previous install.
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
install(CODE "
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
" COMPONENT Runtime)
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
# Install the AOT library on non-Debug builds only.
if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endif()

View File

@@ -0,0 +1,88 @@
# This file controls Flutter-level build steps. It should not be edited.
cmake_minimum_required(VERSION 3.10)
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
# Configuration provided via flutter tool.
include(${EPHEMERAL_DIR}/generated_config.cmake)
# TODO: Move the rest of this into files in ephemeral. See
# https://github.com/flutter/flutter/issues/57146.
# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
# which isn't available in 3.10.
function(list_prepend LIST_NAME PREFIX)
set(NEW_LIST "")
foreach(element ${${LIST_NAME}})
list(APPEND NEW_LIST "${PREFIX}${element}")
endforeach(element)
set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
endfunction()
# === Flutter Library ===
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
# Published to parent scope for install step.
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
list(APPEND FLUTTER_LIBRARY_HEADERS
"fl_basic_message_channel.h"
"fl_binary_codec.h"
"fl_binary_messenger.h"
"fl_dart_project.h"
"fl_engine.h"
"fl_json_message_codec.h"
"fl_json_method_codec.h"
"fl_message_codec.h"
"fl_method_call.h"
"fl_method_channel.h"
"fl_method_codec.h"
"fl_method_response.h"
"fl_plugin_registrar.h"
"fl_plugin_registry.h"
"fl_standard_message_codec.h"
"fl_standard_method_codec.h"
"fl_string_codec.h"
"fl_value.h"
"fl_view.h"
"flutter_linux.h"
)
list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
add_library(flutter INTERFACE)
target_include_directories(flutter INTERFACE
"${EPHEMERAL_DIR}"
)
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
target_link_libraries(flutter INTERFACE
PkgConfig::GTK
PkgConfig::GLIB
PkgConfig::GIO
)
add_dependencies(flutter flutter_assemble)
# === Flutter tool backend ===
# _phony_ is a non-existent file to force this command to run every time,
# since currently there's no way to get a full input/output list from the
# flutter tool.
add_custom_command(
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
${CMAKE_CURRENT_BINARY_DIR}/_phony_
COMMAND ${CMAKE_COMMAND} -E env
${FLUTTER_TOOL_ENVIRONMENT}
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
VERBATIM
)
add_custom_target(flutter_assemble DEPENDS
"${FLUTTER_LIBRARY}"
${FLUTTER_LIBRARY_HEADERS}
)

View File

@@ -0,0 +1,15 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin");
sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar);
}

View File

@@ -0,0 +1,15 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter_linux/flutter_linux.h>
// Registers Flutter plugins.
void fl_register_plugins(FlPluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_

View File

@@ -0,0 +1,24 @@
#
# Generated file, do not edit.
#
list(APPEND FLUTTER_PLUGIN_LIST
sqlite3_flutter_libs
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
)
set(PLUGIN_BUNDLED_LIBRARIES)
foreach(plugin ${FLUTTER_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
endforeach(plugin)
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
endforeach(ffi_plugin)

View File

@@ -0,0 +1,26 @@
cmake_minimum_required(VERSION 3.13)
project(runner LANGUAGES CXX)
# Define the application target. To change its name, change BINARY_NAME in the
# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
# work.
#
# Any new source files that you add to the application should be added here.
add_executable(${BINARY_NAME}
"main.cc"
"my_application.cc"
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
)
# Apply the standard set of build settings. This can be removed for applications
# that need different build settings.
apply_standard_settings(${BINARY_NAME})
# Add preprocessor definitions for the application ID.
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
# Add dependency libraries. Add any application-specific dependencies here.
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")

6
linux/runner/main.cc Normal file
View File

@@ -0,0 +1,6 @@
#include "my_application.h"
int main(int argc, char** argv) {
g_autoptr(MyApplication) app = my_application_new();
return g_application_run(G_APPLICATION(app), argc, argv);
}

View File

@@ -0,0 +1,144 @@
#include "my_application.h"
#include <flutter_linux/flutter_linux.h>
#ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h>
#endif
#include "flutter/generated_plugin_registrant.h"
struct _MyApplication {
GtkApplication parent_instance;
char** dart_entrypoint_arguments;
};
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
// Called when first Flutter frame received.
static void first_frame_cb(MyApplication* self, FlView *view)
{
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
}
// Implements GApplication::activate.
static void my_application_activate(GApplication* application) {
MyApplication* self = MY_APPLICATION(application);
GtkWindow* window =
GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
// Use a header bar when running in GNOME as this is the common style used
// by applications and is the setup most users will be using (e.g. Ubuntu
// desktop).
// If running on X and not using GNOME then just use a traditional title bar
// in case the window manager does more exotic layout, e.g. tiling.
// If running on Wayland assume the header bar will work (may need changing
// if future cases occur).
gboolean use_header_bar = TRUE;
#ifdef GDK_WINDOWING_X11
GdkScreen* screen = gtk_window_get_screen(window);
if (GDK_IS_X11_SCREEN(screen)) {
const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
use_header_bar = FALSE;
}
}
#endif
if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar));
gtk_header_bar_set_title(header_bar, "DragonLedger");
gtk_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else {
gtk_window_set_title(window, "DragonLedger");
}
gtk_window_set_default_size(window, 1280, 720);
g_autoptr(FlDartProject) project = fl_dart_project_new();
fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments);
FlView* view = fl_view_new(project);
GdkRGBA background_color;
// Background defaults to black, override it here if necessary, e.g. #00000000 for transparent.
gdk_rgba_parse(&background_color, "#000000");
fl_view_set_background_color(view, &background_color);
gtk_widget_show(GTK_WIDGET(view));
gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
// Show the window when Flutter renders.
// Requires the view to be realized so we can start rendering.
g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), self);
gtk_widget_realize(GTK_WIDGET(view));
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
gtk_widget_grab_focus(GTK_WIDGET(view));
}
// Implements GApplication::local_command_line.
static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) {
MyApplication* self = MY_APPLICATION(application);
// Strip out the first argument as it is the binary name.
self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
g_autoptr(GError) error = nullptr;
if (!g_application_register(application, nullptr, &error)) {
g_warning("Failed to register: %s", error->message);
*exit_status = 1;
return TRUE;
}
g_application_activate(application);
*exit_status = 0;
return TRUE;
}
// Implements GApplication::startup.
static void my_application_startup(GApplication* application) {
//MyApplication* self = MY_APPLICATION(object);
// Perform any actions required at application startup.
G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
}
// Implements GApplication::shutdown.
static void my_application_shutdown(GApplication* application) {
//MyApplication* self = MY_APPLICATION(object);
// Perform any actions required at application shutdown.
G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
}
// Implements GObject::dispose.
static void my_application_dispose(GObject* object) {
MyApplication* self = MY_APPLICATION(object);
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
}
static void my_application_class_init(MyApplicationClass* klass) {
G_APPLICATION_CLASS(klass)->activate = my_application_activate;
G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line;
G_APPLICATION_CLASS(klass)->startup = my_application_startup;
G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown;
G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
}
static void my_application_init(MyApplication* self) {}
MyApplication* my_application_new() {
// Set the program name to the application ID, which helps various systems
// like GTK and desktop environments map this running application to its
// corresponding .desktop file. This ensures better integration by allowing
// the application to be recognized beyond its binary name.
g_set_prgname(APPLICATION_ID);
return MY_APPLICATION(g_object_new(my_application_get_type(),
"application-id", APPLICATION_ID,
"flags", G_APPLICATION_NON_UNIQUE,
nullptr));
}

View File

@@ -0,0 +1,18 @@
#ifndef FLUTTER_MY_APPLICATION_H_
#define FLUTTER_MY_APPLICATION_H_
#include <gtk/gtk.h>
G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION,
GtkApplication)
/**
* my_application_new:
*
* Creates a new Flutter-based application.
*
* Returns: a new #MyApplication.
*/
MyApplication* my_application_new();
#endif // FLUTTER_MY_APPLICATION_H_

102
pubspec.yaml Normal file
View File

@@ -0,0 +1,102 @@
name: dragon_ledger
description: "Eine Flutter-App um den Überblick über wiederkehrende Transaktionen und den damit verbunden Kontostand zu behalten."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: ^3.9.2
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
date_field: ^6.0.3+1
drift: ^2.30.0
drift_flutter: ^0.2.8
dropdown_search: ^6.0.2
fl_chart: ^1.1.1
flutter:
sdk: flutter
flutter_expandable_fab: ^2.5.2
flutter_local_notifications: ^19.5.0
flutter_localizations:
sdk: flutter
flutter_sticky_header: ^0.8.0
font_awesome_flutter: ^10.10.0
intl: ^0.20.2
isolate_manager: ^6.1.2
logger: ^2.6.2
package_info_plus: ^9.0.0
path_provider: ^2.1.5
routemaster: ^1.0.1
uuid: ^4.5.2
workmanager: ^0.9.0+3
dev_dependencies:
build_runner: ^2.10.4
drift_dev: ^2.30.0
flutter_launcher_icons: ^0.14.4
flutter_lints: ^6.0.0
flutter_test:
sdk: flutter
isolate_manager_generator: ^0.3.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package

Some files were not shown because too many files have changed in this diff Show More