diff --git a/.env.dev b/.env.dev new file mode 100644 index 0000000..b7c75e6 --- /dev/null +++ b/.env.dev @@ -0,0 +1,8 @@ +# Don't forget, .env should never be commited. +# If you want to keep your environment variables secret, +# you might be interested in git-crypt, otherwise add +# the .env to your .gitignore file. + +# DEV +API_URL=https://dev.com/api/v1 +ANALYTICS_URL=https://dev.analytics.com diff --git a/.env.prod b/.env.prod new file mode 100644 index 0000000..ad97203 --- /dev/null +++ b/.env.prod @@ -0,0 +1,8 @@ +# Don't forget, .env should never be commited. +# If you want to keep your environment variables secret, +# you might be interested in git-crypt, otherwise add +# the .env to your .gitignore file. + +# DEV +API_URL=https://prod.com/api/v1 +ANALYTICS_URL=https://prod.analytics.com diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 0000000..906bbb3 --- /dev/null +++ b/.fvmrc @@ -0,0 +1,3 @@ +{ + "flutter": "3.24.3" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e844d19 --- /dev/null +++ b/.gitignore @@ -0,0 +1,135 @@ +# Miscellaneous +*.class +*.lock +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# Visual Studio Code related +.classpath +.project +.settings/ +.vscode/ + +# Flutter repo-specific +/bin/cache/ +/bin/internal/bootstrap.bat +/bin/internal/bootstrap.sh +/bin/mingit/ +/dev/benchmarks/mega_gallery/ +/dev/bots/.recipe_deps +/dev/bots/android_tools/ +/dev/devicelab/ABresults*.json +/dev/docs/doc/ +/dev/docs/flutter.docs.zip +/dev/docs/lib/ +/dev/docs/pubspec.yaml +/dev/integration_tests/**/xcuserdata +/dev/integration_tests/**/Pods +/packages/flutter/coverage/ +version +analysis_benchmark.json + +# packages file containing multi-root paths +.packages.generated + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +**/generated_plugin_registrant.dart +.packages +.pub-cache/ +.pub/ +build/ +flutter_*.png +linked_*.ds +unlinked.ds +unlinked_spec.ds + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java +**/android/key.properties +*.jks + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/.last_build_id +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# macOS +**/macos/Flutter/GeneratedPluginRegistrant.swift + +# Coverage +coverage/ + +# Symbols +app.*.symbols + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages +!/dev/ci/**/Gemfile.lock + +/ios/build/* + +# Hide injection files. +/*.inject.summary +/*.inject.dart +**/*.freezed.dart +**/*.g.dart +**/*.gr.dart +**/*.config.dart +android/app/release/ + +# don't check in golden failure output +**/failures/*.png + +# FVM Version Cache +.fvm/ diff --git a/README.md b/README.md index fb5dd8c..3260657 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,72 @@ -# fvm_monorepo_bloc_boilerplate -fvm_monorepo_bloc_boilerplate +# Flutter Monorepo Bloc Boilerplate + +## Getting Started + +Follow these steps to set up and run the project: + +### Prerequisites + +- **Git** installed on your machine. +- **Dart Sdk (dart-sdk)** installed. + + ```bash + brew install dart-sdk + ``` + +- **Flutter Version Management (FVM)** installed. + + ```bash + brew install fvm + ``` + +- **Melos** installed. + + ```bash + fvm dart pub global activate melos + ``` + +### Installation + +1. **Clone the Repository** + + ```bash + git clone https://github.com/thomashoangvn/fvm_monorepo_bloc_boilerplate.git + cd fvm_monorepo_bloc_boilerplate + ``` + +2. **Set Flutter Version with FVM** + + Use FVM to switch to the required Flutter version: + + ```bash + fvm install 3.24.3 + fvm use 3.24.3 + ``` + +3. **Install Dependencies** + + Fetch all project dependencies: + + ```bash + fvm flutter pub get + ``` + +4. **Bootstrap the Project with Melos** + + Initialize the project using Melos: + + ```bash + fvm dart pub global activate melos + ``` + + ```bash + melos bootstrap + ``` + +## Contributing + +We appreciate your interest in contributing to FMBB. Feel free to open issues or submit pull requests. + +## License + +This project is licensed under the [BSD-4-Clause License](LICENSE). diff --git a/all_lint_rules.yaml b/all_lint_rules.yaml new file mode 100644 index 0000000..088fb42 --- /dev/null +++ b/all_lint_rules.yaml @@ -0,0 +1,227 @@ +# Official list of all Dart & Flutter lint rules: +# https://dart.dev/tools/linter-rules/all + +linter: + rules: + - always_declare_return_types + - always_put_control_body_on_new_line + - always_put_required_named_parameters_first + - always_specify_types + - always_use_package_imports + - annotate_overrides + - annotate_redeclares + - avoid_annotating_with_dynamic + - avoid_bool_literals_in_conditional_expressions + - avoid_catches_without_on_clauses + - avoid_catching_errors + - avoid_classes_with_only_static_members + - 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_final_parameters + - avoid_function_literals_in_foreach_calls + - 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_types_on_closure_parameters + - 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 + - diagnostic_describe_all_properties + - 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 + - omit_local_variable_types + - one_member_abstracts + - only_throw_errors + - overridden_fields + - package_api_docs + - 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_double_quotes + - 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 + - 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_await_in_return + - unnecessary_brace_in_string_interps + - unnecessary_breaks + - unnecessary_const + - unnecessary_constructor_name + - unnecessary_final + - unnecessary_getters_setters + - 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 + - unreachable_from_main + - unrelated_type_equality_checks + - unsafe_html + - 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 + - valid_regexps + - void_checks \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..1544bcc --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,138 @@ +include: all_lint_rules.yaml + +analyzer: + errors: + # Otherwise cause the import of all_lint_rules to warn because of some rules conflicts. + # We explicitly enabled even conflicting rules and are fixing the conflict + # in this file + included_file_warning: ignore + # https://github.com/rrousselGit/freezed/issues/488 + invalid_annotation_target: ignore + + exclude: + # Exclude generated files + - "**/generated_plugin_registrant.dart" + - "deps/**" + - "**/overridens/**" + - "**/animated_box_decoration/**" + - "**.g.dart" + +# DCM (formerly Dart Code Metrics) is a toolkit that helps to identify and fix problems. +# https://dcm.dev/docs/getting-started/ +dart_code_metrics: + extends: + - package:dart_code_metrics_presets/all.yaml + rules: + # will be removed + - no-magic-string: false + - no-magic-number: false + - parameters-ordering: false + - avoid-barrel-files: false + - avoid-inferrable-type-arguments: false + - avoid-explicit-type-declaration: false + - member-ordering: + order: + - constructors + - public-fields + - private-fields + - close-method + - dispose-method + widgets-order: + - constructor + - build-method + - init-state-method + - did-change-dependencies-method + - did-update-widget-method + - dispose-method + - avoid-non-null-assertion: + skip-checked-fields: true + - prefer-moving-to-variable: + allowed-duplicated-chains: 5 + - avoid-late-keyword: + allow-initialized: true + ignored-types: + - StreamSubscription + - avoid-nested-conditional-expressions: + acceptable-level: 2 + - prefer-correct-throws: false + - prefer-commenting-analyzer-ignores: false + - avoid-similar-names: + show-similarity: true + similarity-threshold: 0.9 + - prefer-boolean-prefixes: + ignored-names: + - newValue + - prefer-named-parameters: + max-number: 3 + - prefer-extracting-function-callbacks: false + + +linter: + rules: + # Boring as it sometimes force a line of 81 characters to be split in two. + # As long as we try to respect that 80 characters limit, going slightly + # above is fine. + lines_longer_than_80_chars: false + + # Not quite suitable for Flutter, which may have a `build` method with a single + # return, but that return is still complex enough that a "body" is worth it. + prefer_expression_function_bodies: false + + # There are situations where we voluntarily want to catch everything, + # especially as a library. + avoid_catches_without_on_clauses: false + + # Conflicts with `always_specify_types` and other rules. + # As per Dart guidelines, we want to avoid unnecessary types + # to make the code more readable. + # See https://dart.dev/guides/language/effective-dart/design#avoid-type-annotating-initialized-local-variables + omit_local_variable_types: false + avoid_types_on_closure_parameters: false + + # Far too verbose, and not that big of a deal when using parameter_assignments + prefer_final_parameters: false + + # Conflicts with `prefer_single_quotes` + # Single quotes are easier to type and don't compromise on readability. + prefer_double_quotes: false + + # This project doesn't use Flutter-style todos + flutter_style_todos: false + + # Conflicts with disabling `implicit-dynamic` + avoid_annotating_with_dynamic: false + + # Incompatible with `prefer_final_locals` + # Having immutable local variables makes larger functions more predictible + # so we will use `prefer_final_locals` instead. + unnecessary_final: false + + # conflicts with `prefer_relative_imports` + prefer_relative_imports: false + + # Disabled for now until we have NNBD as it otherwise conflicts with `missing_return` + no_default_cases: false + + # False positive, null checks don't need a message + prefer_asserts_with_message: false + + # Too many false positive (builders) + diagnostic_describe_all_properties: false + + # false positives (setter-like functions) + avoid_positional_boolean_parameters: false + + # Does not apply to providers + prefer_const_constructors_in_immutables: false + + ## Disabled rules because the repository doesn't respect them (yet) + comment_references: false + avoid_classes_with_only_static_members: false + do_not_use_environment: false + discarded_futures: false + use_decorated_box: false + public_member_api_docs: false + one_member_abstracts: false + sort_constructors_first: false + sort_child_properties_last: false + document_ignores: false \ No newline at end of file diff --git a/app/.metadata b/app/.metadata new file mode 100644 index 0000000..2d1be89 --- /dev/null +++ b/app/.metadata @@ -0,0 +1,45 @@ +# 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: "2663184aa79047d0a33a14a3b607954f8fdd8730" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + - platform: android + create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + - platform: ios + create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + - platform: linux + create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + - platform: macos + create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + - platform: web + create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + - platform: windows + create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + + # 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' diff --git a/app/android/.gitignore b/app/android/.gitignore new file mode 100644 index 0000000..55afd91 --- /dev/null +++ b/app/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle new file mode 100644 index 0000000..54b6d53 --- /dev/null +++ b/app/android/app/build.gradle @@ -0,0 +1,44 @@ +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 = "com.example.app" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.app" + // 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.debug + } + } +} + +flutter { + source = "../.." +} diff --git a/app/android/app/src/debug/AndroidManifest.xml b/app/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/app/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6ee3df0 --- /dev/null +++ b/app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/android/app/src/main/kotlin/com/example/app/MainActivity.kt b/app/android/app/src/main/kotlin/com/example/app/MainActivity.kt new file mode 100644 index 0000000..026d9a9 --- /dev/null +++ b/app/android/app/src/main/kotlin/com/example/app/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.app + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() diff --git a/app/android/app/src/main/res/drawable-v21/launch_background.xml b/app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/android/app/src/main/res/drawable/launch_background.xml b/app/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/app/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/values-night/styles.xml b/app/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/app/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/android/app/src/main/res/values/styles.xml b/app/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/android/app/src/profile/AndroidManifest.xml b/app/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/app/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/android/build.gradle b/app/android/build.gradle new file mode 100644 index 0000000..d2ffbff --- /dev/null +++ b/app/android/build.gradle @@ -0,0 +1,18 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = "../build" +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/app/android/gradle.properties b/app/android/gradle.properties new file mode 100644 index 0000000..2597170 --- /dev/null +++ b/app/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/app/android/gradle/wrapper/gradle-wrapper.properties b/app/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..7bb2df6 --- /dev/null +++ b/app/android/gradle/wrapper/gradle-wrapper.properties @@ -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.3-all.zip diff --git a/app/android/settings.gradle b/app/android/settings.gradle new file mode 100644 index 0000000..b9e43bd --- /dev/null +++ b/app/android/settings.gradle @@ -0,0 +1,25 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return 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.1.0" apply false + id "org.jetbrains.kotlin.android" version "1.8.22" apply false +} + +include ":app" diff --git a/app/devtools_options.yaml b/app/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/app/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/app/ios/.gitignore b/app/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/app/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/app/ios/Flutter/AppFrameworkInfo.plist b/app/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..7c56964 --- /dev/null +++ b/app/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 12.0 + + diff --git a/app/ios/Flutter/Debug.xcconfig b/app/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/app/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/app/ios/Flutter/Release.xcconfig b/app/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..a6bc54d --- /dev/null +++ b/app/ios/Flutter/Release.xcconfig @@ -0,0 +1,3 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include? "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig" +#include "Generated.xcconfig" diff --git a/app/ios/Podfile b/app/ios/Podfile new file mode 100644 index 0000000..164df53 --- /dev/null +++ b/app/ios/Podfile @@ -0,0 +1,44 @@ +# Uncomment this line to define a global platform for your project +platform :ios, '12.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/app/ios/Runner.xcodeproj/project.pbxproj b/app/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..d0e8038 --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,749 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 425CD6CFA401537AE8BF505F /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8A6EB4AA0E9481FF907CCA32 /* Pods_RunnerTests.framework */; }; + 456B693358D48777BCAE3CA0 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B3DA5B12908B1E7A5A59C538 /* Pods_Runner.framework */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 1F8CAAE8541E4EEE2D2A859A /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 2F496530966018908EC99B38 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 360F51F1853457F44E542B77 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 4953B94E7C58FA13659611BC /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 60E9000D6E63B296A1D9E487 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 6D5BC415494363A16C2EE956 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 8A6EB4AA0E9481FF907CCA32 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B3DA5B12908B1E7A5A59C538 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 456B693358D48777BCAE3CA0 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B60B7BD87976D6F5A8514C6D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 425CD6CFA401537AE8BF505F /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + D9775E02DF6FF81005DFC1CD /* Pods */, + E413E863D59FF9B299879E8E /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + D9775E02DF6FF81005DFC1CD /* Pods */ = { + isa = PBXGroup; + children = ( + 360F51F1853457F44E542B77 /* Pods-Runner.debug.xcconfig */, + 6D5BC415494363A16C2EE956 /* Pods-Runner.release.xcconfig */, + 4953B94E7C58FA13659611BC /* Pods-Runner.profile.xcconfig */, + 2F496530966018908EC99B38 /* Pods-RunnerTests.debug.xcconfig */, + 60E9000D6E63B296A1D9E487 /* Pods-RunnerTests.release.xcconfig */, + 1F8CAAE8541E4EEE2D2A859A /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + E413E863D59FF9B299879E8E /* Frameworks */ = { + isa = PBXGroup; + children = ( + B3DA5B12908B1E7A5A59C538 /* Pods_Runner.framework */, + 8A6EB4AA0E9481FF907CCA32 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + C219CBF6326AB5C1B1FEBEBD /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + B60B7BD87976D6F5A8514C6D /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 1187E3FD219E4F1FF1AAA38C /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + A0C87628D40FF2E3BD12E3B1 /* [CP] Embed Pods Frameworks */, + F550E787ECC72F6B4696F768 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 1187E3FD219E4F1FF1AAA38C /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + A0C87628D40FF2E3BD12E3B1 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + C219CBF6326AB5C1B1FEBEBD /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + F550E787ECC72F6B4696F768 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = G6GSP49983; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 2F496530966018908EC99B38 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.app.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 60E9000D6E63B296A1D9E487 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.app.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1F8CAAE8541E4EEE2D2A859A /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.app.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = G6GSP49983; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = G6GSP49983; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..8e3ca5d --- /dev/null +++ b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/ios/Runner.xcworkspace/contents.xcworkspacedata b/app/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/app/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/app/ios/Runner/AppDelegate.swift b/app/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/app/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/app/ios/Runner/Base.lproj/LaunchScreen.storyboard b/app/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/app/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/ios/Runner/Base.lproj/Main.storyboard b/app/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/app/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/ios/Runner/Info.plist b/app/ios/Runner/Info.plist new file mode 100644 index 0000000..c2e05c5 --- /dev/null +++ b/app/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + App + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + app + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/app/ios/Runner/Runner-Bridging-Header.h b/app/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/app/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/app/ios/RunnerTests/RunnerTests.swift b/app/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/app/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/app/lib/main.dart b/app/lib/main.dart new file mode 100644 index 0000000..f6f93ca --- /dev/null +++ b/app/lib/main.dart @@ -0,0 +1,25 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/infrastructure/infrastructure.dart'; +import 'package:deps/packages/flutter_native_splash.dart'; +import 'package:flutter/widgets.dart'; + +/// The entry point of the application. +/// +/// This function initializes necessary services before starting the app's main logic. +/// +/// The [MainBinding] class is responsible for setting up the app's core components +/// and handling the [WidgetsBinding]. The [FlutterNativeSplash.preserve] call +/// ensures that the splash screen is displayed while the asynchronous initialization completes. +void main() => MainBinding( + mainCallback: (WidgetsBinding binding) async { + // Keeps the native splash screen until Flutter is ready + FlutterNativeSplash.preserve(widgetsBinding: binding); + + // TODO: Initialize other services here if needed. + }, + ); diff --git a/app/pubspec.yaml b/app/pubspec.yaml new file mode 100644 index 0000000..b554f24 --- /dev/null +++ b/app/pubspec.yaml @@ -0,0 +1,47 @@ +name: app_name +description: The main app of the flutter monorepo bloc boilerplate. +publish_to: none +version: 0.0.1+1 + +environment: + sdk: ">=3.5.3 <4.0.0" + +dependencies: + cupertino_icons: ^1.0.8 + deps: + path: ../deps + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true + assets: + - ../design/assets/icons/ + - ../design/assets/anims/ + - ../design/assets/images/ + + fonts: + - family: Inter + fonts: + - asset: ../design/assets/fonts/Inter/Inter-Black.ttf + weight: 900 + - asset: ../design/assets/fonts/Inter/Inter-ExtraBold.ttf + weight: 800 + - asset: ../design/assets/fonts/Inter/Inter-Bold.ttf + weight: 700 + - asset: ../design/assets/fonts/Inter/Inter-SemiBold.ttf + weight: 600 + - asset: ../design/assets/fonts/Inter/Inter-Medium.ttf + weight: 500 + - asset: ../design/assets/fonts/Inter/Inter-Regular.ttf + weight: 400 + - asset: ../design/assets/fonts/Inter/Inter-Light.ttf + weight: 300 + - asset: ../design/assets/fonts/Inter/Inter-ExtraLight.ttf + weight: 200 + - asset: ../design/assets/fonts/Inter/Inter-Thin.ttf + weight: 100 \ No newline at end of file diff --git a/app/pubspec_overrides.yaml b/app/pubspec_overrides.yaml new file mode 100644 index 0000000..300314f --- /dev/null +++ b/app/pubspec_overrides.yaml @@ -0,0 +1,14 @@ +# melos_managed_dependency_overrides: deps,design,feature_auth,feature_core,feature_user,infrastructure +dependency_overrides: + deps: + path: ../deps + design: + path: ../design + feature_auth: + path: ../features/auth + feature_core: + path: ../features/_core + feature_user: + path: ../features/user + infrastructure: + path: ../infrastructure diff --git a/deps/build.yaml b/deps/build.yaml new file mode 100644 index 0000000..306d957 --- /dev/null +++ b/deps/build.yaml @@ -0,0 +1,22 @@ +# Copyright 2024 Thomas. All rights reserved. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file. +# + +# This `build.yaml` file is used to configure various code generation tools such as Auto Route, +# Freezed, Json Serializable, Injectable, and Slang for a Flutter project. + + +targets: + $default: + builders: + # Injectable Generators + # + # Injectable is used to generate dependency injection code for the app. It automatically generates + # registration code for classes annotated with `@Injectable` or `@LazySingleton`. + + # This section controls the Injectable dependency injection code generation. + injectable_generator:injectable_builder: + generate_for: + include: + - lib/locator/locator.dart \ No newline at end of file diff --git a/deps/devtools_options.yaml b/deps/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/deps/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/deps/lib/design/design.dart b/deps/lib/design/design.dart new file mode 100644 index 0000000..977094e --- /dev/null +++ b/deps/lib/design/design.dart @@ -0,0 +1,12 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// This file exports the entire `design` package, allowing it to be used in +// other parts of the project. By exporting `design.dart`, any module or +// component that imports this file will have access to all the elements +// defined in the `design` package. + +export 'package:design/design.dart'; diff --git a/deps/lib/features/features.dart b/deps/lib/features/features.dart new file mode 100644 index 0000000..792df7c --- /dev/null +++ b/deps/lib/features/features.dart @@ -0,0 +1 @@ +export 'package:feature_core/core.dart'; diff --git a/deps/lib/infrastructure/infrastructure.dart b/deps/lib/infrastructure/infrastructure.dart new file mode 100644 index 0000000..d8752a8 --- /dev/null +++ b/deps/lib/infrastructure/infrastructure.dart @@ -0,0 +1,13 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// This file exports the entire `infrastructure` package, providing access to +// various foundational components, services, utilities, and configurations used +// throughout the application. By exporting `infrastructure.dart`, other modules +// can leverage the complete infrastructure layer of the application from a +// single import statement. + +export 'package:infrastructure/infrastructure.dart'; diff --git a/deps/lib/locator/locator.dart b/deps/lib/locator/locator.dart new file mode 100644 index 0000000..8fb65b7 --- /dev/null +++ b/deps/lib/locator/locator.dart @@ -0,0 +1,27 @@ +import 'package:feature_core/_di/_di.dart'; +import 'package:infrastructure/_core/_di/_di.dart'; + +import '../packages/get_it.dart'; +import '../packages/injectable.dart'; +import 'locator.config.dart'; + +/// Global instance of [GetIt] for dependency injection. +final GetIt locator = GetIt.instance; + +/// Initializes the service locator [GetIt] with infrastructure and feature dependencies. +/// +/// This function configures [GetIt] to provide dependencies for different layers of the application. +/// It initializes dependencies for both infrastructure and features. +/// +/// [env] is a string representing the current environment (e.g., development, production). +@InjectableInit() +Future initLocator(String env) async { + // Inject infrastructure-level dependencies. + await injectInfrastructure(di: locator, env: env); + + // Inject feature-level dependencies. + injectAllFeatures(di: locator, env: env); + + // Finalize the initialization of dependencies. + locator.init(environment: env); +} diff --git a/deps/lib/packages/adaptive_theme.dart b/deps/lib/packages/adaptive_theme.dart new file mode 100644 index 0000000..c2bc072 --- /dev/null +++ b/deps/lib/packages/adaptive_theme.dart @@ -0,0 +1 @@ +export 'package:adaptive_theme/adaptive_theme.dart'; diff --git a/deps/lib/packages/auto_route.dart b/deps/lib/packages/auto_route.dart new file mode 100644 index 0000000..0ddb22f --- /dev/null +++ b/deps/lib/packages/auto_route.dart @@ -0,0 +1 @@ +export 'package:auto_route/auto_route.dart'; diff --git a/deps/lib/packages/back_button_interceptor.dart b/deps/lib/packages/back_button_interceptor.dart new file mode 100644 index 0000000..5ae0eef --- /dev/null +++ b/deps/lib/packages/back_button_interceptor.dart @@ -0,0 +1 @@ +export 'package:back_button_interceptor/back_button_interceptor.dart'; diff --git a/deps/lib/packages/dio.dart b/deps/lib/packages/dio.dart new file mode 100644 index 0000000..dcf16a0 --- /dev/null +++ b/deps/lib/packages/dio.dart @@ -0,0 +1 @@ +export 'package:dio/dio.dart'; diff --git a/deps/lib/packages/dio_smart_retry.dart b/deps/lib/packages/dio_smart_retry.dart new file mode 100644 index 0000000..9912b3b --- /dev/null +++ b/deps/lib/packages/dio_smart_retry.dart @@ -0,0 +1 @@ +export 'package:dio_smart_retry/dio_smart_retry.dart'; diff --git a/deps/lib/packages/envied.dart b/deps/lib/packages/envied.dart new file mode 100644 index 0000000..e1b8a7f --- /dev/null +++ b/deps/lib/packages/envied.dart @@ -0,0 +1 @@ +export 'package:envied/envied.dart'; diff --git a/deps/lib/packages/flutter_bloc.dart b/deps/lib/packages/flutter_bloc.dart new file mode 100644 index 0000000..f3e1ae3 --- /dev/null +++ b/deps/lib/packages/flutter_bloc.dart @@ -0,0 +1 @@ +export 'package:flutter_bloc/flutter_bloc.dart'; diff --git a/deps/lib/packages/flutter_native_splash.dart b/deps/lib/packages/flutter_native_splash.dart new file mode 100644 index 0000000..ab99e6a --- /dev/null +++ b/deps/lib/packages/flutter_native_splash.dart @@ -0,0 +1 @@ +export 'package:flutter_native_splash/flutter_native_splash.dart'; diff --git a/deps/lib/packages/flutter_secure_storage.dart b/deps/lib/packages/flutter_secure_storage.dart new file mode 100644 index 0000000..bef146c --- /dev/null +++ b/deps/lib/packages/flutter_secure_storage.dart @@ -0,0 +1 @@ +export 'package:flutter_secure_storage/flutter_secure_storage.dart'; diff --git a/deps/lib/packages/flutter_svg.dart b/deps/lib/packages/flutter_svg.dart new file mode 100644 index 0000000..f84095a --- /dev/null +++ b/deps/lib/packages/flutter_svg.dart @@ -0,0 +1 @@ +export 'package:flutter_svg/flutter_svg.dart'; diff --git a/deps/lib/packages/fpdart.dart b/deps/lib/packages/fpdart.dart new file mode 100644 index 0000000..6c8fc97 --- /dev/null +++ b/deps/lib/packages/fpdart.dart @@ -0,0 +1 @@ +export 'package:fpdart/fpdart.dart' hide State; diff --git a/deps/lib/packages/freezed_annotation.dart b/deps/lib/packages/freezed_annotation.dart new file mode 100644 index 0000000..2c2954e --- /dev/null +++ b/deps/lib/packages/freezed_annotation.dart @@ -0,0 +1 @@ +export 'package:freezed_annotation/freezed_annotation.dart'; diff --git a/deps/lib/packages/get_it.dart b/deps/lib/packages/get_it.dart new file mode 100644 index 0000000..40b088f --- /dev/null +++ b/deps/lib/packages/get_it.dart @@ -0,0 +1 @@ +export 'package:get_it/get_it.dart'; diff --git a/deps/lib/packages/hydrated_bloc.dart b/deps/lib/packages/hydrated_bloc.dart new file mode 100644 index 0000000..57825a7 --- /dev/null +++ b/deps/lib/packages/hydrated_bloc.dart @@ -0,0 +1 @@ +export 'package:hydrated_bloc/hydrated_bloc.dart'; diff --git a/deps/lib/packages/infinite_scroll_pagination.dart b/deps/lib/packages/infinite_scroll_pagination.dart new file mode 100644 index 0000000..476de9c --- /dev/null +++ b/deps/lib/packages/infinite_scroll_pagination.dart @@ -0,0 +1 @@ +export 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; diff --git a/deps/lib/packages/injectable.dart b/deps/lib/packages/injectable.dart new file mode 100644 index 0000000..a6e62bf --- /dev/null +++ b/deps/lib/packages/injectable.dart @@ -0,0 +1 @@ +export 'package:injectable/injectable.dart'; diff --git a/deps/lib/packages/internet_connection_checker_plus.dart b/deps/lib/packages/internet_connection_checker_plus.dart new file mode 100644 index 0000000..65f90f1 --- /dev/null +++ b/deps/lib/packages/internet_connection_checker_plus.dart @@ -0,0 +1 @@ +export 'package:internet_connection_checker_plus/internet_connection_checker_plus.dart'; diff --git a/deps/lib/packages/intl.dart b/deps/lib/packages/intl.dart new file mode 100644 index 0000000..dfd44ed --- /dev/null +++ b/deps/lib/packages/intl.dart @@ -0,0 +1 @@ +export 'package:intl/intl.dart'; diff --git a/deps/lib/packages/lottie.dart b/deps/lib/packages/lottie.dart new file mode 100644 index 0000000..800a186 --- /dev/null +++ b/deps/lib/packages/lottie.dart @@ -0,0 +1 @@ +export 'package:lottie/lottie.dart'; diff --git a/deps/lib/packages/package_info_plus.dart b/deps/lib/packages/package_info_plus.dart new file mode 100644 index 0000000..edf50d6 --- /dev/null +++ b/deps/lib/packages/package_info_plus.dart @@ -0,0 +1 @@ +export 'package:package_info_plus/package_info_plus.dart'; diff --git a/deps/lib/packages/path_provider.dart b/deps/lib/packages/path_provider.dart new file mode 100644 index 0000000..5eb9209 --- /dev/null +++ b/deps/lib/packages/path_provider.dart @@ -0,0 +1 @@ +export 'package:path_provider/path_provider.dart'; diff --git a/deps/lib/packages/permission_handler.dart b/deps/lib/packages/permission_handler.dart new file mode 100644 index 0000000..f0edcca --- /dev/null +++ b/deps/lib/packages/permission_handler.dart @@ -0,0 +1 @@ +export 'package:permission_handler/permission_handler.dart'; diff --git a/deps/lib/packages/reactive_forms.dart b/deps/lib/packages/reactive_forms.dart new file mode 100644 index 0000000..bb8c99a --- /dev/null +++ b/deps/lib/packages/reactive_forms.dart @@ -0,0 +1 @@ +export 'package:reactive_forms/reactive_forms.dart'; diff --git a/deps/lib/packages/styled_text.dart b/deps/lib/packages/styled_text.dart new file mode 100644 index 0000000..e8e7a1a --- /dev/null +++ b/deps/lib/packages/styled_text.dart @@ -0,0 +1 @@ +export 'package:styled_text/styled_text.dart'; diff --git a/deps/lib/packages/talker_bloc_logger.dart b/deps/lib/packages/talker_bloc_logger.dart new file mode 100644 index 0000000..5fc3076 --- /dev/null +++ b/deps/lib/packages/talker_bloc_logger.dart @@ -0,0 +1 @@ +export 'package:talker_bloc_logger/talker_bloc_logger.dart'; diff --git a/deps/lib/packages/talker_dio_logger.dart b/deps/lib/packages/talker_dio_logger.dart new file mode 100644 index 0000000..19cc134 --- /dev/null +++ b/deps/lib/packages/talker_dio_logger.dart @@ -0,0 +1 @@ +export 'package:talker_dio_logger/talker_dio_logger.dart'; diff --git a/deps/lib/packages/talker_flutter.dart b/deps/lib/packages/talker_flutter.dart new file mode 100644 index 0000000..0e1e024 --- /dev/null +++ b/deps/lib/packages/talker_flutter.dart @@ -0,0 +1 @@ +export 'package:talker_flutter/talker_flutter.dart'; diff --git a/deps/lib/packages/universal_html.dart b/deps/lib/packages/universal_html.dart new file mode 100644 index 0000000..f0a0ba4 --- /dev/null +++ b/deps/lib/packages/universal_html.dart @@ -0,0 +1 @@ +export 'package:universal_html/html.dart'; diff --git a/deps/pubspec.yaml b/deps/pubspec.yaml new file mode 100644 index 0000000..4fad540 --- /dev/null +++ b/deps/pubspec.yaml @@ -0,0 +1,58 @@ +name: deps +description: The dependencies of the flutter advanced boilerplate. +publish_to: none +version: 0.0.1 + +environment: + sdk: ">=3.5.3 <4.0.0" + +dependencies: + adaptive_theme: ^3.6.0 + auto_route: ^9.2.2 + back_button_interceptor: ^7.0.3 + design: + path: ../design + dio: ^5.7.0 + dio_smart_retry: ^6.0.0 + envied: ^0.5.4+1 + feature_core: + path: ../features/_core + flutter: + sdk: flutter + flutter_bloc: ^8.1.6 + flutter_native_splash: ^2.4.1 + flutter_secure_storage: ^9.2.2 + flutter_svg: ^2.0.10+1 + fpdart: ^1.1.0 + freezed_annotation: ^2.4.4 + get_it: ^7.7.0 + hydrated_bloc: ^9.1.5 + infinite_scroll_pagination: ^4.0.0 + infrastructure: + path: ../infrastructure + injectable: ^2.4.4 + internet_connection_checker_plus: ^2.5.1 + intl: ^0.19.0 + lottie: ^3.1.2 + package_info_plus: ^8.0.2 + path_provider: ^2.1.4 + permission_handler: ^11.3.1 + reactive_forms: ^17.0.1 + slang: ^3.31.2 + slang_flutter: ^3.31.0 + styled_text: ^8.1.0 + talker_bloc_logger: ^4.4.1 + talker_dio_logger: ^4.4.1 + talker_flutter: ^4.4.1 + universal_html: ^2.2.4 + +dependency_overrides: + flutter_secure_storage_web: + git: + url: https://github.com/fikretsengul/flutter_secure_storage.git + path: flutter_secure_storage_web + ref: develop + +dev_dependencies: + build_runner: ^2.4.13 + injectable_generator: ^2.6.2 diff --git a/deps/pubspec_overrides.yaml b/deps/pubspec_overrides.yaml new file mode 100644 index 0000000..7ea0bc1 --- /dev/null +++ b/deps/pubspec_overrides.yaml @@ -0,0 +1,17 @@ +# melos_managed_dependency_overrides: design,feature_auth,feature_core,feature_user,infrastructure,flutter_secure_storage_web +dependency_overrides: + design: + path: ../design + feature_auth: + path: ../features/auth + feature_core: + path: ../features/_core + feature_user: + path: ../features/user + infrastructure: + path: ../infrastructure + flutter_secure_storage_web: + git: + url: https://github.com/fikretsengul/flutter_secure_storage.git + ref: develop + path: flutter_secure_storage_web diff --git a/design/assets/fonts/Inter/Inter-Black.ttf b/design/assets/fonts/Inter/Inter-Black.ttf new file mode 100644 index 0000000..5653757 Binary files /dev/null and b/design/assets/fonts/Inter/Inter-Black.ttf differ diff --git a/design/assets/fonts/Inter/Inter-Bold.ttf b/design/assets/fonts/Inter/Inter-Bold.ttf new file mode 100644 index 0000000..e98b84c Binary files /dev/null and b/design/assets/fonts/Inter/Inter-Bold.ttf differ diff --git a/design/assets/fonts/Inter/Inter-ExtraBold.ttf b/design/assets/fonts/Inter/Inter-ExtraBold.ttf new file mode 100644 index 0000000..7f16a0f Binary files /dev/null and b/design/assets/fonts/Inter/Inter-ExtraBold.ttf differ diff --git a/design/assets/fonts/Inter/Inter-ExtraLight.ttf b/design/assets/fonts/Inter/Inter-ExtraLight.ttf new file mode 100644 index 0000000..69426a3 Binary files /dev/null and b/design/assets/fonts/Inter/Inter-ExtraLight.ttf differ diff --git a/design/assets/fonts/Inter/Inter-Light.ttf b/design/assets/fonts/Inter/Inter-Light.ttf new file mode 100644 index 0000000..a5f0736 Binary files /dev/null and b/design/assets/fonts/Inter/Inter-Light.ttf differ diff --git a/design/assets/fonts/Inter/Inter-Medium.ttf b/design/assets/fonts/Inter/Inter-Medium.ttf new file mode 100644 index 0000000..721147d Binary files /dev/null and b/design/assets/fonts/Inter/Inter-Medium.ttf differ diff --git a/design/assets/fonts/Inter/Inter-Regular.ttf b/design/assets/fonts/Inter/Inter-Regular.ttf new file mode 100644 index 0000000..96fd6a1 Binary files /dev/null and b/design/assets/fonts/Inter/Inter-Regular.ttf differ diff --git a/design/assets/fonts/Inter/Inter-SemiBold.ttf b/design/assets/fonts/Inter/Inter-SemiBold.ttf new file mode 100644 index 0000000..ddb2792 Binary files /dev/null and b/design/assets/fonts/Inter/Inter-SemiBold.ttf differ diff --git a/design/assets/fonts/Inter/Inter-Thin.ttf b/design/assets/fonts/Inter/Inter-Thin.ttf new file mode 100644 index 0000000..76be625 Binary files /dev/null and b/design/assets/fonts/Inter/Inter-Thin.ttf differ diff --git a/design/build.yaml b/design/build.yaml new file mode 100644 index 0000000..59d069d --- /dev/null +++ b/design/build.yaml @@ -0,0 +1,30 @@ +# Copyright 2024 Thomas. All rights reserved. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file. +# + +# This `build.yaml` file is used to configure various code generation tools such as Auto Route, +# Freezed, Json Serializable, Injectable, and Slang for a Flutter project. + + +targets: + $default: + builders: + # Slang Generators + # + # Slang is used for internationalization (i18n) by generating translation classes. This configuration + # controls how translations are handled and generated. + + # This section configures the Slang package for generating i18n (internationalization) translation classes. + slang_build_runner: + options: + locale_handling: false + translation_class_visibility: public + fallback_strategy: base_locale + input_directory: lib/_core/_i18n + output_directory: lib/_core/_i18n + output_file_name: translations.g.dart + key_case: camel + key_map_case: camel + param_case: camel + flat_map: false \ No newline at end of file diff --git a/design/devtools_options.yaml b/design/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/design/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/design/lib/_core/_constants/assets.gen.dart b/design/lib/_core/_constants/assets.gen.dart new file mode 100644 index 0000000..ff1339f --- /dev/null +++ b/design/lib/_core/_constants/assets.gen.dart @@ -0,0 +1,14 @@ +/// GENERATED CODE - DO NOT MODIFY BY HAND +/// ***************************************************** +/// FlutterGen +/// ***************************************************** + +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: directives_ordering,unnecessary_import,implicit_dynamic_list_literal,deprecated_member_use + +class Assets { + Assets._(); + + static const String package = 'design'; +} diff --git a/design/lib/_core/_constants/colours.dart b/design/lib/_core/_constants/colours.dart new file mode 100644 index 0000000..4304ba3 --- /dev/null +++ b/design/lib/_core/_constants/colours.dart @@ -0,0 +1,75 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: prefer-correct-callback-field-name + +import 'package:flutter/material.dart'; + +/// The `Colours` class provides a centralized collection of static color constants +/// and getters that define the primary and accent color palette used across the +/// application. These colors are consistent and reusable to ensure uniform design +/// language across the app's UI components. +final class Colours { + /// Pure white color, used typically for backgrounds and text. + static const Color kWhite = Color(0xFFFFFFFF); + + /// Semi-grey color, a light grey used for subtle UI elements like borders. + static const Color kSemiGrey = Color(0xFFE2E4E5); + + /// Half-grey color, a medium grey used for text or inactive UI elements. + static const Color kHalfGrey = Color(0xFF929292); + + /// Full grey color, a darker grey suitable for primary text or more prominent elements. + static const Color kGrey = Color(0xFF494949); + + /// Light blue color, used for softer accent elements in the UI. + static const Color kSemiBlue = Color(0xFFDBEEF9); + + /// Bright blue color, often used for primary buttons and interactive elements. + static const Color kBlue = Color(0xFF69C9FF); + + /// Light red color, used for background elements indicating warnings or errors. + static const Color kSemiRed = Color(0xFFEFD5D4); + + /// Bright red color, commonly used for errors or alert messages. + static const Color kRed = Color(0xFFED7870); + + /// Bright green color, suitable for success messages or confirmation buttons. + static const Color kGreen = Color(0xFF40DBA3); + + /// Light green color, used for softer success indicators. + static const Color kSemiGreen = Color(0xFFC6EFE1); + + /// Getter for white color. + Color get white => kWhite; + + /// Getter for semi-grey color. + Color get semiGrey => kSemiGrey; + + /// Getter for half-grey color. + Color get halfGrey => kHalfGrey; + + /// Getter for grey color. + Color get grey => kGrey; + + /// Getter for semi-blue color. + Color get semiBlue => kSemiBlue; + + /// Getter for blue color. + Color get blue => kBlue; + + /// Getter for semi-red color. + Color get semiRed => kSemiRed; + + /// Getter for red color. + Color get red => kRed; + + /// Getter for green color. + Color get green => kGreen; + + /// Getter for semi-green color. + Color get semiGreen => kSemiGreen; +} diff --git a/design/lib/_core/_constants/defaults.dart b/design/lib/_core/_constants/defaults.dart new file mode 100644 index 0000000..bf8934e --- /dev/null +++ b/design/lib/_core/_constants/defaults.dart @@ -0,0 +1,27 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter/widgets.dart'; + +/// The `Defaults` class provides a collection of default styling properties +/// that can be used throughout the application to ensure consistency in design. +/// +/// This class primarily includes a default box shadow configuration which can +/// be applied to various UI elements such as containers, cards, and buttons. +final class Defaults { + /// Default box shadow styling for elevated UI components. + /// + /// - **color**: The shadow color is set to a dark gray. + /// - **offset**: The shadow is offset slightly downwards (0, 1). + /// - **blurRadius**: The blur radius is set to 1, giving it a subtle shadow effect. + final List boxShadow = [ + const BoxShadow( + color: Color.fromARGB(255, 40, 40, 41), + offset: Offset(0, 1), + blurRadius: 1, + ), + ]; +} diff --git a/design/lib/_core/_constants/fonts.dart b/design/lib/_core/_constants/fonts.dart new file mode 100644 index 0000000..f732ab6 --- /dev/null +++ b/design/lib/_core/_constants/fonts.dart @@ -0,0 +1,125 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: prefer-correct-identifier-length + +import 'package:flutter/material.dart'; + +/// The `Fonts` class provides predefined text styles that can be reused +/// throughout the application to maintain consistency in typography. +/// +/// Each text style is configured with the following attributes: +/// - **color**: Text color, defaulting to a shade of gray. +/// - **fontSize**: Font size in logical pixels. +/// - **fontWeight**: The thickness of the font, ranging from normal to bold. +/// - **fontStyle**: Regular or italicized text style. +/// - **fontFamily**: Specifies the `Inter` font family for all text. + +final class Fonts { + /// Heading 1 (h1) text style. + /// + /// - Font Size: 24px + /// - Font Weight: Normal + /// - Color: Dark Gray (#494949) + final TextStyle h1 = const TextStyle( + color: Color(0xFF494949), + fontSize: 24, + fontWeight: FontWeight.normal, + fontStyle: FontStyle.normal, + fontFamily: 'Inter', + ); + + /// Heading 2 (h2) text style. + /// + /// - Font Size: 18px + /// - Font Weight: Normal + /// - Color: Dark Gray (#494949) + final TextStyle h2 = const TextStyle( + color: Color(0xFF494949), + fontSize: 18, + fontWeight: FontWeight.normal, + fontStyle: FontStyle.normal, + fontFamily: 'Inter', + ); + + /// Subtitle 1 (sub1) text style. + /// + /// - Font Size: 15px + /// - Font Weight: Normal + /// - Color: Dark Gray (#494949) + final TextStyle sub1 = const TextStyle( + color: Color(0xFF494949), + fontSize: 15, + fontWeight: FontWeight.normal, + fontStyle: FontStyle.normal, + fontFamily: 'Inter', + ); + + /// Subtitle 2 (sub2) text style. + /// + /// - Font Size: 14px + /// - Font Weight: Normal + /// - Color: Dark Gray (#494949) + final TextStyle sub2 = const TextStyle( + color: Color(0xFF494949), + fontSize: 14, + fontWeight: FontWeight.normal, + fontStyle: FontStyle.normal, + fontFamily: 'Inter', + ); + + /// Body Text 1 (body1) text style. + /// + /// - Font Size: 13px + /// - Font Weight: Normal + /// - Color: Dark Gray (#494949) + final TextStyle body1 = const TextStyle( + color: Color(0xFF494949), + fontSize: 13, + fontWeight: FontWeight.normal, + fontStyle: FontStyle.normal, + fontFamily: 'Inter', + ); + + /// Body Text 2 (body2) text style. + /// + /// - Font Size: 11px + /// - Font Weight: Normal + /// - Color: Dark Gray (#494949) + final TextStyle body2 = const TextStyle( + color: Color(0xFF494949), + fontSize: 11, + fontWeight: FontWeight.normal, + fontStyle: FontStyle.normal, + fontFamily: 'Inter', + ); + + /// Button text style. + /// + /// - Font Size: 11px + /// - Font Weight: Normal + /// - Color: Dark Gray (#494949) + final TextStyle button = const TextStyle( + color: Color(0xFF494949), + fontSize: 11, + fontWeight: FontWeight.normal, + fontStyle: FontStyle.normal, + fontFamily: 'Inter', + ); + + /// Caption text style. + /// + /// - Font Size: 9px + /// - Font Weight: Normal + /// - Color: Dark Gray (#494949) + final TextStyle caption = const TextStyle( + color: Color(0xFF494949), + fontSize: 9, + fontWeight: FontWeight.normal, + fontStyle: FontStyle.normal, + fontFamily: 'Inter', + ); +} diff --git a/design/lib/_core/_constants/fonts.gen.dart b/design/lib/_core/_constants/fonts.gen.dart new file mode 100644 index 0000000..84dd275 --- /dev/null +++ b/design/lib/_core/_constants/fonts.gen.dart @@ -0,0 +1,15 @@ +/// GENERATED CODE - DO NOT MODIFY BY HAND +/// ***************************************************** +/// FlutterGen +/// ***************************************************** + +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: directives_ordering,unnecessary_import,implicit_dynamic_list_literal,deprecated_member_use + +class FontFamily { + FontFamily._(); + + /// Font family: Inter + static const String inter = 'Inter'; +} diff --git a/design/lib/_core/_constants/gradients.dart b/design/lib/_core/_constants/gradients.dart new file mode 100644 index 0000000..9b6323b --- /dev/null +++ b/design/lib/_core/_constants/gradients.dart @@ -0,0 +1,97 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: prefer-correct-callback-field-name + +import 'package:flutter/material.dart'; + +/// The `Gradients` class provides a collection of predefined linear gradients +/// for consistent use of gradient styles across the application. +/// +/// Each gradient has two colors with a top-to-bottom alignment (vertical gradient). +/// The available gradients include various shades of grey, red, green, and blue. + +final class Gradients { + /// Semi-transparent grey gradient from light grey to slightly darker grey. + static const LinearGradient kSemiGrey = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFE2E4E5), Color(0xFFECEEEE)], + ); + + /// Dark grey gradient from a darker grey to a slightly lighter shade. + static const LinearGradient kGrey = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF494949), Color(0xFF5D5D5D)], + ); + + /// Semi-transparent red gradient transitioning from light red to a soft peach. + static const LinearGradient kSemiRed = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFFBDDDD), Color(0xFFFAE0DA)], + ); + + /// Bright red gradient transitioning from a bold red to a lighter red. + static const LinearGradient kRed = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFED6E76), Color(0xFFEC816A)], + ); + + /// Semi-transparent green gradient transitioning from a light green to a soft teal. + static const LinearGradient kSemiGreen = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFCFF6E8), Color(0xFFD4F7EB)], + ); + + /// Bright green gradient transitioning from a bold green to a lighter green. + static const LinearGradient kGreen = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF40DBA3), Color(0xFF54DFAD)], + ); + + /// Semi-transparent blue gradient transitioning from light blue to a soft sky blue. + static const LinearGradient kSemiBlue = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFDAF2FF), Color(0xFFDDF3FF)], + ); + + /// Bright blue gradient transitioning from a bold blue to a lighter blue. + static const LinearGradient kBlue = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF69C9FF), Color(0xFF78CFFF)], + ); + + /// Getter for the semi-transparent grey gradient. + LinearGradient get semiGrey => Gradients.kSemiGrey; + + /// Getter for the dark grey gradient. + LinearGradient get grey => Gradients.kGrey; + + /// Getter for the semi-transparent red gradient. + LinearGradient get semiRed => Gradients.kSemiRed; + + /// Getter for the bright red gradient. + LinearGradient get red => Gradients.kRed; + + /// Getter for the semi-transparent green gradient. + LinearGradient get semiGreen => Gradients.kSemiGreen; + + /// Getter for the bright green gradient. + LinearGradient get green => Gradients.kGreen; + + /// Getter for the semi-transparent blue gradient. + LinearGradient get semiBlue => Gradients.kSemiBlue; + + /// Getter for the bright blue gradient. + LinearGradient get blue => Gradients.kBlue; +} diff --git a/design/lib/_core/_constants/theme.gen.dart b/design/lib/_core/_constants/theme.gen.dart new file mode 100644 index 0000000..3a2fde3 --- /dev/null +++ b/design/lib/_core/_constants/theme.gen.dart @@ -0,0 +1,84 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: unused_import, avoid-global-state + +import 'package:design/_core/_constants/assets.gen.dart'; +import 'package:design/_core/_constants/colours.dart'; +import 'package:design/_core/_constants/defaults.dart'; +import 'package:design/_core/_constants/fonts.dart'; +import 'package:design/_core/_constants/gradients.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' hide TextTheme; +import 'package:theme_tailor_annotation/theme_tailor_annotation.dart'; + +part 'theme.gen.tailor.dart'; + +/// The `ThemeGen` class is a customizable and extendable theme class that defines +/// various theme-related properties for the application. It leverages Flutter's +/// ThemeExtension to allow for theme configuration, custom properties, and diagnostic tools. +/// +/// It uses the TailorMixin, provided by the `theme_tailor_annotation` package, +/// to automatically generate custom-tailored themes. The class contains properties +/// like colors, fonts, shadows, and gradients, all designed for easy reuse and +/// customization across the application. +@TailorMixin(themeGetter: ThemeGetter.onThemeData) +class ThemeGen extends ThemeExtension + with DiagnosticableTreeMixin, _$ThemeGenTailorMixin { + /// The constructor for the `ThemeGen` class. It requires all theme properties to be passed. + const ThemeGen({ + required this.background, + required this.surface, + required this.onSurface, + required this.border, + required this.shadow, + required this.placeholder, + required this.gradients, + required this.colors, + required this.fonts, + required this.defaults, + }); + + /// The background color used across the application. + @override + final Color background; + + /// The color of the surface elements, such as cards or containers. + @override + final Color surface; + + /// The color for text and icons on surface elements. + @override + final Color onSurface; + + /// The color used for borders. + @override + final Color border; + + /// The color used for shadows across the application. + @override + final Color shadow; + + /// The color used for placeholders in inputs and other elements. + @override + final Color placeholder; + + /// A collection of predefined linear gradients used throughout the app. + @override + final Gradients gradients; + + /// A collection of colors defined in the `Colours` class. + @override + final Colours colors; + + /// A collection of text styles defined in the `Fonts` class. + @override + final Fonts fonts; + + /// Default configuration for shadows and other UI elements. + @override + final Defaults defaults; +} diff --git a/design/lib/_core/_constants/theme.gen.tailor.dart b/design/lib/_core/_constants/theme.gen.tailor.dart new file mode 100644 index 0000000..8134ab0 --- /dev/null +++ b/design/lib/_core/_constants/theme.gen.tailor.dart @@ -0,0 +1,124 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element, unnecessary_cast + +part of 'theme.gen.dart'; + +// ************************************************************************** +// TailorAnnotationsGenerator +// ************************************************************************** + +mixin _$ThemeGenTailorMixin + on ThemeExtension, DiagnosticableTreeMixin { + Color get background; + Color get surface; + Color get onSurface; + Color get border; + Color get shadow; + Color get placeholder; + Gradients get gradients; + Colours get colors; + Fonts get fonts; + Defaults get defaults; + + @override + ThemeGen copyWith({ + Color? background, + Color? surface, + Color? onSurface, + Color? border, + Color? shadow, + Color? placeholder, + Gradients? gradients, + Colours? colors, + Fonts? fonts, + Defaults? defaults, + }) { + return ThemeGen( + background: background ?? this.background, + surface: surface ?? this.surface, + onSurface: onSurface ?? this.onSurface, + border: border ?? this.border, + shadow: shadow ?? this.shadow, + placeholder: placeholder ?? this.placeholder, + gradients: gradients ?? this.gradients, + colors: colors ?? this.colors, + fonts: fonts ?? this.fonts, + defaults: defaults ?? this.defaults, + ); + } + + @override + ThemeGen lerp(covariant ThemeExtension? other, double t) { + if (other is! ThemeGen) return this as ThemeGen; + return ThemeGen( + background: Color.lerp(background, other.background, t)!, + surface: Color.lerp(surface, other.surface, t)!, + onSurface: Color.lerp(onSurface, other.onSurface, t)!, + border: Color.lerp(border, other.border, t)!, + shadow: Color.lerp(shadow, other.shadow, t)!, + placeholder: Color.lerp(placeholder, other.placeholder, t)!, + gradients: t < 0.5 ? gradients : other.gradients, + colors: t < 0.5 ? colors : other.colors, + fonts: t < 0.5 ? fonts : other.fonts, + defaults: t < 0.5 ? defaults : other.defaults, + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is ThemeGen && + const DeepCollectionEquality() + .equals(background, other.background) && + const DeepCollectionEquality().equals(surface, other.surface) && + const DeepCollectionEquality().equals(onSurface, other.onSurface) && + const DeepCollectionEquality().equals(border, other.border) && + const DeepCollectionEquality().equals(shadow, other.shadow) && + const DeepCollectionEquality() + .equals(placeholder, other.placeholder) && + const DeepCollectionEquality().equals(gradients, other.gradients) && + const DeepCollectionEquality().equals(colors, other.colors) && + const DeepCollectionEquality().equals(fonts, other.fonts) && + const DeepCollectionEquality().equals(defaults, other.defaults)); + } + + @override + int get hashCode { + return Object.hash( + runtimeType.hashCode, + const DeepCollectionEquality().hash(background), + const DeepCollectionEquality().hash(surface), + const DeepCollectionEquality().hash(onSurface), + const DeepCollectionEquality().hash(border), + const DeepCollectionEquality().hash(shadow), + const DeepCollectionEquality().hash(placeholder), + const DeepCollectionEquality().hash(gradients), + const DeepCollectionEquality().hash(colors), + const DeepCollectionEquality().hash(fonts), + const DeepCollectionEquality().hash(defaults), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('type', 'ThemeGen')) + ..add(DiagnosticsProperty('background', background)) + ..add(DiagnosticsProperty('surface', surface)) + ..add(DiagnosticsProperty('onSurface', onSurface)) + ..add(DiagnosticsProperty('border', border)) + ..add(DiagnosticsProperty('shadow', shadow)) + ..add(DiagnosticsProperty('placeholder', placeholder)) + ..add(DiagnosticsProperty('gradients', gradients)) + ..add(DiagnosticsProperty('colors', colors)) + ..add(DiagnosticsProperty('fonts', fonts)) + ..add(DiagnosticsProperty('defaults', defaults)); + } +} + +extension ThemeGenThemeData on ThemeData { + ThemeGen get themeGen => extension()!; +} diff --git a/design/lib/_core/_constants/themes.dart b/design/lib/_core/_constants/themes.dart new file mode 100644 index 0000000..261912a --- /dev/null +++ b/design/lib/_core/_constants/themes.dart @@ -0,0 +1,69 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: no-equal-arguments, avoid-non-null-assertion, arguments-ordering + +import 'package:design/_core/_constants/colours.dart'; +import 'package:design/_core/_constants/defaults.dart'; +import 'package:design/_core/_constants/fonts.dart'; +import 'package:design/_core/_constants/gradients.dart'; +import 'package:design/_core/_constants/theme.gen.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +/// The `Themes` class defines the theme configuration for the application. +/// It provides a static theme, which is customizable, and uses various +/// design constants such as colors, fonts, gradients, and other UI elements. +/// +/// This class abstracts away theme configuration, making it easy to apply +/// consistent styles across the application by just referencing the predefined theme. +abstract final class Themes { + /// The `light` theme configuration provides a light color scheme for the application. + /// It uses the Material and Cupertino design languages, allowing the application + /// to have a uniform appearance across platforms. + /// + /// Key properties include: + /// - **cupertinoOverrideTheme**: Defines theme settings specific to Cupertino (iOS) widgets. + /// - **extensions**: Uses `ThemeGen` to extend and include custom theme properties such as gradients, fonts, and colors. + /// - **textSelectionTheme**: Configures the appearance of text selection, including cursor and selection colors. + /// - **brightness**: Defines the overall brightness of the theme (light). + /// - **scaffoldBackgroundColor**: Sets the background color for the scaffold (main application background). + static final ThemeData light = ThemeData( + cupertinoOverrideTheme: const CupertinoThemeData( + brightness: Brightness.light, + primaryColor: Color(0xFF69C9FF), + barBackgroundColor: Color(0xFFF3F5F6), + primaryContrastingColor: Color(0xFFF3F5F6), + scaffoldBackgroundColor: Color(0xFFF3F5F6), + ), + + // Extend the theme with custom properties defined in ThemeGen. + extensions: >[ + ThemeGen( + background: const Color(0xFFF3F5F6), + surface: const Color(0xFFFFFFFF), + onSurface: const Color(0xFFF3F5F6), + border: const Color(0xFFECECEC), + shadow: const Color(0xFFDBDBDB), + placeholder: const Color(0xFFB6B6B6), + gradients: Gradients(), + colors: Colours(), + fonts: Fonts(), + defaults: Defaults(), + ), + ], + + // Define the text selection theme including cursor and selection colors. + textSelectionTheme: const TextSelectionThemeData( + cursorColor: Color(0xFF69C9FF), + selectionColor: Color(0xFF69C9FF), + selectionHandleColor: Color(0xFF69C9FF), + ), + + brightness: Brightness.light, + scaffoldBackgroundColor: const Color(0xFFF3F5F6), + ); +} diff --git a/design/lib/_core/_i18n/design_i18n_cubit_locale.ext.dart b/design/lib/_core/_i18n/design_i18n_cubit_locale.ext.dart new file mode 100644 index 0000000..053be7f --- /dev/null +++ b/design/lib/_core/_i18n/design_i18n_cubit_locale.ext.dart @@ -0,0 +1,28 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/flutter_bloc.dart'; +import 'package:design/_core/_i18n/translations.g.dart'; +import 'package:flutter/material.dart'; + +/// Extension on `Cubit` to easily access the design's i18n (internationalization) functionality. +/// +/// This extension provides a convenient way to retrieve the appropriate `Translations` object for the current locale +/// managed by a `Cubit`. It uses `AppLocaleUtils.parse` to map the `Locale` to a specific `AppLocale` and +/// then builds the `Translations` object, which contains localized strings for the app. +/// +/// Example usage: +/// ```dart +/// final translations = $.tr.design; +/// ``` +extension DesignI18nCubitLocaleExt on Cubit { + /// Retrieves the `Translations` object for the current locale. + /// + /// This method maps the `Locale` stored in the cubit's state to an `AppLocale` using `AppLocaleUtils.parse`. + /// It then builds and returns the corresponding `Translations` object, which provides localized strings + /// for use in the application. + Translations get design => AppLocaleUtils.parse(state.toString()).build(); +} diff --git a/design/lib/_core/_i18n/strings_en.i18n.json b/design/lib/_core/_i18n/strings_en.i18n.json new file mode 100644 index 0000000..0bb6739 --- /dev/null +++ b/design/lib/_core/_i18n/strings_en.i18n.json @@ -0,0 +1,5 @@ +{ + "atoms": {}, + "widgets": {}, + "core": {} +} \ No newline at end of file diff --git a/design/lib/_core/_i18n/strings_tr.i18n.json b/design/lib/_core/_i18n/strings_tr.i18n.json new file mode 100644 index 0000000..0bb6739 --- /dev/null +++ b/design/lib/_core/_i18n/strings_tr.i18n.json @@ -0,0 +1,5 @@ +{ + "atoms": {}, + "widgets": {}, + "core": {} +} \ No newline at end of file diff --git a/design/lib/_core/extensions/dialog_context.ext.dart b/design/lib/_core/extensions/dialog_context.ext.dart new file mode 100644 index 0000000..65231d7 --- /dev/null +++ b/design/lib/_core/extensions/dialog_context.ext.dart @@ -0,0 +1,51 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/infrastructure/infrastructure.dart'; +import 'package:flutter/material.dart'; + +/// Extension on `DialogContext` to provide additional convenience methods for displaying dialogs, modals, and sheets. +/// +/// This extension provides simplified methods to push dialogs, modals, and bottom sheets within a Flutter application, +/// with customizable dismissibility options. +extension DialogContextExt on DialogContext { + /// Shows a custom dialog in the current context. + /// + /// - `dialog`: The widget to be shown as the dialog. + /// - `isDismissible`: Specifies whether the dialog can be dismissed by tapping outside of it. Default is `false`. + /// + /// Returns a `Future` that completes when the dialog is dismissed. + Future showDialog(Widget dialog, {bool isDismissible = false}) async { + return $.dialog.pushDialog( + builder: (_) => dialog, + config: DialogConfig(isBarrierDismissible: isDismissible), + ); + } + + /// Shows a modal dialog in the current context. + /// + /// - `modal`: The widget to be shown as the modal. + /// + /// Returns a `Future` that completes when the modal is dismissed. + Future showModal(Widget modal) async { + return $.dialog.pushModal( + builder: (_) => modal, + ); + } + + /// Displays a bottom sheet in the current context. + /// + /// - `sheet`: The widget to be shown as the bottom sheet. + /// - `isDismissible`: Specifies whether the sheet can be dismissed by dragging it down. Default is `false`. + /// + /// Does not return a value as sheets generally remain open until explicitly dismissed. + void showSheet(Widget sheet, {bool isDismissible = false}) { + $.dialog.pushSheet( + builder: (_) => sheet, + config: SheetConfig(shouldEnableDrag: isDismissible), + ); + } +} diff --git a/design/lib/_core/extensions/overlay_context.ext.dart b/design/lib/_core/extensions/overlay_context.ext.dart new file mode 100644 index 0000000..359bbd6 --- /dev/null +++ b/design/lib/_core/extensions/overlay_context.ext.dart @@ -0,0 +1,37 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/infrastructure/infrastructure.dart'; +import 'package:flutter/material.dart'; + +/// Extension on `OverlayContext` to provide an easy method for displaying a loading overlay. +/// +/// This extension allows you to show a loading spinner with a semi-transparent background, preventing user interaction +/// with the underlying UI while loading is in progress. +extension OverlayContextExt on OverlayContext { + /// Displays a loading overlay that blocks user interaction. + /// + /// The overlay consists of: + /// - A `ModalBarrier` that covers the entire screen with a semi-transparent background, preventing user interaction. + /// - `CircularProgressIndicator` widget that shows a loading spinner. + /// + /// The `popOverlay` method is used to dismiss the overlay once the loading is complete. + Future showLoading() async { + await pushOverlay( + builder: (_) => Stack( + children: [ + ModalBarrier( + color: Colors.black26, + dismissible: false, + onDismiss: popOverlay, + ), + const Center( + child: CircularProgressIndicator.adaptive(strokeWidth: 10)), + ], + ), + ); + } +} diff --git a/design/lib/_core/extensions/theme_text_style.ext.dart b/design/lib/_core/extensions/theme_text_style.ext.dart new file mode 100644 index 0000000..6f68229 --- /dev/null +++ b/design/lib/_core/extensions/theme_text_style.ext.dart @@ -0,0 +1,22 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/infrastructure/infrastructure.dart'; +import 'package:flutter/material.dart'; + +/// Extension on `TextStyle` to simplify applying predefined theme colors to text styles. +/// +/// This extension allows easy customization of text color using predefined theme colors, making it easier to manage +/// consistent color usage across the app. +extension ThemeTextStyleExt on TextStyle { + /// Applies the white color from the theme to the text style. + /// + /// Example: + /// ```dart + /// TextStyle().white; // Returns a TextStyle with the color set to white. + /// ``` + TextStyle get white => copyWith(color: $.theme.colors.white); +} diff --git a/design/lib/_core/extensions/toast_context.ext.dart b/design/lib/_core/extensions/toast_context.ext.dart new file mode 100644 index 0000000..174fb97 --- /dev/null +++ b/design/lib/_core/extensions/toast_context.ext.dart @@ -0,0 +1,65 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/infrastructure/infrastructure.dart'; +import 'package:flutter/cupertino.dart'; + +/// Extension on `ToastContext` for displaying alerts based on `Failure` objects. +/// +/// This extension provides an asynchronous method for showing alerts depending on the type of failure, +/// either constructive (e.g., success messages) or error-based alerts. It leverages toast notifications to provide +/// feedback to the user based on the failure type. +extension ToastContextExt on ToastContext { + /// Asynchronously displays an alert based on the `Failure` type using the toast system. + /// + /// This method first removes any existing overlays, then checks the `Failure` type. If the failure + /// type is [FailureTypeEnum.empty], no alert will be shown. Otherwise, it displays a toast + /// with a colored [Container] and the failure message. + /// + /// Example usage: + /// ```dart + /// final Failure failure = Failure(type: FailureTypeEnum.error, message: 'An error occurred'); + /// await $.toast.showAlert(failure); + /// ``` + /// + /// - [failure]: The failure object containing the failure type and message. + Future showAlert( + Failure failure, { + bool shouldAutoDismissModal = false, + }) async { + // Remove any existing overlay before showing a new alert. + await $.overlay.popOverlay(); + + // Remove any existing modal before showing a new alert. + if (shouldAutoDismissModal) { + await $.dialog.popDialog(); + } + + // If the failure type is empty, do nothing. + if (failure.type == FailureTypeEnum.empty) { + return; + } + + // Create a widget based on the failure type. Success is green, and errors are red. + final Widget child = switch (failure.type) { + // Constructive (e.g., success) alerts show a green background. + FailureTypeEnum.constructive => Container( + height: 50, + color: const Color(0xFF40DBA3), + child: failure.message.text(), + ), + // Error alerts show a red background. + _ => Container( + height: 50, + color: const Color(0xFFE4756D), + child: failure.message.text(), + ), + }; + + // Display the alert as a toast with the generated widget. + await $.toast.pushWidgetToast(child: child); + } +} diff --git a/design/lib/design.dart b/design/lib/design.dart new file mode 100644 index 0000000..e7b38ad --- /dev/null +++ b/design/lib/design.dart @@ -0,0 +1,8 @@ +export '_core/_constants/assets.gen.dart'; +export '_core/_constants/theme.gen.dart'; +export '_core/_constants/themes.dart'; +export '_core/_i18n/design_i18n_cubit_locale.ext.dart'; +export '_core/extensions/dialog_context.ext.dart'; +export '_core/extensions/overlay_context.ext.dart'; +export '_core/extensions/theme_text_style.ext.dart'; +export '_core/extensions/toast_context.ext.dart'; diff --git a/design/pubspec.yaml b/design/pubspec.yaml new file mode 100644 index 0000000..556121b --- /dev/null +++ b/design/pubspec.yaml @@ -0,0 +1,63 @@ +name: design +description: The design system of the flutter advanced boilerplate. +publish_to: none +version: 0.0.1 + +environment: + sdk: ">=3.5.3 <4.0.0" + +dependencies: + deps: + path: ../deps + flutter: + sdk: flutter + flutter_svg: ^2.0.10+1 + theme_tailor_annotation: ^3.0.1 + +dev_dependencies: + build_runner: ^2.4.13 + dart_code_metrics_presets: ^2.16.0 + flutter_gen_runner: ^5.7.0 + json_serializable: ^6.8.0 + slang_build_runner: ^3.31.0 + theme_tailor: ^3.0.1 + +flutter: + uses-material-design: true + assets: + - assets/icons/ + - assets/anims/ + - assets/images/ + + fonts: + - family: Inter + fonts: + - asset: assets/fonts/Inter/Inter-Black.ttf + weight: 900 + - asset: assets/fonts/Inter/Inter-ExtraBold.ttf + weight: 800 + - asset: assets/fonts/Inter/Inter-Bold.ttf + weight: 700 + - asset: assets/fonts/Inter/Inter-SemiBold.ttf + weight: 600 + - asset: assets/fonts/Inter/Inter-Medium.ttf + weight: 500 + - asset: assets/fonts/Inter/Inter-Regular.ttf + weight: 400 + - asset: assets/fonts/Inter/Inter-Light.ttf + weight: 300 + - asset: assets/fonts/Inter/Inter-ExtraLight.ttf + weight: 200 + - asset: assets/fonts/Inter/Inter-Thin.ttf + weight: 100 + + +flutter_gen: + output: lib/_core/_constants/ + line_length: 120 + integrations: + flutter_svg: true + lottie: true + assets: + outputs: + package_parameter_enabled: true \ No newline at end of file diff --git a/design/pubspec_overrides.yaml b/design/pubspec_overrides.yaml new file mode 100644 index 0000000..c884732 --- /dev/null +++ b/design/pubspec_overrides.yaml @@ -0,0 +1,12 @@ +# melos_managed_dependency_overrides: deps,feature_auth,feature_core,feature_user,infrastructure +dependency_overrides: + deps: + path: ../deps + feature_auth: + path: ../features/auth + feature_core: + path: ../features/_core + feature_user: + path: ../features/user + infrastructure: + path: ../infrastructure diff --git a/docs.json b/docs.json new file mode 100644 index 0000000..2e90b43 --- /dev/null +++ b/docs.json @@ -0,0 +1,281 @@ +{ + "anchors": [ + { + "href": "#", + "icon": "hand-holding-dollar", + "title": "Have no time or want more?" + }, + { + "href": "/design", + "icon": "figma", + "title": "Looking for a ready-to-use kit?" + }, + { + "href": "#", + "icon": "discord", + "title": "Need help or a new feature?" + } + ], + "description": "", + "logo": { + "href": "/", + "light": "/assets/logo.svg" + }, + "name": "Flutter Advanced Boilerplate", + "sidebar": [ + { + "group": "Getting Started", + "pages": [ + { + "href": "/", + "icon": "readme", + "title": "About" + }, + { + "href": "/getting-started/dependencies", + "icon": "circle-nodes", + "title": "Dependencies" + }, + { + "href": "/getting-started/architecture", + "icon": "folder-tree", + "title": "Architecture" + }, + { + "href": "/getting-started/changelog", + "icon": "arrow-up-9-1", + "title": "Changelog" + }, + { + "href": "/getting-started/license", + "icon": "scale-balanced", + "title": "License" + } + ] + }, + { + "group": "Guides", + "pages": [ + { + "group": "Preparation", + "icon": "screwdriver-wrench", + "pages": [ + { + "href": "/guides/preparation/set-the-environment", + "icon": "download", + "title": "Set The Environment" + }, + { + "href": "/guides/preparation/install-extensions", + "icon": "puzzle-piece", + "title": "Install Extensions" + }, + { + "href": "/guides/preparation/configure-snippets", + "icon": "code", + "title": "Configure Snippets" + } + ] + }, + { + "group": "Setup", + "icon": "hammer", + "pages": [ + { + "href": "/guides/setup/fetch-and-int", + "icon": "download", + "title": "Fetch and Init" + }, + { + "href": "/guides/setup/understand-melos", + "icon": "m", + "title": "Understand Melos" + }, + { + "href": "/guides/setup/generate-code", + "icon": "code", + "title": "Generate Code" + } + ] + }, + { + "href": "/guides/startup", + "icon": "terminal", + "title": "Startup" + }, + { + "group": "Deployment", + "icon": "rocket", + "pages": [ + { + "group": "Pre-deployment", + "icon": "toolbox", + "pages": [ + { + "href": "/guides/deployment/pre-deployment/change-app-icon", + "icon": "shapes", + "title": "Change App Icon" + }, + { + "href": "/guides/deployment/pre-deployment/generate-splash-screen", + "icon": "mobile-screen", + "title": "Generate Splash Screen" + } + ] + }, + { + "href": "/guides/deployment/versioning", + "icon": "arrows-split-up-and-left", + "title": "Versioning" + }, + { + "href": "/guides/deployment/publish-google-play-store", + "icon": "google-play", + "title": "Publish Google Play Store" + }, + { + "href": "/guides/deployment/publish-apple-store", + "icon": "app-store", + "title": "Publish Apple App Store" + } + ] + }, + { + "href": "/guides/automation-ci-cd", + "icon": "robot", + "title": "Automation (CI/CD)" + } + ] + }, + { + "group": "Modules", + "pages": [ + { + "href": "/modules/app", + "icon": "hashtag", + "title": "app" + }, + { + "group": "features", + "icon": "icons", + "pages": [ + { + "href": "/modules/features/_core", + "icon": "asterisk", + "title": "_core" + }, + { + "href": "/modules/features/feature-x", + "icon": "florin-sign", + "title": "feature_x" + } + ] + }, + { + "href": "/modules/deps", + "icon": "bezier-curve", + "title": "deps" + }, + { + "group": "design", + "icon": "palette", + "pages": [ + { + "href": "/modules/design/_core", + "icon": "asterisk", + "title": "_core" + }, + { + "href": "/modules/design/atoms", + "icon": "border-top-left", + "title": "atoms" + }, + { + "href": "/modules/design/widgets", + "icon": "border-top-left", + "title": "widgets" + } + ] + }, + + + { + "group": "infrastructure", + "icon": "layer-group", + "pages": [ + { + "href": "/modules/infrastructure/_core", + "icon": "asterisk", + "title": "_core" + }, + { + "href": "/modules/infrastructure/analytics", + "icon": "bug", + "title": "analytics" + }, + { + "href": "/modules/infrastructure/flavors", + "icon": "at", + "title": "flavors" + }, + { + "href": "/modules/infrastructure/networking", + "icon": "earth-asia", + "title": "networking" + }, + { + "href": "/modules/infrastructure/permissions", + "icon": "unlock", + "title": "permissions" + }, + { + "href": "/modules/infrastructure/presentation", + "icon": "cubes", + "title": "presentation" + }, + { + "href": "/modules/infrastructure/storage", + "icon": "database", + "title": "storage" + }, + { + "href": "/modules/infrastructure/translations", + "icon": "language", + "title": "translations" + } + ] + }, + { + "href": "/modules/widgetbook", + "icon": "border-top-left", + "title": "widgetbook" + } + ] + }, + { + "group": "Misc.", + "pages": [ + { + "href": "/misc/faqs", + "icon": "circle-question", + "title": "FAQs" + } + ] + } + ], + "tabs": [ + { + "href": "/", + "id": "documentation", + "title": "Documentation" + }, + { + "href": "#", + "id": "cookbook", + "title": "Cookbook" + } + ], + "theme": { + "primary": "#027DFD" + } +} \ No newline at end of file diff --git a/docs/assets/architecture.svg b/docs/assets/architecture.svg new file mode 100644 index 0000000..cffbfb7 --- /dev/null +++ b/docs/assets/architecture.svg @@ -0,0 +1,17 @@ + + + + + + + + appdepsfeaturesdesigninfrastructure_corefeature_x_corefeature_yanalyticsflavorsnetworkingpermissionspresentationstoragetranslationswidgetbookwidgetsatoms_core \ No newline at end of file diff --git a/docs/assets/logo.svg b/docs/assets/logo.svg new file mode 100644 index 0000000..cb98212 --- /dev/null +++ b/docs/assets/logo.svg @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/docs/getting-started/architecture.mdx b/docs/getting-started/architecture.mdx new file mode 100644 index 0000000..0ebbf13 --- /dev/null +++ b/docs/getting-started/architecture.mdx @@ -0,0 +1,8 @@ +--- +title: Architecture +description: Description +--- + +![Represantation of the architecture](/assets/architecture.svg) + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/getting-started/changelog.mdx b/docs/getting-started/changelog.mdx new file mode 100644 index 0000000..358db04 --- /dev/null +++ b/docs/getting-started/changelog.mdx @@ -0,0 +1,6 @@ +--- +title: Changelog +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/getting-started/dependencies.mdx b/docs/getting-started/dependencies.mdx new file mode 100644 index 0000000..e8ad7fb --- /dev/null +++ b/docs/getting-started/dependencies.mdx @@ -0,0 +1,6 @@ +--- +title: Dependencies +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/getting-started/license.mdx b/docs/getting-started/license.mdx new file mode 100644 index 0000000..d3a9c2a --- /dev/null +++ b/docs/getting-started/license.mdx @@ -0,0 +1,33 @@ +--- +title: License +description: BSD 4-Clause "Original" or "Old" License +--- + +A permissive license similar to the BSD 3-Clause License, but with an "advertising clause" that requires an acknowledgment of the original source in all advertising material. + + + + Commercial use, Modification, Distribution, Private use + + + Liability, Warranty + + + License and copyright notice + + + +Copyright 2024 Fikret Şengül. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. All advertising materials mentioning features or use of this software must display the following acknowledgement: +This product includes software developed by the organization . + +1. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY COPYRIGHT HOLDER "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL COPYRIGHT HOLDER BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/docs/guides/automation-ci-cd.mdx b/docs/guides/automation-ci-cd.mdx new file mode 100644 index 0000000..96f2ad0 --- /dev/null +++ b/docs/guides/automation-ci-cd.mdx @@ -0,0 +1,6 @@ +--- +title: Automation (CI/CD) +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/guides/deployment/pre-deployment/change-app-icon.mdx b/docs/guides/deployment/pre-deployment/change-app-icon.mdx new file mode 100644 index 0000000..1bee269 --- /dev/null +++ b/docs/guides/deployment/pre-deployment/change-app-icon.mdx @@ -0,0 +1,6 @@ +--- +title: Change App Icon +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/guides/deployment/pre-deployment/generate-splash-screen.mdx b/docs/guides/deployment/pre-deployment/generate-splash-screen.mdx new file mode 100644 index 0000000..efec962 --- /dev/null +++ b/docs/guides/deployment/pre-deployment/generate-splash-screen.mdx @@ -0,0 +1,6 @@ +--- +title: Generate Splash Screen +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/guides/deployment/publish-apple-store.mdx b/docs/guides/deployment/publish-apple-store.mdx new file mode 100644 index 0000000..eebeffb --- /dev/null +++ b/docs/guides/deployment/publish-apple-store.mdx @@ -0,0 +1,6 @@ +--- +title: Publish Apple App Store +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/guides/deployment/publish-google-play-store.mdx b/docs/guides/deployment/publish-google-play-store.mdx new file mode 100644 index 0000000..fb2244a --- /dev/null +++ b/docs/guides/deployment/publish-google-play-store.mdx @@ -0,0 +1,6 @@ +--- +title: Publish Google Play Store +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/guides/deployment/versioning.mdx b/docs/guides/deployment/versioning.mdx new file mode 100644 index 0000000..828fb64 --- /dev/null +++ b/docs/guides/deployment/versioning.mdx @@ -0,0 +1,6 @@ +--- +title: Versioning +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/guides/preparation/configure-snippets.mdx b/docs/guides/preparation/configure-snippets.mdx new file mode 100644 index 0000000..589192e --- /dev/null +++ b/docs/guides/preparation/configure-snippets.mdx @@ -0,0 +1,6 @@ +--- +title: Configure Snippets +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/guides/preparation/install-extensions.mdx b/docs/guides/preparation/install-extensions.mdx new file mode 100644 index 0000000..9a44611 --- /dev/null +++ b/docs/guides/preparation/install-extensions.mdx @@ -0,0 +1,6 @@ +--- +title: Install Extensions +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/guides/preparation/set-the-environment.mdx b/docs/guides/preparation/set-the-environment.mdx new file mode 100644 index 0000000..8fef0da --- /dev/null +++ b/docs/guides/preparation/set-the-environment.mdx @@ -0,0 +1,6 @@ +--- +title: Set The Environment +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/guides/setup/fetch-and-init.mdx b/docs/guides/setup/fetch-and-init.mdx new file mode 100644 index 0000000..0793ea7 --- /dev/null +++ b/docs/guides/setup/fetch-and-init.mdx @@ -0,0 +1,6 @@ +--- +title: Fetch and Init +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/guides/setup/generate-code.mdx b/docs/guides/setup/generate-code.mdx new file mode 100644 index 0000000..a401012 --- /dev/null +++ b/docs/guides/setup/generate-code.mdx @@ -0,0 +1,6 @@ +--- +title: Generate Code +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/guides/setup/understand-melos.mdx b/docs/guides/setup/understand-melos.mdx new file mode 100644 index 0000000..4dc57ed --- /dev/null +++ b/docs/guides/setup/understand-melos.mdx @@ -0,0 +1,6 @@ +--- +title: Understand Melos +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/guides/startup.mdx b/docs/guides/startup.mdx new file mode 100644 index 0000000..87ee3c6 --- /dev/null +++ b/docs/guides/startup.mdx @@ -0,0 +1,6 @@ +--- +title: Startup +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/index.mdx b/docs/index.mdx new file mode 100644 index 0000000..7b64891 --- /dev/null +++ b/docs/index.mdx @@ -0,0 +1,6 @@ +--- +title: About +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/misc/faqs.mdx b/docs/misc/faqs.mdx new file mode 100644 index 0000000..7f031a8 --- /dev/null +++ b/docs/misc/faqs.mdx @@ -0,0 +1,6 @@ +--- +title: FAQs +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/modules/app.mdx b/docs/modules/app.mdx new file mode 100644 index 0000000..304a4f7 --- /dev/null +++ b/docs/modules/app.mdx @@ -0,0 +1,6 @@ +--- +title: App +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/modules/deps.mdx b/docs/modules/deps.mdx new file mode 100644 index 0000000..9f3a704 --- /dev/null +++ b/docs/modules/deps.mdx @@ -0,0 +1,6 @@ +--- +title: Deps +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/modules/design/_core.mdx b/docs/modules/design/_core.mdx new file mode 100644 index 0000000..2c43f21 --- /dev/null +++ b/docs/modules/design/_core.mdx @@ -0,0 +1,6 @@ +--- +title: Core +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/modules/design/atoms.mdx b/docs/modules/design/atoms.mdx new file mode 100644 index 0000000..3d64a68 --- /dev/null +++ b/docs/modules/design/atoms.mdx @@ -0,0 +1,6 @@ +--- +title: Atoms +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/modules/design/widgets.mdx b/docs/modules/design/widgets.mdx new file mode 100644 index 0000000..b818d34 --- /dev/null +++ b/docs/modules/design/widgets.mdx @@ -0,0 +1,6 @@ +--- +title: Widgets +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/modules/features/_core.mdx b/docs/modules/features/_core.mdx new file mode 100644 index 0000000..2c43f21 --- /dev/null +++ b/docs/modules/features/_core.mdx @@ -0,0 +1,6 @@ +--- +title: Core +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/modules/features/feature-x.mdx b/docs/modules/features/feature-x.mdx new file mode 100644 index 0000000..8587c39 --- /dev/null +++ b/docs/modules/features/feature-x.mdx @@ -0,0 +1,6 @@ +--- +title: Feature X +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/modules/infrastructure/_core.mdx b/docs/modules/infrastructure/_core.mdx new file mode 100644 index 0000000..2c43f21 --- /dev/null +++ b/docs/modules/infrastructure/_core.mdx @@ -0,0 +1,6 @@ +--- +title: Core +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/modules/infrastructure/analytics.mdx b/docs/modules/infrastructure/analytics.mdx new file mode 100644 index 0000000..9312cfa --- /dev/null +++ b/docs/modules/infrastructure/analytics.mdx @@ -0,0 +1,6 @@ +--- +title: Analytics +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/modules/infrastructure/flavors.mdx b/docs/modules/infrastructure/flavors.mdx new file mode 100644 index 0000000..78af5f8 --- /dev/null +++ b/docs/modules/infrastructure/flavors.mdx @@ -0,0 +1,6 @@ +--- +title: Flavors +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/modules/infrastructure/networking.mdx b/docs/modules/infrastructure/networking.mdx new file mode 100644 index 0000000..b1b7a0b --- /dev/null +++ b/docs/modules/infrastructure/networking.mdx @@ -0,0 +1,6 @@ +--- +title: Networking +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/modules/infrastructure/permissions.mdx b/docs/modules/infrastructure/permissions.mdx new file mode 100644 index 0000000..c867661 --- /dev/null +++ b/docs/modules/infrastructure/permissions.mdx @@ -0,0 +1,6 @@ +--- +title: Permissions +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/modules/infrastructure/presentation.mdx b/docs/modules/infrastructure/presentation.mdx new file mode 100644 index 0000000..04ade99 --- /dev/null +++ b/docs/modules/infrastructure/presentation.mdx @@ -0,0 +1,6 @@ +--- +title: Presentation +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/modules/infrastructure/storage.mdx b/docs/modules/infrastructure/storage.mdx new file mode 100644 index 0000000..090b123 --- /dev/null +++ b/docs/modules/infrastructure/storage.mdx @@ -0,0 +1,6 @@ +--- +title: Storage +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/modules/infrastructure/translations.mdx b/docs/modules/infrastructure/translations.mdx new file mode 100644 index 0000000..f075b99 --- /dev/null +++ b/docs/modules/infrastructure/translations.mdx @@ -0,0 +1,6 @@ +--- +title: Translations +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/docs/modules/widgetbook.mdx b/docs/modules/widgetbook.mdx new file mode 100644 index 0000000..a4d04b1 --- /dev/null +++ b/docs/modules/widgetbook.mdx @@ -0,0 +1,6 @@ +--- +title: Widgetbook +description: Description +--- + +This part is currently being documented. Thank you for your patience. \ No newline at end of file diff --git a/features/_core/build.yaml b/features/_core/build.yaml new file mode 100644 index 0000000..d32e32f --- /dev/null +++ b/features/_core/build.yaml @@ -0,0 +1,109 @@ +# Copyright 2024 Thomas. All rights reserved. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file. +# + +# This `build.yaml` file is used to configure various code generation tools such as Auto Route, +# Freezed, Json Serializable, Injectable, and Slang for a Flutter project. + +targets: + $default: + builders: + # Auto Route Generators + # + # These settings configure the code generation for the Auto Route package. Auto Route + # helps in generating the routing infrastructure for a Flutter app. + # + # `auto_router_generator`: Generates the primary routing files, caching build options + # to improve build performance. + + # This section handles the main AutoRoute generator for router.dart files. + auto_route_generator:auto_router_generator: + options: + enable_cached_builds: true + generate_for: + include: + - lib/_router/*.router.dart + + # Auto Route Generators + # + # These settings configure the code generation for the Auto Route package. Auto Route + # helps in generating the routing infrastructure for a Flutter app. + # + # `auto_router_generator`: Generates the primary routing files, caching build options + # to improve build performance. + + # This section handles the main AutoRoute generator for router.dart files. + auto_route_generator:auto_route_generator: + options: + enable_cached_builds: true + generate_for: + include: + - lib/commons/pages/*.page.dart + - lib/commons/routers/*.router.dart + + # Freezed Generators + # + # Freezed is used to generate immutable data classes, unions, and sealed classes. This section configures how + # Freezed generates code, particularly controlling the behavior of `when` and `map` methods. + + # This section controls Freezed code generation. + freezed: + options: + map: false + when: + when: false + maybe_when: false + when_or_null: true + generate_for: + include: + - lib/commons/cubits/*.cubit.dart + - lib/commons/models/*.model.dart + + # Json Serializable Generators + # + # Json Serializable is used to generate serialization and deserialization logic for models. + # It can automatically create methods to convert objects to and from JSON format. + + # This section controls JSON serialization and deserialization code generation. + json_serializable: + options: + create_factory: true + create_to_json: true + explicit_to_json: true + field_rename: none + include_if_null: true + generate_for: + include: + - lib/commons/models/*.model.dart + + # Injectable Generators + # + # Injectable is used to generate dependency injection code for the app. It automatically generates + # registration code for classes annotated with `@Injectable` or `@LazySingleton`. + + # This section controls the Injectable dependency injection code generation. + injectable_generator:injectable_builder: + generate_for: + include: + - lib/_di/*.dart + - lib/commons/cubits/*.cubit.dart + + # Slang Generators + # + # Slang is used for internationalization (i18n) by generating translation classes. This configuration + # controls how translations are handled and generated. + + # This section configures the Slang package for generating i18n (internationalization) translation classes. + slang_build_runner: + options: + locale_handling: false + translation_class_visibility: public + fallback_strategy: base_locale + input_directory: lib/_core/_i18n + output_directory: lib/_core/_i18n + output_file_name: translations.g.dart + key_case: camel + key_map_case: camel + param_case: camel + flat_map: false \ No newline at end of file diff --git a/features/_core/lib/_di/_di.dart b/features/_core/lib/_di/_di.dart new file mode 100644 index 0000000..44adc30 --- /dev/null +++ b/features/_core/lib/_di/_di.dart @@ -0,0 +1,39 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: prefer-static-class, avoid-ignoring-return-values, prefer-correct-identifier-length + +import 'package:deps/packages/get_it.dart'; +import 'package:deps/packages/injectable.dart'; +import 'package:feature_auth/_core/_di/_di.dart'; +import 'package:feature_core/_di/_di.config.dart'; +import 'package:feature_core/_router/features.router.dart'; +import 'package:feature_user/_core/_di/_di.dart'; + +part '_modules.dart'; + +/// [injectAllFeatures] is responsible for initializing all the feature-specific dependency injection setups. +/// +/// It configures the DI container [GetIt] with dependencies for various features by calling their respective +/// injection methods. It also runs the default injection setup for the core app using `@InjectableInit`. +/// +/// Parameters: +/// - [di]: The [GetIt] instance representing the DI container. +/// - [env]: The environment string used to determine which dependencies should be injected (e.g., `dev`, `prod`). +/// +/// Example usage: +/// ```dart +/// injectAllFeatures(di: GetIt.instance, env: 'prod'); +/// ``` +@InjectableInit() +void injectAllFeatures({required GetIt di, required String env}) { + // Initialize the core dependencies using @InjectableInit + di.init(environment: env); + + // Initialize feature-specific dependencies + injectAuthFeature(di, env); + injectUserFeature(di, env); +} diff --git a/features/_core/lib/_di/_modules.dart b/features/_core/lib/_di/_modules.dart new file mode 100644 index 0000000..d7f203f --- /dev/null +++ b/features/_core/lib/_di/_modules.dart @@ -0,0 +1,31 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: prefer-match-file-name + +part of '_di.dart'; + +/// [FeaturesRouterModule] provides a lazily initialized instance of [FeaturesRouter]. +/// +/// This module is part of the dependency injection setup and defines how the [FeaturesRouter] +/// should be provided across the application. The [@module] annotation marks it as an Injectable module +/// that groups dependency providers. The [@lazySingleton] annotation ensures that a single instance +/// of [FeaturesRouter] is created lazily (when it's first required). +/// +/// Usage: +/// - This class is automatically integrated into the DI system, making the [FeaturesRouter] accessible +/// wherever it's injected using GetIt or other DI methods. +/// +/// Example: +/// ```dart +/// final router = GetIt.instance(); +/// ``` +@module +abstract class FeaturesRouterModule { + /// Provides a lazily created singleton instance of [FeaturesRouter]. + @lazySingleton + FeaturesRouter get router => FeaturesRouter(); +} diff --git a/features/_core/lib/_router/enums/navigation_tabs.enum.dart b/features/_core/lib/_router/enums/navigation_tabs.enum.dart new file mode 100644 index 0000000..8a91fa7 --- /dev/null +++ b/features/_core/lib/_router/enums/navigation_tabs.enum.dart @@ -0,0 +1,51 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/auto_route.dart'; +import 'package:feature_core/_router/features.router.gr.dart'; +import 'package:flutter/material.dart'; + +/// Enum [NavigationTabsEnum] represents different navigation tabs in the application. +/// +/// Each tab is associated with a screen route, an icon, and a label. +/// The routes correspond to specific pages in the app, and icons are used for visual representation +/// in the navigation bar. The labels are currently static and should be localized using translations. +/// +/// Example usage: +/// ```dart +/// NavigationTabsEnum.a.screen; +/// NavigationTabsEnum.b.icon; +/// ``` +/// +/// Properties: +/// - `screen`: The route information for the tab, representing the destination page. +/// - `icon`: The icon used in the navigation bar for the tab. +/// - `label`: The label for the tab, which should be moved to the translation system. +enum NavigationTabsEnum { + /// Example tab representing the "Home" page. + /// This should be replaced with the actual home page route. + a(screen: ARoute(), icon: Icons.home, label: 'Home'), + + /// Example tab representing the "Settings" page. + /// This should be replaced with the actual settings page route. + b(screen: BRoute(), icon: Icons.settings, label: 'Settings'); + + /// The route representing the screen to navigate to. + final PageRouteInfo screen; + + /// The icon displayed in the bottom navigation bar. + final IconData icon; + + /// The label for the tab, which should be localized. + final String label; + + /// Constructor for the [NavigationTabsEnum]. + const NavigationTabsEnum({ + required this.screen, + required this.icon, + required this.label, + }); +} diff --git a/features/_core/lib/_router/features.router.dart b/features/_core/lib/_router/features.router.dart new file mode 100644 index 0000000..6202974 --- /dev/null +++ b/features/_core/lib/_router/features.router.dart @@ -0,0 +1,72 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/features/features.dart'; +import 'package:deps/infrastructure/infrastructure.dart'; +import 'package:deps/packages/auto_route.dart'; +import 'package:feature_core/_router/guards/auth.guard.dart'; +import 'package:feature_core/_router/routes/custom_page_route.dart'; +import 'package:flutter/material.dart'; + +/// A router class that defines the app's navigation configuration using AutoRoute. +/// +/// The [FeaturesRouter] handles the routing between the different features of +/// the app, such as authentication and core infrastructure routes. It includes +/// custom page transitions and guards for route protection. +@AutoRouterConfig() +class FeaturesRouter extends RootStackRouter { + /// Router for authentication-related routes. + final AuthFeatureRouter authFeatureRouter = AuthFeatureRouter(); + + /// Router for infrastructure-related routes. + final InfrastructureRouter infrastructureRouter = InfrastructureRouter(); + + /// Default route type with a custom transition animation. + /// + /// This method defines a custom page transition using [CustomPageRoute], which + /// adds a transition duration of 500 milliseconds for forward and reverse animations. + @override + RouteType get defaultRouteType => RouteType.custom( + customRouteBuilder: (_, Widget child, AutoRoutePage page) { + return CustomPageRoute( + builder: (_) { + return child; + }, + settings: page, + ); + }, + durationInMilliseconds: 500, + reverseDurationInMilliseconds: 500, + ); + + /// List of routes in the app. + /// + /// This method defines the application's routing structure, including the main + /// wrapper (`SuperWrapper`), the `DashboardRouter` which contains child routes + /// and route guards, as well as additional routes for authentication and infrastructure. + @override + List get routes => [ + AutoRoute( + page: SuperWrapper.page, + children: [ + AutoRoute( + page: DashboardRouter.page, + guards: const [AuthGuard()], + children: [ + // TODO: Routes in the dashboard should be placed here. + AutoRoute(page: ARoute.page), + AutoRoute(page: BRoute.page), + ], + initial: true, + ), + ...authFeatureRouter.routes, + // TODO: Other routes should be placed here. + ...infrastructureRouter.routes, + ], + initial: true, + ), + ]; +} diff --git a/features/_core/lib/_router/guards/auth.guard.dart b/features/_core/lib/_router/guards/auth.guard.dart new file mode 100644 index 0000000..13ea5c9 --- /dev/null +++ b/features/_core/lib/_router/guards/auth.guard.dart @@ -0,0 +1,62 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: avoid-dynamic, avoid-ignoring-return-values, avoid-unassigned-stream-subscriptions + +import 'dart:async'; + +import 'package:deps/infrastructure/infrastructure.dart'; +import 'package:deps/locator/locator.dart'; +import 'package:deps/packages/auto_route.dart'; +import 'package:feature_auth/_core/_router/router.gr.dart'; +import 'package:feature_auth/views/cubits/auth.cubit.dart'; +import 'package:feature_user/views/cubits/user.cubit.dart'; + +/// [AuthGuard] is a custom route guard used to protect routes that require user authentication. +/// +/// It implements the [AutoRouteGuard] interface and is responsible for handling route navigation based on the user's +/// authentication status. If the user is authenticated and the user data is available, the navigation proceeds; +/// otherwise, the user is redirected to the authentication route. +/// +/// Example usage: +/// ```dart +/// AutoRoute( +/// path: '/protected', +/// page: ProtectedPage, +/// guards: [AuthGuard()], +/// ) +/// ``` +class AuthGuard extends AutoRouteGuard { + /// Creates an instance of [AuthGuard] with the default constructor. + const AuthGuard(); + + /// Handles the navigation when a user attempts to access a guarded route. + /// + /// If the user is authenticated (checked via the [AuthCubit]) and their user data is available + /// (checked via the [UserCubit]), the route navigation proceeds by calling [resolver.next]. + /// + /// If the user is unauthenticated, they are redirected to the authentication route using + /// [resolver.redirect], replacing the current route. + /// + /// - [resolver]: The resolver allows you to either continue navigation or redirect the user. + /// - [router]: The current router stack that manages the navigation flow. + @override + Future onNavigation( + NavigationResolver resolver, StackRouter router) async { + // Check if the user is authenticated and user data exists. + Future.delayed(Duration.zero, () async { + if (locator().state == AuthStatusEnum.authenticated && + locator().state.user.isNotEmpty) { + // Proceed with the navigation if authenticated. + resolver.next(); + } + // Redirect to the authentication route if unauthenticated. + else if (locator().state == AuthStatusEnum.unauthenticated) { + await resolver.redirect(const AuthRoute(), replace: true); + } + }); + } +} diff --git a/features/_core/lib/_router/routes/custom_page_route.dart b/features/_core/lib/_router/routes/custom_page_route.dart new file mode 100644 index 0000000..aad3a61 --- /dev/null +++ b/features/_core/lib/_router/routes/custom_page_route.dart @@ -0,0 +1,41 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter/material.dart'; + +/// A custom page route that extends [MaterialPageRoute] to provide custom transition duration. +/// +/// This class allows you to define a page route with a custom transition animation duration +/// while maintaining the default behavior of [MaterialPageRoute]. +/// +/// Example usage: +/// ```dart +/// Navigator.push( +/// context, +/// CustomPageRoute( +/// builder: (context) => YourScreen(), +/// settings: RouteSettings(name: 'your_screen'), +/// ), +/// ); +/// ``` +/// +/// [T] is the type of the return value of the route. +/// +/// - `transitionDuration`: Overridden to define the custom transition duration of 500 milliseconds. +class CustomPageRoute extends MaterialPageRoute { + /// Creates a custom page route with the specified builder and route settings. + /// + /// - [builder]: A function that returns the widget to be displayed by this route. + /// - [settings]: Route-specific settings, such as the name of the route. + CustomPageRoute({required super.builder, required super.settings}); + + /// Custom transition duration for the route. + /// + /// This overrides the default transition duration of [MaterialPageRoute] + /// and provides a 500 millisecond transition. + @override + Duration get transitionDuration => const Duration(milliseconds: 500); +} diff --git a/features/_core/lib/commons/pages/a.page.dart b/features/_core/lib/commons/pages/a.page.dart new file mode 100644 index 0000000..fcd892b --- /dev/null +++ b/features/_core/lib/commons/pages/a.page.dart @@ -0,0 +1,38 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/infrastructure/infrastructure.dart'; +import 'package:deps/packages/auto_route.dart'; +import 'package:flutter/cupertino.dart'; + +/// A simple page represented by [APage] which serves as a placeholder. +/// +/// This page is built using [CupertinoPageScaffold] and showcases the use of a Cupertino style +/// navigation bar. The page displays a `Placeholder` widget and a text label in the navigation bar. +/// +// TODO: These page should be deleted and created as feature. +@RoutePage() +class APage extends StatelessWidget { + const APage({super.key}); + + /// Builds the UI for the page. + /// + /// The method returns a [CupertinoPageScaffold] with a navigation bar and a placeholder + /// widget as the page's content. + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + // Sets the title of the page using a custom text style from the theme. + middle: 'Home'.text(style: $.theme.fonts.h1), + // Disables transition between routes for the navigation bar. + transitionBetweenRoutes: false, + ), + // Displays a simple placeholder as the body of the page. + child: const Placeholder(), + ); + } +} diff --git a/features/_core/lib/commons/pages/b.page.dart b/features/_core/lib/commons/pages/b.page.dart new file mode 100644 index 0000000..7417f05 --- /dev/null +++ b/features/_core/lib/commons/pages/b.page.dart @@ -0,0 +1,50 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/design/design.dart'; +import 'package:deps/infrastructure/infrastructure.dart'; +import 'package:deps/packages/auto_route.dart'; +import 'package:feature_auth/views/cubits/auth.cubit.dart'; +import 'package:flutter/cupertino.dart'; + +/// A basic settings page represented by [BPage]. +/// +/// This page contains a Cupertino-style UI with a logout button that triggers +/// the logout functionality of the application. It is currently used as a placeholder +/// and should be refactored as part of a feature module. +/// +// TODO: These page should be deleted and created as feature. +@RoutePage() +class BPage extends StatelessWidget { + const BPage({super.key}); + + /// Builds the UI for the settings page. + /// + /// The method returns a [CupertinoPageScaffold] with a navigation bar and a centered + /// logout button that logs the user out when pressed. + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + // Sets the title of the page using a custom text style from the theme. + middle: 'Settings'.text(style: $.theme.fonts.h1), + // Disables transition between routes for the navigation bar. + transitionBetweenRoutes: false, + ), + // Displays a logout button in the center of the page. + child: Center( + child: CupertinoButton( + // Styles the button's background and text color using the theme. + color: $.theme.colors.blue, + // The button's label is styled with the theme's h2 font and white text color. + child: 'Logout'.styled(style: $.theme.fonts.h2.white), + // Logs out the user when the button is pressed. + onPressed: () => $.get().logout(useBackend: false), + ), + ), + ); + } +} diff --git a/features/_core/lib/commons/routers/dashboard.router.dart b/features/_core/lib/commons/routers/dashboard.router.dart new file mode 100644 index 0000000..4c38c17 --- /dev/null +++ b/features/_core/lib/commons/routers/dashboard.router.dart @@ -0,0 +1,83 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: always_specify_types + +import 'package:deps/infrastructure/infrastructure.dart'; +import 'package:deps/packages/auto_route.dart'; +import 'package:deps/packages/flutter_bloc.dart'; +import 'package:feature_user/views/cubits/user.cubit.dart'; +import 'package:flutter/cupertino.dart'; + +/// [DashboardRouter] is the main entry point for a tabbed navigation UI using `AutoRoute`. +/// +/// This widget builds an [AutoTabsScaffold] that contains the navigation structure +/// for the authenticated user. The tabs are dynamically generated based on the user's +/// authentication status and are retrieved from the `UserCubit`. +/// +/// The [CupertinoTabBar] is used as the bottom navigation bar to switch between +/// different tabs. The tab structure can be adjusted based on the routes returned +/// by the [UserCubit]. +@RoutePage() +class DashboardRouter extends StatefulWidget { + const DashboardRouter({super.key}); + + @override + State createState() => _DashboardRouterState(); +} + +/// The state for the [DashboardRouter] widget. +/// +/// This class manages the state of the tabbed navigation structure, including +/// the setup of a [HeroController] for smooth page transitions. +class _DashboardRouterState extends State { + final HeroController _heroController = HeroController(); + + /// Builds the main UI structure using [AutoTabsScaffold]. + /// + /// The tabs are populated dynamically based on the authenticated user's + /// available navigation tabs, which are fetched from the `UserCubit`. + @override + Widget build(BuildContext context) { + return AutoTabsScaffold( + // Retrieves the list of navigation tabs available for the authenticated user. + routes: $ + .get() + .getAuthenticatedNavigationTabs + .map((tab) => tab.screen) + .toList(), + // Defines the bottom navigation bar using a CupertinoTabBar. + bottomNavigationBuilder: (_, TabsRouter tabsRouter) { + return BlocBuilder( + builder: (_, __) { + return CupertinoTabBar( + currentIndex: tabsRouter.activeIndex, + // Changes the active tab when a user taps on a navigation item. + onTap: tabsRouter.setActiveIndex, + // Maps the available tabs into navigation items. + items: + $.get().getAuthenticatedNavigationTabs.map((tab) { + return BottomNavigationBarItem( + icon: Icon(tab.icon), + label: tab.label, + ); + }).toList(), + ); + }, + ); + }, + // Sets up navigation observers, including the [HeroController]. + navigatorObservers: () => [_heroController], + ); + } + + /// Disposes of resources such as the [HeroController] when the widget is removed from the tree. + @override + void dispose() { + _heroController.dispose(); + super.dispose(); + } +} diff --git a/features/_core/lib/core.dart b/features/_core/lib/core.dart new file mode 100644 index 0000000..2a71f29 --- /dev/null +++ b/features/_core/lib/core.dart @@ -0,0 +1,7 @@ +export 'package:feature_auth/auth.dart'; +export 'package:feature_user/user.dart'; + +export '_di/_di.dart'; +export '_router/enums/navigation_tabs.enum.dart'; +export '_router/features.router.dart'; +export '_router/features.router.gr.dart'; diff --git a/features/_core/pubspec.yaml b/features/_core/pubspec.yaml new file mode 100644 index 0000000..7a9422e --- /dev/null +++ b/features/_core/pubspec.yaml @@ -0,0 +1,27 @@ +name: feature_core +description: Core for all features of the flutter advanced boilerplate. +publish_to: none +version: 0.0.1 + +environment: + sdk: ">=3.5.3 <4.0.0" + +dependencies: + deps: + path: ../../deps + feature_auth: + path: ../auth + feature_user: + path: ../user + flutter: + sdk: flutter + json_annotation: ^4.9.0 + +dev_dependencies: + auto_route_generator: ^9.0.0 + build_runner: ^2.4.13 + dart_code_metrics_presets: ^2.16.0 + freezed: ^2.5.7 + injectable_generator: ^2.6.2 + json_serializable: ^6.8.0 + slang_build_runner: ^3.31.0 diff --git a/features/_core/pubspec_overrides.yaml b/features/_core/pubspec_overrides.yaml new file mode 100644 index 0000000..1f34b60 --- /dev/null +++ b/features/_core/pubspec_overrides.yaml @@ -0,0 +1,12 @@ +# melos_managed_dependency_overrides: deps,design,feature_auth,feature_user,infrastructure +dependency_overrides: + deps: + path: ../../deps + design: + path: ../../design + feature_auth: + path: ../auth + feature_user: + path: ../user + infrastructure: + path: ../../infrastructure diff --git a/features/auth/build.yaml b/features/auth/build.yaml new file mode 100644 index 0000000..2f1daf3 --- /dev/null +++ b/features/auth/build.yaml @@ -0,0 +1,113 @@ +# Copyright 2024 Thomas. All rights reserved. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file. +# + +# This `build.yaml` file is used to configure various code generation tools such as Auto Route, +# Freezed, Json Serializable, Injectable, and Slang for a Flutter project. + + +targets: + $default: + builders: + # Auto Route Generators + # + # These settings configure the code generation for the Auto Route package. Auto Route + # helps in generating the routing infrastructure for a Flutter app. + # + # `auto_router_generator`: Generates the primary routing files, caching build options + # to improve build performance. + + # This section handles the main AutoRoute generator for router.dart files. + auto_route_generator:auto_router_generator: + options: + enable_cached_builds: true + generate_for: + include: + - lib/_core/_router/router.dart + + # Auto Route Generators + # + # These settings configure the code generation for the Auto Route package. Auto Route + # helps in generating the routing infrastructure for a Flutter app. + # + # `auto_router_generator`: Generates the primary routing files, caching build options + # to improve build performance. + + # This section handles the main AutoRoute generator for router.dart files. + auto_route_generator:auto_route_generator: + options: + enable_cached_builds: true + generate_for: + include: + - lib/views/*.page.dart + - lib/views/**/*.page.dart + + # Freezed Generators + # + # Freezed is used to generate immutable data classes, unions, and sealed classes. This section configures how + # Freezed generates code, particularly controlling the behavior of `when` and `map` methods. + + # This section controls Freezed code generation. + freezed: + options: + map: false + when: + when: false + maybe_when: false + when_or_null: true + generate_for: + include: + - lib/_core/_di/*.dart + - lib/models/*.model.dart + - lib/views/cubits/*.cubit.dart + + # Json Serializable Generators + # + # Json Serializable is used to generate serialization and deserialization logic for models. + # It can automatically create methods to convert objects to and from JSON format. + + # This section controls JSON serialization and deserialization code generation. + json_serializable: + options: + create_factory: true + create_to_json: true + explicit_to_json: true + field_rename: none + include_if_null: true + generate_for: + include: + - lib/models/*.model.dart + - lib/views/cubits/*.cubit.dart + + # Injectable Generators + # + # Injectable is used to generate dependency injection code for the app. It automatically generates + # registration code for classes annotated with `@Injectable` or `@LazySingleton`. + + # This section controls the Injectable dependency injection code generation. + injectable_generator:injectable_builder: + generate_for: + include: + - lib/_core/_di/*.dart + - lib/services/*.service.dart + - lib/views/cubits/*.cubit.dart + + # Slang Generators + # + # Slang is used for internationalization (i18n) by generating translation classes. This configuration + # controls how translations are handled and generated. + + # This section configures the Slang package for generating i18n (internationalization) translation classes. + slang_build_runner: + options: + locale_handling: false + translation_class_visibility: public + fallback_strategy: base_locale + input_directory: lib/_core/_i18n + output_directory: lib/_core/_i18n + output_file_name: translations.g.dart + key_case: camel + key_map_case: camel + param_case: camel + flat_map: false \ No newline at end of file diff --git a/features/auth/lib/_core/_di/_di.dart b/features/auth/lib/_core/_di/_di.dart new file mode 100644 index 0000000..21eed32 --- /dev/null +++ b/features/auth/lib/_core/_di/_di.dart @@ -0,0 +1,29 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/get_it.dart'; +import 'package:deps/packages/injectable.dart'; +import 'package:feature_auth/_core/_di/_di.config.dart'; + +/// Initializes the dependency injection (DI) system for the auth feature. +/// +/// This function is responsible for injecting dependencies needed for the auth, +/// using the `GetIt` package for service location and the `Injectable` package for +/// automatic dependency injection setup. +/// +/// The DI setup is controlled by the [env] parameter, allowing different configurations +/// for different environments (e.g., development, production). +/// +/// The injected dependencies are defined in the `_di.config.dart` file, which is generated +/// by the `Injectable` package based on annotations in the project. +/// +/// - [di]: An instance of `GetIt` used for dependency injection. +/// - [env]: A string specifying the environment (e.g., 'dev', 'prod') to determine the setup. +@InjectableInit() +void injectAuthFeature(GetIt di, String env) { + // Initialize the dependencies for the specified environment. + di.init(environment: env); +} diff --git a/features/auth/lib/_core/_i18n/auth_i18n_cubit_locale.ext.dart b/features/auth/lib/_core/_i18n/auth_i18n_cubit_locale.ext.dart new file mode 100644 index 0000000..afcf767 --- /dev/null +++ b/features/auth/lib/_core/_i18n/auth_i18n_cubit_locale.ext.dart @@ -0,0 +1,28 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/flutter_bloc.dart'; +import 'package:feature_auth/_core/_i18n/translations.g.dart'; +import 'package:flutter/material.dart'; + +/// Extension on `Cubit` to easily access the auth's i18n (internationalization) functionality. +/// +/// This extension provides a convenient way to retrieve the appropriate `Translations` object for the current locale +/// managed by a `Cubit`. It uses `AppLocaleUtils.parse` to map the `Locale` to a specific `AppLocale` and +/// then builds the `Translations` object, which contains localized strings for the app. +/// +/// Example usage: +/// ```dart +/// final translations = $.tr.auth; +/// ``` +extension AuthI18nCubitLocaleExt on Cubit { + /// Retrieves the `Translations` object for the current locale. + /// + /// This method maps the `Locale` stored in the cubit's state to an `AppLocale` using `AppLocaleUtils.parse`. + /// It then builds and returns the corresponding `Translations` object, which provides localized strings + /// for use in the application. + Translations get auth => AppLocaleUtils.parse(state.toString()).build(); +} diff --git a/features/auth/lib/_core/_i18n/strings_en.i18n.json b/features/auth/lib/_core/_i18n/strings_en.i18n.json new file mode 100644 index 0000000..187dd2d --- /dev/null +++ b/features/auth/lib/_core/_i18n/strings_en.i18n.json @@ -0,0 +1,7 @@ +{ + "title": "Authentication", + "header": "Login to your account", + "button": { + "login": "Log in" + } +} \ No newline at end of file diff --git a/features/auth/lib/_core/_i18n/strings_tr.i18n.json b/features/auth/lib/_core/_i18n/strings_tr.i18n.json new file mode 100644 index 0000000..dc8c24b --- /dev/null +++ b/features/auth/lib/_core/_i18n/strings_tr.i18n.json @@ -0,0 +1,7 @@ +{ + "title": "Yetkilendirme", + "header": "Hesabınıza giriş yapın", + "button": { + "login": "Giriş Yap" + } +} \ No newline at end of file diff --git a/features/auth/lib/_core/_router/router.dart b/features/auth/lib/_core/_router/router.dart new file mode 100644 index 0000000..1e46672 --- /dev/null +++ b/features/auth/lib/_core/_router/router.dart @@ -0,0 +1,30 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: prefer-match-file-name + +import 'package:deps/packages/auto_route.dart'; +import 'package:feature_auth/_core/_router/router.gr.dart'; + +/// [AuthFeatureRouter] manages the routing for the authentication feature. +/// +/// This router is responsible for handling all the routes associated with the +/// authentication flow of the app. It defines a route for the authentication page +/// and integrates with AutoRoute for automatic routing. +@AutoRouterConfig() +class AuthFeatureRouter extends RootStackRouter { + /// Defines the list of routes for the authentication feature. + /// + /// The primary route for the authentication flow is the [AuthRoute], which + /// is accessible via the path `'auth'`. + @override + List get routes => [ + AutoRoute( + page: AuthRoute.page, + path: 'auth', + ), + ]; +} diff --git a/features/auth/lib/auth.dart b/features/auth/lib/auth.dart new file mode 100644 index 0000000..6fca138 --- /dev/null +++ b/features/auth/lib/auth.dart @@ -0,0 +1,4 @@ +export '_core/_router/router.dart'; +export '_core/_router/router.gr.dart'; +export 'services/auth.service.dart'; +export 'views/cubits/auth.cubit.dart'; diff --git a/features/auth/lib/services/auth.service.dart b/features/auth/lib/services/auth.service.dart new file mode 100644 index 0000000..0387c96 --- /dev/null +++ b/features/auth/lib/services/auth.service.dart @@ -0,0 +1,99 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: always_specify_types + +import 'package:deps/infrastructure/infrastructure.dart'; +import 'package:deps/packages/fpdart.dart'; +import 'package:deps/packages/injectable.dart'; +import 'package:feature_auth/services/failures/auth.failures.dart'; + +/// The `AuthService` is responsible for handling user authentication +/// operations, such as logging in and logging out, through the API. +/// +/// This class uses `IApiClient` to interact with the backend and +/// returns responses wrapped in `AsyncEither` to ensure proper +/// error handling. +/// +/// Example usage: +/// ```dart +/// final Either result = await _service.login(username: 'user', password: 'pass'); +/// result.fold( +/// (failure) => print('Login failed: $failure'), +/// (token) => print('Login succeeded: $token'), +/// ); +/// ``` +@lazySingleton +class AuthService { + /// Constructs the `AuthService` with the required API client. + AuthService(this._client); + + /// The API client used for making HTTP requests. + final IApiClient _client; + + /// Attempts to log in with the provided `username` and `password`. + /// + /// On success, it returns a `TokenModel` containing the access token, + /// refresh token, expiration date, and ID token. If the login fails, + /// it returns an `ExampleAuthFailure`. + /// + /// Example usage: + /// ```dart + /// final Either result = await _service.login(username: 'user', password: 'pass'); + /// ``` + AsyncEither login({ + required String username, + required String password, + }) async { + try { + // Mocked successful login response, returning a TokenModel. + return Right( + TokenModel( + accessToken: 'accessToken', + refreshToken: 'refreshToken', + expirationDate: DateTime(2100), + idToken: 'idToken', + ), + ); + } catch (exception) { + // If an error occurs during the login process, it wraps the exception + // in an `ExampleAuthFailure` and returns it as a `Left` value. + final ExampleAuthFailure failure = + ExampleAuthFailure(exception: exception); + return Left(failure); + } + } + + /// Attempts to log out using the provided `idToken`. + /// + /// The logout request is sent via a POST request, and the result is wrapped + /// in an `AsyncEither`. If the logout is successful, it returns a + /// `Right(null)`, otherwise, it returns a `Failure`. + /// + /// Example usage: + /// ```dart + /// final Either result = await _service.logout(idToken); + /// result.fold( + /// (failure) => print('Logout failed: $failure'), + /// (_) => print('Logout succeeded'), + /// ); + /// ``` + AsyncEither logout(String idToken) async { + // Make an API call to the 'logout' endpoint and handle the response. + final Either response = await _client.invoke( + 'logout', + RequestTypeEnum.post, + ); + + // Return either a success (Right) or a failure (Left). + return response.fold( + Left.new, + (bool isSucceeded) async { + return const Right(null); + }, + ); + } +} diff --git a/features/auth/lib/services/failures/auth.failures.dart b/features/auth/lib/services/failures/auth.failures.dart new file mode 100644 index 0000000..5295057 --- /dev/null +++ b/features/auth/lib/services/failures/auth.failures.dart @@ -0,0 +1,41 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: prefer-match-file-name + +import 'package:deps/infrastructure/infrastructure.dart'; + +/// [ExampleAuthFailure] represents a failure case that occurs during an example +/// authentication process. It extends the [Failure] class and categorizes this +/// failure as an authentication-related error. +/// +/// This class is useful for identifying specific authentication failures, enabling +/// better error handling and troubleshooting. +/// +/// Example usage: +/// ```dart +/// try { +/// // Example authentication logic +/// } catch (e, stack) { +/// throw ExampleAuthFailure(exception: e, stack: stack); +/// } +/// ``` +class ExampleAuthFailure extends Failure { + /// Creates an instance of [ExampleAuthFailure]. + /// + /// - [exception]: The original exception that triggered the failure. + /// - [stack]: The stack trace when the failure occurred. + /// + /// The failure is initialized with a specific message, code, and failure type, + /// indicating that it is related to an authentication failure scenario. + ExampleAuthFailure({super.exception, super.stack}) + : super( + code: 'example-auth-failure', + type: FailureTypeEnum.exception, + tag: FailureTagEnum.authentication, + message: 'We encountered a failure while authenticating.', + ); +} diff --git a/features/auth/lib/views/auth.page.dart b/features/auth/lib/views/auth.page.dart new file mode 100644 index 0000000..e8912e6 --- /dev/null +++ b/features/auth/lib/views/auth.page.dart @@ -0,0 +1,96 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: avoid-unnecessary-stateful-widgets, prefer-extracting-callbacks, prefer-moving-to-variable + +import 'package:deps/design/design.dart'; +import 'package:deps/infrastructure/infrastructure.dart'; +import 'package:deps/packages/auto_route.dart'; +import 'package:deps/packages/flutter_bloc.dart'; +import 'package:deps/packages/reactive_forms.dart'; +import 'package:feature_auth/_core/_i18n/auth_i18n_cubit_locale.ext.dart'; +import 'package:feature_auth/views/cubits/auth.cubit.dart'; +import 'package:feature_auth/views/forms/login.form.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +/// The [AuthPage] is responsible for displaying the login UI and handling user authentication. +/// It uses [ReactiveForms] for handling form controls and validation, and the [AuthCubit] for managing authentication logic. +@RoutePage() +class AuthPage extends StatefulWidget { + /// Creates a new instance of [AuthPage]. + const AuthPage({super.key}); + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => $.get(), + child: ReactiveForm( + formGroup: LoginForm.form, + child: CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: $.tr.auth.title.text(style: $.theme.fonts.h1), + ), + child: PaddingAll.sm( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + $.tr.auth.header.styled(style: $.theme.fonts.h2.regular), + PaddingGap.xxs(), + ReactiveTextField( + formControlName: 'username', + decoration: InputDecoration( + labelText: 'Username', + labelStyle: $.theme.fonts.body1, + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: $.theme.colors.blue), + ), + ), + ), + ReactiveTextField( + formControlName: 'password', + decoration: InputDecoration( + labelText: 'Password', + labelStyle: $.theme.fonts.body1, + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: $.theme.colors.blue), + ), + ), + ), + PaddingGap.md(), + ReactiveFormConsumer( + builder: (_, FormGroup form, ___) { + final String username = form.control('username').value; + final String password = form.control('password').value; + + return Center( + child: CupertinoButton( + color: $.theme.colors.blue, + child: $.tr.auth.button.login + .styled(style: $.theme.fonts.h2.white), + onPressed: form.valid + ? () => $ + .get() + .login(username: username, password: password) + .ignore() + : null, + ), + ); + }, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/features/auth/lib/views/cubits/auth.cubit.dart b/features/auth/lib/views/cubits/auth.cubit.dart new file mode 100644 index 0000000..db44612 --- /dev/null +++ b/features/auth/lib/views/cubits/auth.cubit.dart @@ -0,0 +1,119 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'dart:async'; + +import 'package:deps/design/design.dart'; +import 'package:deps/features/features.dart'; +import 'package:deps/infrastructure/infrastructure.dart'; +import 'package:deps/packages/flutter_bloc.dart'; +import 'package:deps/packages/fpdart.dart'; +import 'package:deps/packages/freezed_annotation.dart'; +import 'package:deps/packages/injectable.dart'; + +part 'auth.cubit.freezed.dart'; +part 'states/auth.state.dart'; + +/// `AuthCubit` manages the authentication flow of the app, handling login, logout, +/// and changes to the user's authentication state. It listens for authentication status +/// changes and communicates with services like `AuthService` and `IApiClient` to manage tokens. +@lazySingleton +class AuthCubit extends Cubit { + AuthCubit(this._client, this._service) : super(AuthStatusEnum.initial) { + _authStatusListener = + _client.tokenStorage.authStatus.listen(_onAuthStatusChanged); + } + + /// Client responsible for network and token operations. + final IApiClient _client; + + /// Auth service responsible for handling login and logout requests. + final AuthService _service; + + /// Subscription to the authentication status stream. + late final StreamSubscription _authStatusListener; + + /// Listens for changes in the authentication status and updates the state accordingly. + void _onAuthStatusChanged(AuthStatusEnum event) { + if (state != event) { + switch (event) { + case AuthStatusEnum.authenticated: + emit(event); + $.get().init(); + + case AuthStatusEnum.unauthenticated: + emit(event); + $.get().deinit(); + + default: + } + } + } + + /// Handles user login by calling the `AuthService` and managing the token. + /// + /// Shows a loading overlay during the process and stores the token upon success. + /// If the login fails, it shows a failure alert. + Future login({ + required String username, + required String password, + }) async { + emit(AuthStatusEnum.loading); + await $.overlay.showLoading(); + + final Either response = await _service.login( + username: username, + password: password, + ); + + await response.fold( + $.toast.showAlert, + (TokenModel token) async { + await _client.tokenStorage.setToken(token); + }, + ); + } + + /// Logs the user out, either by making a backend call or just clearing local tokens. + /// + /// This method shows a loading overlay during the process and restores the previous state if the logout fails. + Future logout({bool useBackend = true}) async { + if (state != AuthStatusEnum.authenticated) { + return; + } + + final AuthStatusEnum previousState = state; + emit(AuthStatusEnum.loading); + await $.overlay.showLoading(); + + if (useBackend) { + final TokenModel? tokens = await _client.tokenStorage.token; + final Either response = + await _service.logout(tokens!.idToken); + + response.fold( + (Failure failure) { + $.toast.showAlert(failure); + emit(previousState); + }, + (_) { + $.overlay.popOverlay(); + _client.tokenStorage.clearToken(); + }, + ); + } else { + await $.overlay.popOverlay(); + await _client.tokenStorage.clearToken(); + } + } + + /// Closes the authentication status listener when the cubit is disposed. + @override + Future close() { + _authStatusListener.cancel(); + return super.close(); + } +} diff --git a/features/auth/lib/views/cubits/states/auth.state.dart b/features/auth/lib/views/cubits/states/auth.state.dart new file mode 100644 index 0000000..ee5d49a --- /dev/null +++ b/features/auth/lib/views/cubits/states/auth.state.dart @@ -0,0 +1,19 @@ +part of '../auth.cubit.dart'; + +/// `AuthState` represents the different states of the authentication process. +/// These states are sealed using the `freezed` package for immutability and pattern matching. +@freezed +sealed class AuthState with _$AuthState { + /// Represents the state when an authentication operation is in progress. + const factory AuthState.loading() = AuthStateLoading; + + /// Represents the state when an authentication operation has failed. + /// Contains a [Failure] object to describe the failure reason. + const factory AuthState.failed(Failure failure) = AuthStateFailed; + + /// Represents the state when the user is unauthenticated. + const factory AuthState.unauthenticated() = AuthStateUnauthenticated; + + /// Represents the state when the user is successfully authenticated. + const factory AuthState.authenticated() = AuthStateAuthenticated; +} diff --git a/features/auth/lib/views/forms/login.form.dart b/features/auth/lib/views/forms/login.form.dart new file mode 100644 index 0000000..9e15b67 --- /dev/null +++ b/features/auth/lib/views/forms/login.form.dart @@ -0,0 +1,33 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: avoid-dynamic + +import 'package:deps/packages/reactive_forms.dart'; + +/// The `LoginForm` class is responsible for managing the login form +/// which includes `username` and `password` fields. +/// This form uses the `Reactive Forms` package for handling state and validation. +abstract final class LoginForm { + /// A reactive form group that contains two fields: `username` and `password`. + /// - **`username`**: A `FormControl` that is initialized with a default value of `test`. + /// - Validators: + /// - `RequiredValidator()`: Ensures the username field is not left empty. + /// + /// - **`password`**: A `FormControl` that is initialized with a default value of `test`. + /// - Validators: + /// - `RequiredValidator()`: Ensures the password field is not left empty. + static final FormGroup form = FormGroup(>{ + 'username': FormControl( + value: 'test', + validators: const >[RequiredValidator()], + ), + 'password': FormControl( + value: 'test', + validators: const >[RequiredValidator()], + ), + }); +} diff --git a/features/auth/pubspec.yaml b/features/auth/pubspec.yaml new file mode 100644 index 0000000..0cd10eb --- /dev/null +++ b/features/auth/pubspec.yaml @@ -0,0 +1,25 @@ +name: feature_auth +description: The auth feature of the flutter advanced boilerplate. +publish_to: none +version: 0.0.1 + +environment: + sdk: ">=3.5.3 <4.0.0" + +dependencies: + deps: + path: ../../deps + flutter: + sdk: flutter + json_annotation: ^4.9.0 + +dev_dependencies: + auto_route_generator: ^9.0.0 + build_runner: ^2.4.13 + dart_code_metrics_presets: ^2.16.0 + flutter_test: + sdk: flutter + freezed: ^2.5.7 + injectable_generator: ^2.6.2 + json_serializable: ^6.8.0 + slang_build_runner: ^3.31.0 diff --git a/features/auth/pubspec_overrides.yaml b/features/auth/pubspec_overrides.yaml new file mode 100644 index 0000000..dea562d --- /dev/null +++ b/features/auth/pubspec_overrides.yaml @@ -0,0 +1,12 @@ +# melos_managed_dependency_overrides: deps,design,feature_core,feature_user,infrastructure +dependency_overrides: + deps: + path: ../../deps + design: + path: ../../design + feature_core: + path: ../_core + feature_user: + path: ../user + infrastructure: + path: ../../infrastructure diff --git a/features/user/build.yaml b/features/user/build.yaml new file mode 100644 index 0000000..2f1daf3 --- /dev/null +++ b/features/user/build.yaml @@ -0,0 +1,113 @@ +# Copyright 2024 Thomas. All rights reserved. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file. +# + +# This `build.yaml` file is used to configure various code generation tools such as Auto Route, +# Freezed, Json Serializable, Injectable, and Slang for a Flutter project. + + +targets: + $default: + builders: + # Auto Route Generators + # + # These settings configure the code generation for the Auto Route package. Auto Route + # helps in generating the routing infrastructure for a Flutter app. + # + # `auto_router_generator`: Generates the primary routing files, caching build options + # to improve build performance. + + # This section handles the main AutoRoute generator for router.dart files. + auto_route_generator:auto_router_generator: + options: + enable_cached_builds: true + generate_for: + include: + - lib/_core/_router/router.dart + + # Auto Route Generators + # + # These settings configure the code generation for the Auto Route package. Auto Route + # helps in generating the routing infrastructure for a Flutter app. + # + # `auto_router_generator`: Generates the primary routing files, caching build options + # to improve build performance. + + # This section handles the main AutoRoute generator for router.dart files. + auto_route_generator:auto_route_generator: + options: + enable_cached_builds: true + generate_for: + include: + - lib/views/*.page.dart + - lib/views/**/*.page.dart + + # Freezed Generators + # + # Freezed is used to generate immutable data classes, unions, and sealed classes. This section configures how + # Freezed generates code, particularly controlling the behavior of `when` and `map` methods. + + # This section controls Freezed code generation. + freezed: + options: + map: false + when: + when: false + maybe_when: false + when_or_null: true + generate_for: + include: + - lib/_core/_di/*.dart + - lib/models/*.model.dart + - lib/views/cubits/*.cubit.dart + + # Json Serializable Generators + # + # Json Serializable is used to generate serialization and deserialization logic for models. + # It can automatically create methods to convert objects to and from JSON format. + + # This section controls JSON serialization and deserialization code generation. + json_serializable: + options: + create_factory: true + create_to_json: true + explicit_to_json: true + field_rename: none + include_if_null: true + generate_for: + include: + - lib/models/*.model.dart + - lib/views/cubits/*.cubit.dart + + # Injectable Generators + # + # Injectable is used to generate dependency injection code for the app. It automatically generates + # registration code for classes annotated with `@Injectable` or `@LazySingleton`. + + # This section controls the Injectable dependency injection code generation. + injectable_generator:injectable_builder: + generate_for: + include: + - lib/_core/_di/*.dart + - lib/services/*.service.dart + - lib/views/cubits/*.cubit.dart + + # Slang Generators + # + # Slang is used for internationalization (i18n) by generating translation classes. This configuration + # controls how translations are handled and generated. + + # This section configures the Slang package for generating i18n (internationalization) translation classes. + slang_build_runner: + options: + locale_handling: false + translation_class_visibility: public + fallback_strategy: base_locale + input_directory: lib/_core/_i18n + output_directory: lib/_core/_i18n + output_file_name: translations.g.dart + key_case: camel + key_map_case: camel + param_case: camel + flat_map: false \ No newline at end of file diff --git a/features/user/lib/_core/_di/_di.dart b/features/user/lib/_core/_di/_di.dart new file mode 100644 index 0000000..60ac9c4 --- /dev/null +++ b/features/user/lib/_core/_di/_di.dart @@ -0,0 +1,29 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/get_it.dart'; +import 'package:deps/packages/injectable.dart'; +import 'package:feature_user/_core/_di/_di.config.dart'; + +/// Initializes the dependency injection (DI) system for the user feature. +/// +/// This function is responsible for injecting dependencies needed for the user, +/// using the `GetIt` package for service location and the `Injectable` package for +/// automatic dependency injection setup. +/// +/// The DI setup is controlled by the [env] parameter, allowing different configurations +/// for different environments (e.g., development, production). +/// +/// The injected dependencies are defined in the `_di.config.dart` file, which is generated +/// by the `Injectable` package based on annotations in the project. +/// +/// - [di]: An instance of `GetIt` used for dependency injection. +/// - [env]: A string specifying the environment (e.g., 'dev', 'prod') to determine the setup. +@InjectableInit() +void injectUserFeature(GetIt di, String env) { + // Initialize the dependencies for the specified environment. + di.init(environment: env); +} diff --git a/features/user/lib/_core/_i18n/i18n_mixin.dart b/features/user/lib/_core/_i18n/i18n_mixin.dart new file mode 100644 index 0000000..0e33e5e --- /dev/null +++ b/features/user/lib/_core/_i18n/i18n_mixin.dart @@ -0,0 +1,28 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/flutter_bloc.dart'; +import 'package:feature_user/_core/_i18n/translations.g.dart'; +import 'package:flutter/material.dart'; + +/// Extension on `Cubit` to easily access the user's i18n (internationalization) functionality. +/// +/// This extension provides a convenient way to retrieve the appropriate `Translations` object for the current locale +/// managed by a `Cubit`. It uses `AppLocaleUtils.parse` to map the `Locale` to a specific `AppLocale` and +/// then builds the `Translations` object, which contains localized strings for the app. +/// +/// Example usage: +/// ```dart +/// final translations = $.tr.user; +/// ``` +extension UserI18nCubitLocaleExt on Cubit { + /// Retrieves the `Translations` object for the current locale. + /// + /// This method maps the `Locale` stored in the cubit's state to an `AppLocale` using `AppLocaleUtils.parse`. + /// It then builds and returns the corresponding `Translations` object, which provides localized strings + /// for use in the application. + Translations get user => AppLocaleUtils.parse(state.toString()).build(); +} diff --git a/features/user/lib/_core/_i18n/strings_en.i18n.json b/features/user/lib/_core/_i18n/strings_en.i18n.json new file mode 100644 index 0000000..a9d370a --- /dev/null +++ b/features/user/lib/_core/_i18n/strings_en.i18n.json @@ -0,0 +1,3 @@ +{ + "test": "Test" +} \ No newline at end of file diff --git a/features/user/lib/_core/_i18n/strings_tr.i18n.json b/features/user/lib/_core/_i18n/strings_tr.i18n.json new file mode 100644 index 0000000..a9d370a --- /dev/null +++ b/features/user/lib/_core/_i18n/strings_tr.i18n.json @@ -0,0 +1,3 @@ +{ + "test": "Test" +} \ No newline at end of file diff --git a/features/user/lib/_core/_router/router.dart b/features/user/lib/_core/_router/router.dart new file mode 100644 index 0000000..ce48177 --- /dev/null +++ b/features/user/lib/_core/_router/router.dart @@ -0,0 +1,34 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: prefer-match-file-name + +import 'package:deps/packages/auto_route.dart'; + +/// `UserFeatureRouter` is responsible for defining and configuring +/// the routes related to the User feature module. +/// +/// This class extends `RootStackRouter` and utilizes the `@AutoRouterConfig()` +/// annotation to manage routes automatically, enabling code generation for routing. +/// +/// - **Example usage**: +/// Define user-related routes inside this router, such as user profile, settings, etc. +/// +/// - **Returns**: A list of routes to be used by the `AutoRoute` package. +/// +/// Example: +/// ```dart +/// @override +/// List get routes => [ +/// AutoRoute(page: UserProfilePage), +/// AutoRoute(page: UserSettingsPage), +/// ]; +/// ``` +@AutoRouterConfig() +class UserFeatureRouter extends RootStackRouter { + @override + List get routes => []; +} diff --git a/features/user/lib/models/user.model.dart b/features/user/lib/models/user.model.dart new file mode 100644 index 0000000..9117538 --- /dev/null +++ b/features/user/lib/models/user.model.dart @@ -0,0 +1,78 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: prefer-single-declaration-per-file + +import 'package:deps/packages/freezed_annotation.dart'; + +part 'user.model.freezed.dart'; +part 'user.model.g.dart'; + +/// `UserModel` represents the user entity with fields for ID, first name, +/// optional last name, phone number, email, and username. +/// It is built using the `freezed` package for immutability and offers +/// JSON serialization capabilities. +/// +/// ### Fields: +/// - `id` (required): The unique identifier for the user. +/// - `firstName` (required): The user's first name. +/// - `lastName` (optional): The user's last name. +/// - `phoneNumber` (optional): The user's phone number. +/// - `email` (optional): The user's email address. +/// - `username` (optional): The user's username. +/// +/// ### Factory Constructors: +/// - `UserModel.fromJson(Map json)`: Deserializes the user data from JSON. +/// - `UserModel.empty()`: Creates an empty user model, where only the `id` and `firstName` fields are initialized with default empty values. +/// +/// ### Additional Features: +/// - **isNotEmpty**: A utility getter that checks if the model is not empty. +/// +/// ### Example: +/// ```dart +/// final user = UserModel( +/// id: '123', +/// firstName: 'John', +/// lastName: 'Doe', +/// email: 'john.doe@example.com', +/// ); +/// +/// final json = user.toJson(); // Serialize to JSON +/// final newUser = UserModel.fromJson(json); // Deserialize from JSON +/// +/// if (user.isNotEmpty) { +/// print('User is valid'); +/// } +/// ``` + +@freezed +class UserModel with _$UserModel { + /// The default factory constructor for `UserModel`. + /// `id` and `firstName` are required fields, while others are optional. + const factory UserModel({ + required String id, + required String firstName, + String? lastName, + String? phoneNumber, + String? email, + String? username, + }) = _UserModel; + + /// Private constructor used by Freezed to implement immutability. + const UserModel._(); + + /// Factory method for deserializing `UserModel` from JSON. + factory UserModel.fromJson(Map json) => + _$UserModelFromJson(json); + + /// Provides an empty/default instance of `UserModel` with default values. + /// This is useful when you need a default or uninitialized user model. + factory UserModel.empty() => const UserModel(id: '', firstName: ''); + + /// A utility getter that checks if the user model is not empty. + /// Compares the instance with the result of `UserModel.empty()`. + bool get isNotEmpty => this != UserModel.empty(); +} diff --git a/features/user/lib/models/user_settings.model.dart b/features/user/lib/models/user_settings.model.dart new file mode 100644 index 0000000..f721712 --- /dev/null +++ b/features/user/lib/models/user_settings.model.dart @@ -0,0 +1,62 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/freezed_annotation.dart'; + +part 'user_settings.model.freezed.dart'; +part 'user_settings.model.g.dart'; + +/// The `UserSettingsModel` class is a data model representing user-specific +/// settings, including language preferences, locale, and timezone. It utilizes +/// the `freezed` package for immutable data structures and provides built-in +/// support for JSON serialization. +/// +/// ### Fields: +/// - `languageCode` (required): The language code for the user's preferred language, e.g., 'en', 'tr'. +/// - `locale` (required): The user's locale, e.g., 'en-US', 'tr-TR'. +/// - `timeZone` (required): The user's time zone in the ISO format, e.g., '+03:00'. +/// +/// ### Features: +/// - **Immutability**: The `freezed` package ensures that instances of this class are immutable. +/// - **JSON Serialization**: Provides factory methods for JSON serialization/deserialization. +/// +/// ### Example: +/// ```dart +/// final settings = UserSettingsModel( +/// languageCode: 'en', +/// locale: 'en-US', +/// timeZone: '-05:00', +/// ); +/// +/// final json = settings.toJson(); // Serialize to JSON +/// final newSettings = UserSettingsModel.fromJson(json); // Deserialize from JSON +/// ``` +/// +/// ### Factory Constructors: +/// - `UserSettingsModel.fromJson(Map json)`: Deserializes from JSON. +/// - `UserSettingsModel.empty()`: Provides a default instance with predefined settings. +@freezed +class UserSettingsModel with _$UserSettingsModel { + /// The default factory constructor for `UserSettingsModel`. + /// All fields are required and must be provided. + const factory UserSettingsModel({ + required String languageCode, + required String locale, + required String timeZone, + }) = _UserSettingsModel; + + /// Factory method for deserializing `UserSettingsModel` from JSON. + factory UserSettingsModel.fromJson(Map json) => + _$UserSettingsModelFromJson(json); + + /// Provides an empty/default instance of `UserSettingsModel` with predefined values. + /// Default language is Turkish ('tr'), locale is 'tr-TR', and time zone is '+03:00'. + factory UserSettingsModel.empty() => const UserSettingsModel( + languageCode: 'tr', + locale: 'tr-TR', + timeZone: '+03:00', + ); +} diff --git a/features/user/lib/services/user.service.dart b/features/user/lib/services/user.service.dart new file mode 100644 index 0000000..086b7bc --- /dev/null +++ b/features/user/lib/services/user.service.dart @@ -0,0 +1,66 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: always_specify_types + +import 'package:deps/features/features.dart'; +import 'package:deps/infrastructure/infrastructure.dart'; +import 'package:deps/packages/fpdart.dart'; +import 'package:deps/packages/injectable.dart'; + +/// A service class for handling user-related operations such as fetching +/// user details from the backend. +/// +/// The class uses the `IApiClient` to make API calls and returns the result +/// wrapped in an `AsyncEither`, where it can either return a successful +/// `UserModel` or a `Failure`. +/// +/// ### Example: +/// ```dart +/// final response = await _service.getUserDetails(); +/// +/// response.fold( +/// (failure) => print('Failed to fetch user details: $failure'), +/// (user) => print('User details: $user'), +/// ); +/// ``` + +@lazySingleton +class UserService { + /// Constructor for `UserService` which requires an instance of `IApiClient`. + const UserService(this._client); + + /// The API client used for making requests. + final IApiClient _client; + + /// Fetches user details from the `/details` endpoint. + /// + /// The method returns an `AsyncEither` which will either return a `Failure` + /// in case of an error, or a `UserModel` if the request is successful. + /// + /// ### Example usage: + /// ```dart + /// final Either response = await _service.getUserDetails(); + /// ``` + /// + /// The API call uses a GET request to the `/details` endpoint, and the + /// response is expected to be deserialized into a `UserModel`. + AsyncEither getUserDetails() async { + // Makes an API call to fetch user details and map the result to a UserModel. + final Either response = + await _client.invoke( + '/details', + RequestTypeEnum.get, + fromJson: UserModel.fromJson, + ); + + // Processes the response, either returning a Failure or a successful UserModel. + return response.fold( + Left.new, + (UserModel user) async => Right(user), + ); + } +} diff --git a/features/user/lib/user.dart b/features/user/lib/user.dart new file mode 100644 index 0000000..06931b2 --- /dev/null +++ b/features/user/lib/user.dart @@ -0,0 +1,3 @@ +export '_core/_router/router.dart'; +export 'models/user.model.dart'; +export 'views/cubits/user.cubit.dart'; diff --git a/features/user/lib/views/cubits/states/user.state.dart b/features/user/lib/views/cubits/states/user.state.dart new file mode 100644 index 0000000..b1defc7 --- /dev/null +++ b/features/user/lib/views/cubits/states/user.state.dart @@ -0,0 +1,58 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +part of '../user.cubit.dart'; + +/// Represents the various states of the user's authentication and session status. +/// +/// - `initial`: The initial state before any user data is loaded. +/// - `loading`: The state when the user data is being loaded. +/// - `unauthenticated`: Indicates the user is not authenticated. +/// - `fetchingUserDetails`: The state where user details are being retrieved. +/// - `authenticated`: The user is authenticated and ready to use the application. +enum UserStateStatus { + initial, + loading, + unauthenticated, + fetchingUserDetails, + // TODO: Other additional steps can be added here. + authenticated, +} + +/// Represents the state of the user, containing the status of the authentication, +/// the user details, any failure that occurred, and whether the process failed. +/// +/// This class is sealed and generated using `freezed` to provide immutability and pattern matching capabilities. +@freezed +sealed class UserState with _$UserState { + /// Constructor for the `UserState` which includes: + /// - [status]: Represents the current status of the user session. + /// - [user]: Holds the details of the user. + /// - [isFailed]: A flag indicating whether the process has failed. + /// - [failure]: Contains the failure information in case of an error. + const factory UserState({ + required UserStateStatus status, + required UserModel user, + required bool isFailed, + required Failure failure, + }) = _UserState; + + /// Deserializes a `UserState` object from a JSON map. + factory UserState.fromJson(Map json) => + _$UserStateFromJson(json); + + /// Creates the initial state for the `UserState`, where: + /// - [status] is set to `UserStateStatus.initial`. + /// - [user] is initialized as an empty user. + /// - [isFailed] is set to `false`. + /// - [failure] is set to an empty `Failure`. + factory UserState.initial() => UserState( + status: UserStateStatus.initial, + user: UserModel.empty(), + isFailed: false, + failure: Failure.empty(), + ); +} diff --git a/features/user/lib/views/cubits/user.cubit.dart b/features/user/lib/views/cubits/user.cubit.dart new file mode 100644 index 0000000..24fde59 --- /dev/null +++ b/features/user/lib/views/cubits/user.cubit.dart @@ -0,0 +1,115 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: always_specify_types + +import 'package:deps/design/design.dart'; +import 'package:deps/features/features.dart'; +import 'package:deps/infrastructure/infrastructure.dart'; +import 'package:deps/packages/fpdart.dart'; +import 'package:deps/packages/freezed_annotation.dart'; +import 'package:deps/packages/hydrated_bloc.dart'; +import 'package:deps/packages/injectable.dart'; +import 'package:feature_user/services/user.service.dart'; +import 'package:flutter/material.dart'; + +part 'states/user.state.dart'; +part 'user.cubit.freezed.dart'; +part 'user.cubit.g.dart'; + +/// The `UserCubit` class is responsible for managing the state related to the user within the application. +/// It uses `HydratedCubit` to persist user state between app sessions and interacts with the `UserService` +/// to fetch user data when necessary. +/// +/// This cubit holds information about the authenticated user and manages their navigation tabs. +/// It also handles initialization and deinitialization of the user's session. +@lazySingleton +class UserCubit extends HydratedCubit { + /// Constructor to initialize the `UserCubit` with the required `UserService`. + UserCubit(this._service) : super(UserState.initial()); + + /// The service used for fetching user-related data. + final UserService _service; + + /// Restores the user's state from a JSON object. + /// + /// If an error occurs during deserialization, the user is logged out and the initial state is restored. + @override + UserState fromJson(Map json) { + try { + return UserState.fromJson(json); + } catch (e) { + $.get().logout(useBackend: false); + return UserState.initial(); + } + } + + /// Serializes the current user state to a JSON object, omitting any failures. + @override + Map toJson(UserState state) { + return state.copyWith(failure: Failure.empty()).toJson(); + } + + /// Retrieves the navigation tabs available to the authenticated user. + /// + /// This method can be customized to filter out certain tabs based on the user's permissions. + List get getAuthenticatedNavigationTabs { + // TODO: Users authorized pages can be filtered here. + final List tabs = [ + ...NavigationTabsEnum.values + ]; + return tabs; + } + + /// Initializes the user session by showing a loading overlay and fetching user details. + /// + /// This method is typically called after the user has been authenticated. + Future init() async { + await $.overlay.showLoading(); + emit(state.copyWith(status: UserStateStatus.loading)); + + // TODO: This section has been added as an example in case there is additional information that needs to be pulled after the user is authorized. Otherwise, if the user information is fetched during the authorization phase, only saving the user to the state is enough and the routing can be completed without the need for an additional step. + getUserDetails().ignore(); + } + + /// Deinitializes the user session and redirects to the authentication page. + Future deinit() async { + emit(UserState.initial()); + await $.navigator.replace(const AuthRoute()); + } + + /// Fetches the authenticated user's details and updates the cubit's state. + /// + /// If an error occurs, it shows a dialog with the failure message. Upon success, it navigates to the dashboard. + Future getUserDetails() async { + emit(state.copyWith(status: UserStateStatus.fetchingUserDetails)); + + // Mocking the response for demonstration. Replace with actual service call. + const Either response = Right( + UserModel( + id: '0', + username: 'test', + firstName: 'Fikret', + ), + ); // await _service.getUserDetails(); + + await response.fold( + (Failure failure) { + // Show dialog with the failure message if fetching user details fails. + $.dialog.showDialog( + Dialog( + insetPadding: $.paddings.xl.all, child: failure.message.text()), + ); + emit(state.copyWith(isFailed: true, failure: failure)); + }, + (UserModel user) async { + // Upon successful fetching of user details, navigate to the dashboard. + emit(state.copyWith(status: UserStateStatus.authenticated, user: user)); + await $.navigator.replace(const DashboardRouter()); + }, + ); + } +} diff --git a/features/user/pubspec.yaml b/features/user/pubspec.yaml new file mode 100644 index 0000000..262da41 --- /dev/null +++ b/features/user/pubspec.yaml @@ -0,0 +1,25 @@ +name: feature_user +description: The user feature of the flutter advanced boilerplate. +publish_to: none +version: 0.0.1 + +environment: + sdk: ">=3.5.3 <4.0.0" + +dependencies: + deps: + path: ../../deps + flutter: + sdk: flutter + json_annotation: ^4.9.0 + +dev_dependencies: + auto_route_generator: ^9.0.0 + build_runner: ^2.4.13 + dart_code_metrics_presets: ^2.16.0 + flutter_test: + sdk: flutter + freezed: ^2.5.7 + injectable_generator: ^2.6.2 + json_serializable: ^6.8.0 + slang_build_runner: ^3.31.0 diff --git a/features/user/pubspec_overrides.yaml b/features/user/pubspec_overrides.yaml new file mode 100644 index 0000000..27dc84b --- /dev/null +++ b/features/user/pubspec_overrides.yaml @@ -0,0 +1,12 @@ +# melos_managed_dependency_overrides: deps,design,feature_auth,feature_core,infrastructure +dependency_overrides: + deps: + path: ../../deps + design: + path: ../../design + feature_auth: + path: ../auth + feature_core: + path: ../_core + infrastructure: + path: ../../infrastructure diff --git a/infrastructure/build.yaml b/infrastructure/build.yaml new file mode 100644 index 0000000..88f2345 --- /dev/null +++ b/infrastructure/build.yaml @@ -0,0 +1,113 @@ +# Copyright 2024 Thomas. All rights reserved. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file. +# + +# This `build.yaml` file is used to configure various code generation tools such as Auto Route, +# Freezed, Json Serializable, Injectable, and Slang for a Flutter project. + +targets: + $default: + builders: + # Auto Route Generators + # + # These settings configure the code generation for the Auto Route package. Auto Route + # helps in generating the routing infrastructure for a Flutter app. + # + # `auto_router_generator`: Generates the primary routing files, caching build options + # to improve build performance. + # + # `auto_route_generator`: Generates route declarations based on the files included + # in the specified paths. + + # This section handles the main AutoRoute generator for the router.dart file. + auto_route_generator:auto_router_generator: + options: + enable_cached_builds: true + generate_for: + include: + - lib/_core/_router/router.dart + + # This section handles AutoRoute generation for all .route.dart files and wrapper files. + auto_route_generator:auto_route_generator: + options: + enable_cached_builds: true + generate_for: + include: + - lib/presentation/_core/**/*.route.dart + - lib/presentation/wrappers/*.dart + + # Freezed Generators + # + # Freezed is used to generate immutable data classes, unions, and sealed classes. This section configures how + # Freezed generates code, particularly controlling the behavior of `when` and `map` methods. + + # This section controls Freezed code generation. + freezed: + options: + map: false + when: + when: false + maybe_when: false + when_or_null: true + generate_for: + include: + - lib/networking/models/*.model.dart + - lib/presentation/cubits/*.cubit.dart + - lib/presentation/models/*.model.dart + + # Json Serializable Generators + # + # Json Serializable is used to generate serialization and deserialization logic for models. + # It can automatically create methods to convert objects to and from JSON format. + + # This section controls JSON serialization and deserialization code generation. + json_serializable: + options: + create_factory: true + create_to_json: true + explicit_to_json: true + field_rename: none + include_if_null: true + generate_for: + include: + - lib/analytics/failure/failure.dart + - lib/flavors/*.env.dart + - lib/networking/models/*.model.dart + - lib/presentation/cubits/*.cubit.dart + - lib/presentation/models/*.model.dart + + # Injectable Generators + # + # Injectable is used to generate dependency injection code for the app. It automatically generates + # registration code for classes annotated with `@Injectable` or `@LazySingleton`. + + # This section controls the Injectable dependency injection code generation. + injectable_generator:injectable_builder: + generate_for: + include: + - lib/_core/_di/*.dart + - lib/analytics/**/*.dart + - lib/flavors/*.dart + - lib/networking/**/*.dart + - lib/storage/**/*.dart + - lib/translations/*.dart + + # Slang Generators + # + # Slang is used for internationalization (i18n) by generating translation classes. This configuration + # controls how translations are handled and generated. + + # This section configures the Slang package for generating i18n (internationalization) translation classes. + slang_build_runner: + options: + locale_handling: false + translation_class_visibility: public + fallback_strategy: base_locale + input_directory: lib/_core/_i18n + output_directory: lib/_core/_i18n + output_file_name: translations.g.dart + key_case: camel + key_map_case: camel + param_case: camel + flat_map: false \ No newline at end of file diff --git a/infrastructure/devtools_options.yaml b/infrastructure/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/infrastructure/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/infrastructure/lib/_core/_di/_di.dart b/infrastructure/lib/_core/_di/_di.dart new file mode 100644 index 0000000..968b742 --- /dev/null +++ b/infrastructure/lib/_core/_di/_di.dart @@ -0,0 +1,38 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/dio.dart'; +import 'package:deps/packages/flutter_secure_storage.dart'; +import 'package:deps/packages/get_it.dart'; +import 'package:deps/packages/injectable.dart'; +import 'package:deps/packages/internet_connection_checker_plus.dart'; +import 'package:deps/packages/package_info_plus.dart'; +import 'package:deps/packages/talker_flutter.dart'; +import 'package:infrastructure/_core/_di/_di.config.dart'; +import 'package:infrastructure/analytics/reporters/talker/formatter/fancy_talker_log_formatter.dart'; + +part '_modules.dart'; + +/// Initializes the dependency injection (DI) system for the infrastructure layer. +/// +/// This function is responsible for injecting dependencies needed for the infrastructure, +/// using the `GetIt` package for service location and the `Injectable` package for +/// automatic dependency injection setup. +/// +/// The DI setup is controlled by the [env] parameter, allowing different configurations +/// for different environments (e.g., development, production). +/// +/// The injected dependencies are defined in the `_di.config.dart` file, which is generated +/// by the `Injectable` package based on annotations in the project. +/// +/// - [di]: An instance of `GetIt` used for dependency injection. +/// - [env]: A string specifying the environment (e.g., 'dev', 'prod') to determine the setup. +@InjectableInit() +Future injectInfrastructure( + {required GetIt di, required String env}) async { + // Initialize the dependencies for the specified environment. + await di.init(environment: env); +} diff --git a/infrastructure/lib/_core/_di/_modules.dart b/infrastructure/lib/_core/_di/_modules.dart new file mode 100644 index 0000000..04866d7 --- /dev/null +++ b/infrastructure/lib/_core/_di/_modules.dart @@ -0,0 +1,69 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +part of '_di.dart'; + +/// A module providing the `Talker` instance for logging in the application. +/// +/// This module uses `TalkerFlutter` for logging with custom settings such as +/// max line width and a custom formatter (`FancyTalkerLogFormatter`). +/// +/// The `@module` annotation is used by `Injectable` to automatically +/// register this dependency in the DI system. +@module +abstract class TalkerModule { + /// Provides an initialized `Talker` instance for logging purposes. + Talker get talker => TalkerFlutter.init( + logger: TalkerLogger( + settings: TalkerLoggerSettings(maxLineWidth: 100), + formatter: FancyTalkerLogFormatter(), + ), + ); +} + +/// A module providing the `Dio` instance for HTTP requests. +/// +/// This module registers `Dio` as a dependency, which can be injected +/// wherever HTTP requests are needed within the application. +@module +abstract class DioModule { + /// Provides an initialized `Dio` instance for making HTTP requests. + Dio get dio => Dio(); +} + +/// A module providing the `InternetConnection` instance for checking network status. +/// +/// This module registers `InternetConnection`, used for monitoring network +/// connectivity within the application. +@module +abstract class InternetConnectionModule { + /// Provides an initialized `InternetConnection` instance to check internet connectivity. + InternetConnection get internetConnection => InternetConnection(); +} + +/// A module providing the `FlutterSecureStorage` instance for secure key-value storage. +/// +/// This module registers `FlutterSecureStorage`, used for securely storing +/// sensitive information, such as tokens and credentials, with encryption. +@module +abstract class FlutterSecureStorageModule { + /// Provides an initialized `FlutterSecureStorage` instance with encrypted shared preferences. + FlutterSecureStorage get secureStorage => const FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + ); +} + +/// A module providing application information via `PackageInfo`. +/// +/// This module registers `PackageInfo`, which can be used to retrieve information +/// about the app, such as version, build number, etc. The `@preResolve` annotation +/// ensures that the `PackageInfo` is asynchronously fetched and ready before being injected. +@module +abstract class AppInformationModule { + /// Asynchronously retrieves app information from the platform. + @preResolve + Future get appInformation => PackageInfo.fromPlatform(); +} diff --git a/infrastructure/lib/_core/_i18n/infrastructure_i18n_cubit_locale.ext.dart b/infrastructure/lib/_core/_i18n/infrastructure_i18n_cubit_locale.ext.dart new file mode 100644 index 0000000..12d7ba9 --- /dev/null +++ b/infrastructure/lib/_core/_i18n/infrastructure_i18n_cubit_locale.ext.dart @@ -0,0 +1,29 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/flutter_bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:infrastructure/_core/_i18n/translations.g.dart'; + +/// Extension on `Cubit` to easily access the infrastructure's i18n (internationalization) functionality. +/// +/// This extension provides a convenient way to retrieve the appropriate `Translations` object for the current locale +/// managed by a `Cubit`. It uses `AppLocaleUtils.parse` to map the `Locale` to a specific `AppLocale` and +/// then builds the `Translations` object, which contains localized strings for the app. +/// +/// Example usage: +/// ```dart +/// final translations = $.tr.infrastructure; +/// ``` +extension InfrastructureI18nCubitLocaleExt on Cubit { + /// Retrieves the `Translations` object for the current locale. + /// + /// This method maps the `Locale` stored in the cubit's state to an `AppLocale` using `AppLocaleUtils.parse`. + /// It then builds and returns the corresponding `Translations` object, which provides localized strings + /// for use in the application. + Translations get infrastructure => + AppLocaleUtils.parse(state.toString()).build(); +} diff --git a/infrastructure/lib/_core/_i18n/strings_en.i18n.json b/infrastructure/lib/_core/_i18n/strings_en.i18n.json new file mode 100644 index 0000000..5f235a5 --- /dev/null +++ b/infrastructure/lib/_core/_i18n/strings_en.i18n.json @@ -0,0 +1,85 @@ +{ + "permissions": { + "dialog": { + "buttons": { + "cancel": "Cancel", + "ok": "OK", + "openSettings": "Open Settings", + "retry": "Retry", + "understood": "Understood" + }, + "denied": { + "description": "@:permissions.type is required to proceed. Please consider enabling it in your settings.", + "title": "@:permissions.type Permission Denied" + }, + "limited": { + "description": "You have granted limited @:permissions.type access. If you want to allow full access, please adjust in your settings.", + "title": "@:permissions.type Permission Limited" + }, + "permanentlyDenied": { + "description": "@:permissions.type is required but has been permanently denied. Please enable it from app settings.", + "title": "@:permissions.type Permission Permanently Denied" + }, + "provisional": { + "description": "The app is provisionally authorized. Some features might be limited.", + "title": "Provisional @:permissions.type Permission" + }, + "restricted": { + "description": "Access to @:permissions.type is restricted by system policies or parental controls.", + "title": "@:permissions.type Permission Restricted" + } + }, + "failures": { + "invalidPermissionType": "Invalid permission type: $type", + "unknownPermissionRequest": "An error occurred while requesting permission." + }, + "type(context=PermissionTypeEnum)": { + "accessMediaLocation": "Access Media Location", + "accessNotificationPolicy": "Access Notification Policy", + "activityRecognition": "Activity Recognition", + "appTrackingTransparency": "App Tracking Transparency", + "audio": "Audio", + "bluetooth": "Bluetooth", + "bluetoothAdvertise": "Bluetooth Advertise", + "bluetoothConnect": "Bluetooth Connect", + "bluetoothScan": "Bluetooth Scan", + "calendar": "Calendar", + "calendarFullAccess": "Calendar Full Access", + "calendarWriteOnly": "Calendar Write Only", + "camera": "Camera", + "contacts": "Contacts", + "criticalAlerts": "Critical Alerts", + "ignoreBatteryOptimizations": "Ignore Battery Optimizations", + "location": "Location", + "locationAlways": "Location Always", + "locationWhenInUse": "Location When In Use", + "manageExternalStorage": "Manage External Storage", + "mediaLibrary": "Media Library", + "microphone": "Microphone", + "nearbyWifiDevices": "Nearby Wi-Fi Devices", + "notification": "Notification", + "phone": "Phone", + "photos": "Photos", + "photosAddOnly": "Photos Add Only", + "reminders": "Reminders", + "requestInstallPackages": "Request Install Packages", + "scheduleExactAlarm": "Schedule Exact Alarm", + "sensors": "Sensors", + "sensorsAlways": "Sensors Always", + "sms": "SMS", + "speech": "Speech", + "storage": "Storage", + "systemAlertWindow": "System Alert Window", + "unknown": "Unknown", + "videos": "Videos" + } + }, + "presentation": { + "validations": { + "email": "$field should be a valid email address.", + "maxLength": "$field cannot be more than $count characters.", + "minLength": "$field cannot be less than $count characters.", + "required": "$field is required." + } + } +} \ No newline at end of file diff --git a/infrastructure/lib/_core/_i18n/strings_ro.i18n.json b/infrastructure/lib/_core/_i18n/strings_ro.i18n.json new file mode 100644 index 0000000..fc63511 --- /dev/null +++ b/infrastructure/lib/_core/_i18n/strings_ro.i18n.json @@ -0,0 +1,85 @@ +{ + "permissions": { + "dialog": { + "buttons": { + "cancel": "Anulează", + "ok": "OK", + "openSettings": "Deschide Setările", + "retry": "Reîncearcă", + "understood": "Am înțeles" + }, + "denied": { + "description": "@:permissions.type este necesar pentru a continua. Vă rugăm să luați în considerare activarea acestuia în setări.", + "title": "Permisiune @:permissions.type Refuzată" + }, + "limited": { + "description": "Ați acordat acces limitat la @:permissions.type. Dacă doriți să permiteți accesul complet, vă rugăm să ajustați setările.", + "title": "Permisiune @:permissions.type Limitată" + }, + "permanentlyDenied": { + "description": "@:permissions.type este necesar, dar a fost refuzat permanent. Vă rugăm să activați permisiunea din setările aplicației.", + "title": "Permisiune @:permissions.type Refuzată Permanent" + }, + "provisional": { + "description": "Aplicația este autorizată provizoriu. Unele funcții ar putea fi limitate.", + "title": "Permisiune @:permissions.type Provizorie" + }, + "restricted": { + "description": "Accesul la @:permissions.type este restricționat de politicile sistemului sau de controlul parental.", + "title": "Permisiune @:permissions.type Restricționată" + } + }, + "failures": { + "invalidPermissionType": "Tip de permisiune invalid: $type", + "unknownPermissionRequest": "A apărut o eroare în timpul solicitării permisiunii." + }, + "type(context=PermissionTypeEnum)": { + "accessMediaLocation": "Accesare Locație Media", + "accessNotificationPolicy": "Accesare Politică Notificări", + "activityRecognition": "Recunoașterea Activității", + "appTrackingTransparency": "Transparența Urmăririi Aplicației", + "audio": "Audio", + "bluetooth": "Bluetooth", + "bluetoothAdvertise": "Publicitate Bluetooth", + "bluetoothConnect": "Conectare Bluetooth", + "bluetoothScan": "Scanare Bluetooth", + "calendar": "Calendar", + "calendarFullAccess": "Acces Complet Calendar", + "calendarWriteOnly": "Doar Scriere în Calendar", + "camera": "Cameră", + "contacts": "Contacte", + "criticalAlerts": "Alerte Critice", + "ignoreBatteryOptimizations": "Ignorare Optimizări Baterie", + "location": "Locație", + "locationAlways": "Locație Întotdeauna", + "locationWhenInUse": "Locație Când Este Folosită", + "manageExternalStorage": "Gestionare Stocare Externă", + "mediaLibrary": "Bibliotecă Media", + "microphone": "Microfon", + "nearbyWifiDevices": "Dispozitive Wi-Fi Apropiate", + "notification": "Notificare", + "phone": "Telefon", + "photos": "Fotografii", + "photosAddOnly": "Adăugare Doar Fotografii", + "reminders": "Memento-uri", + "requestInstallPackages": "Solicitare Instalare Pachete", + "scheduleExactAlarm": "Programare Alarmă Exactă", + "sensors": "Senzori", + "sensorsAlways": "Senzori Întotdeauna", + "sms": "SMS", + "speech": "Vorbire", + "storage": "Stocare", + "systemAlertWindow": "Fereastră de Avertizare Sistem", + "unknown": "Necunoscut", + "videos": "Videoclipuri" + } + }, + "presentation": { + "validations": { + "email": "$field trebuie să fie o adresă de email validă.", + "maxLength": "$field nu poate avea mai mult de $count caractere.", + "minLength": "$field nu poate avea mai puțin de $count caractere.", + "required": "$field este obligatoriu." + } + } +} \ No newline at end of file diff --git a/infrastructure/lib/_core/_i18n/strings_tr.i18n.json b/infrastructure/lib/_core/_i18n/strings_tr.i18n.json new file mode 100644 index 0000000..a6edd97 --- /dev/null +++ b/infrastructure/lib/_core/_i18n/strings_tr.i18n.json @@ -0,0 +1,85 @@ +{ + "permissions": { + "dialog": { + "buttons": { + "cancel": "İptal", + "ok": "Tamam", + "openSettings": "Ayarları Aç", + "retry": "Yeniden Dene", + "understood": "Anladım" + }, + "denied": { + "description": "@:permissions.type izni ilerlemek için gereklidir. Lütfen uygulama ayarlarından etkinleştirin.", + "title": "@:permissions.type İzni Reddedilmiş" + }, + "limited": { + "description": "Sınırlı @:permissions.type erişimi sağladınız. Tam erişim izni vermek istiyorsanız, lütfen ayarlarınızı düzenleyin.", + "title": "@:permissions.type İzni Sınırlı" + }, + "permanentlyDenied": { + "description": "@:permissions.type izni gerekli ancak kalıcı olarak reddedilmiş. Lütfen uygulama ayarlarından etkinleştirin.", + "title": "@:permissions.type İzni Kalıcı Olarak Reddedilmiş" + }, + "provisional": { + "description": "Uygulama geçici olarak yetkilendirilmiş. Bazı özellikler sınırlı olabilir.", + "title": "Geçici @:permissions.type İzni" + }, + "restricted": { + "description": "@:permissions.type erişimi sistem politikaları veya ebeveyn kontrolleri tarafından kısıtlanmıştır.", + "title": "@:permissions.type İzni Kısıtlanmış" + } + }, + "failures": { + "invalidPermissionType": "Geçersiz izin türü: $type", + "unknownPermissionRequest": "İzin istenirken bir hata oluştu." + }, + "type(context=PermissionTypeEnum)": { + "accessMediaLocation": "Medya Konumuna Erişim", + "accessNotificationPolicy": "Bildirim Politikasına Erişim", + "activityRecognition": "Aktivite Tanıma", + "appTrackingTransparency": "Uygulama Takip Şeffaflığı", + "audio": "Ses", + "bluetooth": "Bluetooth", + "bluetoothAdvertise": "Bluetooth Reklamı", + "bluetoothConnect": "Bluetooth Bağlantısı", + "bluetoothScan": "Bluetooth Taraması", + "calendar": "Takvim", + "calendarFullAccess": "Takvime Tam Erişim", + "calendarWriteOnly": "Sadece Takvim Yaz", + "camera": "Kamera", + "contacts": "Kişiler", + "criticalAlerts": "Kritik Uyarılar", + "ignoreBatteryOptimizations": "Batarya Optimizasyonlarını Yoksay", + "location": "Konum", + "locationAlways": "Daima Konum", + "locationWhenInUse": "Kullanıldığında Konum", + "manageExternalStorage": "Harici Depolamayı Yönet", + "mediaLibrary": "Medya Kütüphanesi", + "microphone": "Mikrofon", + "nearbyWifiDevices": "Yakındaki Wi-Fi Cihazları", + "notification": "Bildirim", + "phone": "Telefon", + "photos": "Fotoğraflar", + "photosAddOnly": "Sadece Fotoğraf Ekle", + "reminders": "Hatırlatıcılar", + "requestInstallPackages": "Paket Yükleme İsteği", + "scheduleExactAlarm": "Kesin Alarmı Planla", + "sensors": "Sensörler", + "sensorsAlways": "Daima Sensörler", + "sms": "SMS", + "speech": "Konuşma", + "storage": "Depolama", + "systemAlertWindow": "Sistem Uyarı Penceresi", + "unknown": "Bilinmeyen", + "videos": "Videolar" + } + }, + "presentation": { + "validations": { + "email": "$field geçerli bir eposta adresi olmalıdır.", + "maxLength": "$field alanı $count karakterden uzun olamaz.", + "minLength": "$field alanı $count karakterden kısa olamaz.", + "required": "$field alanı zorunludur." + } + } +} \ No newline at end of file diff --git a/infrastructure/lib/_core/_router/router.dart b/infrastructure/lib/_core/_router/router.dart new file mode 100644 index 0000000..5f23a87 --- /dev/null +++ b/infrastructure/lib/_core/_router/router.dart @@ -0,0 +1,36 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/auto_route.dart'; +import 'package:infrastructure/_core/_router/router.gr.dart'; +import 'package:infrastructure/presentation/_core/dialog/dialog_builder.dart'; +import 'package:infrastructure/presentation/_core/modal/modal_builder.dart'; + +/// The main router configuration for the infrastructure layer of the application. +/// +/// This class extends `RootStackRouter` and defines custom routes for dialogs and modals. +/// It uses the `AutoRoute` package to manage navigation in a type-safe and declarative way. +@AutoRouterConfig() +class InfrastructureRouter extends RootStackRouter { + /// Defines the routes for the infrastructure, including custom dialog and modal routes. + /// + /// - The `DialogWrapperRoute` and `ModalWrapperRoute` are pages that use custom route builders. + /// - These routes are built using the custom route builders `DialogBuilder.route` and `ModalBuilder.route`, + /// respectively, allowing for a customized presentation of dialogs and modals. + @override + List get routes => [ + // Custom route for handling dialog presentations. + CustomRoute( + page: DialogWrapperRoute.page, + customRouteBuilder: DialogBuilder.route, + ), + // Custom route for handling modal presentations. + CustomRoute( + page: ModalWrapperRoute.page, + customRouteBuilder: ModalBuilder.route, + ), + ]; +} diff --git a/infrastructure/lib/_core/commons/converters/bloc_to_listenable.converter.dart b/infrastructure/lib/_core/commons/converters/bloc_to_listenable.converter.dart new file mode 100644 index 0000000..5bc0b77 --- /dev/null +++ b/infrastructure/lib/_core/commons/converters/bloc_to_listenable.converter.dart @@ -0,0 +1,52 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +/// A utility class that converts a [Stream] (typically from a BLoC) into a +/// [ChangeNotifier] so that it can be used with widgets that rely on +/// [Listenable] interfaces, such as [AnimatedBuilder] or [ValueListenableBuilder]. +/// +/// The class listens to a provided stream and notifies its listeners whenever +/// a new event is emitted by the stream. +/// +/// Example usage: +/// ```dart +/// final converter = BlocToListenableConverter(myBlocStream); +/// // Use converter in a widget that listens for changes. +/// ``` +class BlocToListenableConverter extends ChangeNotifier { + /// The stream that this converter listens to. + final Stream _stream; + + /// The subscription to the stream. This is used to listen for updates and + /// notify the listeners accordingly. + late final StreamSubscription _subscription; + + /// Creates an instance of [BlocToListenableConverter] that listens to the + /// provided [stream]. Each time a new event is emitted from the stream, it + /// calls [notifyListeners] to inform all the listeners. + BlocToListenableConverter(this._stream) { + _subscription = _stream.listen((T event) { + notifyListeners(); + }); + } + + /// Cancels the stream subscription and disposes of the resources when + /// the [BlocToListenableConverter] is no longer needed. + /// + /// Always call [dispose] to free up the stream subscription and prevent + /// memory leaks. + @override + void dispose() { + _subscription + .cancel(); // Cancel the stream subscription to avoid memory leaks. + super + .dispose(); // Call super to ensure any additional cleanup by ChangeNotifier. + } +} diff --git a/infrastructure/lib/_core/commons/enums/auth_status.enum.dart b/infrastructure/lib/_core/commons/enums/auth_status.enum.dart new file mode 100644 index 0000000..de5b4c0 --- /dev/null +++ b/infrastructure/lib/_core/commons/enums/auth_status.enum.dart @@ -0,0 +1,20 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +/// Enum representing the different authentication statuses of the application. +enum AuthStatusEnum { + /// User is successfully authenticated. + authenticated, + + /// Authentication process is currently loading. + loading, + + /// Initial state, before any authentication attempts. + initial, + + /// User is not authenticated. + unauthenticated, +} diff --git a/infrastructure/lib/_core/commons/enums/connectivity_status.enum.dart b/infrastructure/lib/_core/commons/enums/connectivity_status.enum.dart new file mode 100644 index 0000000..3f03793 --- /dev/null +++ b/infrastructure/lib/_core/commons/enums/connectivity_status.enum.dart @@ -0,0 +1,17 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +/// Enum representing the connectivity status of the application. +enum ConnectivityStatusEnum { + /// Device is connected to the network. + connected, + + /// Device is not connected to the network. + disconnected, + + /// Initial state before any connectivity checks. + initial, +} diff --git a/infrastructure/lib/_core/commons/enums/env.enum.dart b/infrastructure/lib/_core/commons/enums/env.enum.dart new file mode 100644 index 0000000..b4cb415 --- /dev/null +++ b/infrastructure/lib/_core/commons/enums/env.enum.dart @@ -0,0 +1,14 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +/// Enum representing the different environments of the application. +enum EnvEnum { + /// Development environment. + dev, + + /// Production environment. + prod, +} diff --git a/infrastructure/lib/_core/commons/enums/failure_tag.enum.dart b/infrastructure/lib/_core/commons/enums/failure_tag.enum.dart new file mode 100644 index 0000000..76f41f1 --- /dev/null +++ b/infrastructure/lib/_core/commons/enums/failure_tag.enum.dart @@ -0,0 +1,35 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +/// Enum representing different tags used to categorize failures in the application. +enum FailureTagEnum { + /// Represents an empty or undefined failure. + empty, + + /// Failures related to authentication issues. + authentication, + + /// Failures caused by network connectivity problems. + network, + + /// Failures related to operational issues. + operation, + + /// Failures due to insufficient permissions. + permission, + + /// Failures in the presentation layer (UI-related). + presentation, + + /// Failures related to service call issues. + service, + + /// Failures related to application state. + state, + + /// Uncaught or uncategorized failures. + uncaught, +} diff --git a/infrastructure/lib/_core/commons/enums/failure_type.enum.dart b/infrastructure/lib/_core/commons/enums/failure_type.enum.dart new file mode 100644 index 0000000..d6ebba1 --- /dev/null +++ b/infrastructure/lib/_core/commons/enums/failure_type.enum.dart @@ -0,0 +1,23 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +/// Enum representing the different types of failures that can occur in the application. +enum FailureTypeEnum { + /// Used to inform the user that the operation is successful. + constructive, + + /// Used to inform the user that the operation is unsuccessful. + destructive, + + /// Represents an empty or undefined failure type. + empty, + + /// General important errors. + error, + + /// Failures caused by exceptions. + exception, +} diff --git a/infrastructure/lib/_core/commons/enums/log_type.enum.dart b/infrastructure/lib/_core/commons/enums/log_type.enum.dart new file mode 100644 index 0000000..4b76a5a --- /dev/null +++ b/infrastructure/lib/_core/commons/enums/log_type.enum.dart @@ -0,0 +1,23 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +/// Enum representing different types of log messages. +enum LogTypeEnum { + /// Debugging information. + debug, + + /// Error messages. + error, + + /// Informational messages. + info, + + /// Verbose log messages, used for detailed debugging. + verbose, + + /// Warning messages. + warning, +} diff --git a/infrastructure/lib/_core/commons/enums/request_type.enum.dart b/infrastructure/lib/_core/commons/enums/request_type.enum.dart new file mode 100644 index 0000000..b37d57c --- /dev/null +++ b/infrastructure/lib/_core/commons/enums/request_type.enum.dart @@ -0,0 +1,23 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +/// Enum representing the different types of API calls. +enum RequestTypeEnum { + /// DELETE request. + delete, + + /// GET request. + get, + + /// PATCH request. + patch, + + /// POST request. + post, + + /// PUT request. + put, +} diff --git a/infrastructure/lib/_core/commons/extensions/color.ext.dart b/infrastructure/lib/_core/commons/extensions/color.ext.dart new file mode 100644 index 0000000..ba7cbc8 --- /dev/null +++ b/infrastructure/lib/_core/commons/extensions/color.ext.dart @@ -0,0 +1,35 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter/material.dart'; + +/// Extension on the `Color` class to add custom utility methods. +extension ColorExt on Color { + /// Converts a color to its ARGB representation with an alpha value of 0. + Color get toARGB => Color.fromARGB(0, red, green, blue); + + /// Converts a color to its hexadecimal string representation. + String get toHex => + "#${value.toRadixString(16).padLeft(8, '0').toUpperCase()}"; + + /// Blends the current color with a [blendColor] by a given percentage [percent]. + /// + /// [percent] should be between 0 and 100. + Color blend(Color blendColor, int percent) { + assert(percent >= 0 && percent <= 100, 'Percent must be between 0 and 100'); + + final double mixRatio = percent / 100; + final double inverseRatio = 1 - mixRatio; + + // Blend the colors based on the given percentage. + return Color.fromARGB( + 255, + (red * inverseRatio + blendColor.red * mixRatio).toInt(), + (green * inverseRatio + blendColor.green * mixRatio).toInt(), + (blue * inverseRatio + blendColor.blue * mixRatio).toInt(), + ); + } +} diff --git a/infrastructure/lib/_core/commons/extensions/context.ext.dart b/infrastructure/lib/_core/commons/extensions/context.ext.dart new file mode 100644 index 0000000..f8ce6a0 --- /dev/null +++ b/infrastructure/lib/_core/commons/extensions/context.ext.dart @@ -0,0 +1,77 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter/material.dart'; +import 'package:infrastructure/presentation/super_class.dart'; + +/// Extension on `BuildContext` to simplify access to various Flutter `ThemeData`, `MediaQuery`, and more. +extension ContextExt on BuildContext { + // Access various themes directly from the context. + ThemeData get theme => Theme.of(this); + TextTheme get textTheme => Theme.of(this).textTheme; + ColorScheme get colorScheme => Theme.of(this).colorScheme; + TextTheme get primaryTextTheme => Theme.of(this).primaryTextTheme; + BottomAppBarTheme get bottomAppBarTheme => Theme.of(this).bottomAppBarTheme; + BottomSheetThemeData get bottomSheetTheme => Theme.of(this).bottomSheetTheme; + AppBarTheme get appBarTheme => Theme.of(this).appBarTheme; + + // Access media query properties from the context. + Size get mediaQuerySize => MediaQuery.sizeOf(this); + double get height => mediaQuerySize.height; + double get width => mediaQuerySize.width; + EdgeInsets get mediaQueryPadding => MediaQuery.paddingOf(this); + EdgeInsets get mediaQueryViewPadding => MediaQuery.viewPaddingOf(this); + EdgeInsets get mediaQueryViewInsets => MediaQuery.viewInsetsOf(this); + Orientation get orientation => MediaQuery.orientationOf(this); + + // Determine orientation type. + bool get isLandscape => orientation == Orientation.landscape; + bool get isPortrait => orientation == Orientation.portrait; + + // Check if the 24-hour format should always be used. + bool get shouldAlwaysUse24HourFormat => + MediaQuery.alwaysUse24HourFormatOf(this); + + // Access device pixel ratio and platform brightness from the context. + double get devicePixelRatio => MediaQuery.devicePixelRatioOf(this); + Brightness get platformBrightness => MediaQuery.platformBrightnessOf(this); + + // Determine if the screen size matches phone or tablet dimensions. + double get mediaQueryShortestSide => mediaQuerySize.shortestSide; + bool get shouldShowNavbar => width > 800; + bool get isPhone => mediaQueryShortestSide < 600; + bool get isSmallTablet => mediaQueryShortestSide >= 600; + bool get isLargeTablet => mediaQueryShortestSide >= 720; + bool get isTablet => isSmallTablet || isLargeTablet; + + // Alternative MediaQuery shorthand accessors. + Size get mqSize => MediaQuery.sizeOf(this); + double get mqHeight => mqSize.height; + double get mqWidth => mqSize.width; + EdgeInsets get mqPadding => MediaQuery.paddingOf(this); + EdgeInsets get mqViewPadding => MediaQuery.viewPaddingOf(this); + EdgeInsets get mqViewInsets => MediaQuery.viewInsetsOf(this); + Orientation get mqOrientation => MediaQuery.orientationOf(this); + bool get mqAlwaysUse24HourFormat => MediaQuery.alwaysUse24HourFormatOf(this); + double get mqDevicePixelRatio => MediaQuery.devicePixelRatioOf(this); + Brightness get mqPlatformBrightness => MediaQuery.platformBrightnessOf(this); + + /// Returns a value based on the current device type (desktop, mobile, tablet). + T? responsiveValue({T? desktop, T? mobile, T? tablet}) { + double deviceWidth = mediaQuerySize.shortestSide; + + if ($.platform.isDesktop) { + deviceWidth = mediaQuerySize.width; + } + + // Check the device width and return the appropriate value for desktop, tablet, or mobile. + if (deviceWidth >= 1200 && desktop != null) { + return desktop; + } + + return deviceWidth >= 600 && tablet != null ? tablet : mobile; + } +} diff --git a/infrastructure/lib/_core/commons/extensions/date_time.ext.dart b/infrastructure/lib/_core/commons/extensions/date_time.ext.dart new file mode 100644 index 0000000..f15572c --- /dev/null +++ b/infrastructure/lib/_core/commons/extensions/date_time.ext.dart @@ -0,0 +1,11 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +/// Extension on `DateTime` for potential future utility methods. +extension DateTimeExt on DateTime { + // Currently, there are no additional methods for `DateTime`. + // Add custom `DateTime` utility methods here as needed. +} diff --git a/infrastructure/lib/_core/commons/extensions/double.ext.dart b/infrastructure/lib/_core/commons/extensions/double.ext.dart new file mode 100644 index 0000000..8346eec --- /dev/null +++ b/infrastructure/lib/_core/commons/extensions/double.ext.dart @@ -0,0 +1,97 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter/material.dart'; + +/// Extension on `double` to simplify the creation of `EdgeInsets` from a single value. +extension DoubleExt on double { + /// Creates an `EdgeInsets` with only the specified sides enabled. + /// + /// You can specify which sides to apply the padding to by setting the + /// boolean flags `b` (bottom), `l` (left), `r` (right), and `t` (top). + EdgeInsets only({ + bool b = false, + bool l = false, + bool r = false, + bool t = false, + }) => + EdgeInsets.fromLTRB( + l ? this : 0, // Left padding + t ? this : 0, // Top padding + r ? this : 0, // Right padding + b ? this : 0, // Bottom padding + ); + + /// Creates an `EdgeInsets` with only left padding. + EdgeInsets get left => EdgeInsets.only(left: this); + + /// Creates an `EdgeInsets` with only right padding. + EdgeInsets get right => EdgeInsets.only(right: this); + + /// Creates an `EdgeInsets` with only top padding. + EdgeInsets get top => EdgeInsets.only(top: this); + + /// Creates an `EdgeInsets` with only bottom padding. + EdgeInsets get bottom => EdgeInsets.only(bottom: this); + + /// Creates an `EdgeInsets` with equal padding on all sides. + EdgeInsets get all => EdgeInsets.all(this); + + /// Creates an `EdgeInsets` with symmetric horizontal padding (left and right). + EdgeInsets get horizontal => EdgeInsets.symmetric(horizontal: this); + + /// Creates an `EdgeInsets` with symmetric vertical padding (top and bottom). + EdgeInsets get vertical => EdgeInsets.symmetric(vertical: this); +} + +/// Extension on `double` to simplify the creation of `BorderRadius` and `Radius` objects. +extension RadiusesDoubleExt on double { + /// Creates a `BorderRadius` with the specified corners rounded. + /// + /// You can round specific corners by setting the boolean flags: + /// `tl` (top-left), `tr` (top-right), `bl` (bottom-left), and `br` (bottom-right). + BorderRadius ronly({ + bool tl = false, + bool tr = false, + bool bl = false, + bool br = false, + }) => + BorderRadius.only( + topLeft: Radius.circular(tl ? this : 0), + topRight: Radius.circular(tr ? this : 0), + bottomLeft: Radius.circular(bl ? this : 0), + bottomRight: Radius.circular(br ? this : 0), + ); + + /// Creates a `BorderRadius` with only the top-left corner rounded. + BorderRadius get topLeft => BorderRadius.only(topLeft: Radius.circular(this)); + + /// Creates a `BorderRadius` with only the top-right corner rounded. + BorderRadius get topRight => + BorderRadius.only(topRight: Radius.circular(this)); + + /// Creates a `BorderRadius` with only the bottom-left corner rounded. + BorderRadius get bottomLeft => + BorderRadius.only(bottomLeft: Radius.circular(this)); + + /// Creates a `BorderRadius` with only the bottom-right corner rounded. + BorderRadius get bottomRight => + BorderRadius.only(bottomRight: Radius.circular(this)); + + /// Creates a `BorderRadius` with symmetric horizontal corners rounded (left and right). + BorderRadius get rhorizontal => BorderRadius.horizontal( + left: Radius.circular(this), right: Radius.circular(this)); + + /// Creates a `BorderRadius` with symmetric vertical corners rounded (top and bottom). + BorderRadius get rvertical => BorderRadius.vertical( + top: Radius.circular(this), bottom: Radius.circular(this)); + + /// Creates a `BorderRadius` where all corners are rounded by the same amount. + BorderRadius get borderRadius => BorderRadius.all(Radius.circular(this)); + + /// Creates a `Radius` object with a circular radius equal to this value. + Radius get circularRadius => Radius.circular(this); +} diff --git a/infrastructure/lib/_core/commons/extensions/duration.ext.dart b/infrastructure/lib/_core/commons/extensions/duration.ext.dart new file mode 100644 index 0000000..c56e33f --- /dev/null +++ b/infrastructure/lib/_core/commons/extensions/duration.ext.dart @@ -0,0 +1,21 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'dart:async'; + +import 'package:infrastructure/_core/commons/typedefs/future_or_callback.typedef.dart'; + +/// Extension on `Duration` to easily introduce delays with callbacks. +extension DurationExt on Duration { + /// Delays the execution of the provided [callback] for the duration. + /// + /// The callback can either be synchronous or asynchronous. + Future delay([FutureOrCallback? callback]) => + Future.delayed( + this, + callback, + ); +} diff --git a/infrastructure/lib/_core/commons/extensions/generics.ext.dart b/infrastructure/lib/_core/commons/extensions/generics.ext.dart new file mode 100644 index 0000000..eab1905 --- /dev/null +++ b/infrastructure/lib/_core/commons/extensions/generics.ext.dart @@ -0,0 +1,14 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +/// Extension on nullable generic types to provide utility methods for null checks. +extension GenericsExt on T? { + /// Returns `true` if the object is `null`. + bool get ifNull => this == null; + + /// Returns `true` if the object is not `null`. + bool get ifNotNull => this != null; +} diff --git a/infrastructure/lib/_core/commons/extensions/int.ext.dart b/infrastructure/lib/_core/commons/extensions/int.ext.dart new file mode 100644 index 0000000..2a9a990 --- /dev/null +++ b/infrastructure/lib/_core/commons/extensions/int.ext.dart @@ -0,0 +1,10 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +/// Placeholder extension on `int` for future utility methods. +extension IntExt on int { + // Add custom integer utility methods here as needed. +} diff --git a/infrastructure/lib/_core/commons/extensions/string.ext.dart b/infrastructure/lib/_core/commons/extensions/string.ext.dart new file mode 100644 index 0000000..e1fc9aa --- /dev/null +++ b/infrastructure/lib/_core/commons/extensions/string.ext.dart @@ -0,0 +1,101 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter/widgets.dart'; + +/// Extension on `String` to provide various text manipulation and utility methods. +extension StringExt on String { + /// Capitalizes the first letter of each word in the string. + String get capitalize => isEmpty + ? this + : split(' ').map((String letter) => letter.capitalizeFirst).join(' '); + + /// Capitalizes only the first letter of the string. + String get capitalizeFirst { + if (isEmpty) { + return this; + } + final String firstCharacter = characters.firstOrNull?.toUpperCase() ?? ''; + final Characters remainingCharacters = characters.skip(1).toLowerCase(); + return firstCharacter + remainingCharacters.string; + } + + /// Converts the first letter of the string to lowercase. + String get lowercaseFirst { + if (isEmpty) { + return this; + } + final String firstCharacter = characters.firstOrNull?.toLowerCase() ?? ''; + final Characters remainingCharacters = characters.skip(1); + return firstCharacter + remainingCharacters.string; + } + + /// Calculates the width of the string when rendered with a specific [TextStyle]. + double width(TextStyle style) { + final TextPainter textPainter = TextPainter( + text: TextSpan(text: this, style: style), + textDirection: TextDirection.ltr, + maxLines: 1, + )..layout(); + return textPainter.size.width * 1.16; + } + + /// Removes all whitespaces from the string. + String get removeAllWhitespace => replaceAll(' ', ''); + + /// Checks if the string matches a given regular expression [pattern]. + bool hasMatch(String pattern) { + return RegExp(pattern).hasMatch(this); + } + + /// Checks if the string contains only numeric characters. + bool get isNumericOnly => hasMatch(r'^\d+$'); + + /// Checks if the string contains only alphabetic characters. + bool get isAlphabetOnly => hasMatch(r'^[a-zA-Z]+$'); + + /// Checks if the string contains at least one capital letter. + bool get hasCapitalletter => hasMatch('[A-Z]'); + + /// Checks if the string represents a boolean value (`true` or `false`). + bool get isBool => this == 'true' || this == 'false'; + + /// Creates a `Text` widget with the string and applies the given [TextStyle] and other parameters. + Text text({ + TextStyle? style, + Key? key, + StrutStyle? strutStyle, + TextAlign? textAlign, + TextDirection? textDirection, + Locale? locale, + bool? softWrap, + TextOverflow? overflow, + TextScaler? textScaler, + int? maxLines, + String? semanticsLabel, + TextWidthBasis? textWidthBasis, + TextHeightBehavior? textHeightBehavior, + Color? selectionColor, + }) { + return Text( + this, + key: key, + style: style, + strutStyle: strutStyle, + textAlign: textAlign, + textDirection: textDirection, + locale: locale, + softWrap: softWrap, + overflow: overflow, + textScaler: textScaler, + maxLines: maxLines, + semanticsLabel: semanticsLabel, + textWidthBasis: textWidthBasis, + textHeightBehavior: textHeightBehavior, + selectionColor: selectionColor, + ); + } +} diff --git a/infrastructure/lib/_core/commons/extensions/text_style.ext.dart b/infrastructure/lib/_core/commons/extensions/text_style.ext.dart new file mode 100644 index 0000000..1cd9f79 --- /dev/null +++ b/infrastructure/lib/_core/commons/extensions/text_style.ext.dart @@ -0,0 +1,72 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter/material.dart'; + +/// Extension on `TextStyle` to easily modify text style properties. +extension TextStyleExt on TextStyle { + /// Sets the font weight to the most thick (w900). + TextStyle get mostThick => copyWith(fontWeight: FontWeight.w900); + + /// Sets the font weight to extra bold (w800). + TextStyle get extraBold => copyWith(fontWeight: FontWeight.w800); + + /// Sets the font weight to bold (w700). + TextStyle get bold => copyWith(fontWeight: FontWeight.w700); + + /// Sets the font weight to semi-bold (w600). + TextStyle get semiBold => copyWith(fontWeight: FontWeight.w600); + + /// Sets the font weight to medium (w500). + TextStyle get medium => copyWith(fontWeight: FontWeight.w500); + + /// Sets the font weight to regular (w400). + TextStyle get regular => copyWith(fontWeight: FontWeight.w400); + + /// Sets the font weight to light (w300). + TextStyle get light => copyWith(fontWeight: FontWeight.w300); + + /// Sets the font weight to extra light (w200). + TextStyle get extraLight => copyWith(fontWeight: FontWeight.w200); + + /// Sets the font weight to thin (w100). + TextStyle get thin => copyWith(fontWeight: FontWeight.w100); + + /// Sets the font style to italic. + TextStyle get italic => copyWith(fontStyle: FontStyle.italic); + + /// Underlines the text. + TextStyle get underline => copyWith(decoration: TextDecoration.underline); + + /// Changes the font size to the given [size]. + TextStyle changeSize(double size) => copyWith(fontSize: size); + + /// Changes the font family to the given [family]. + TextStyle changeFamily(String family) => copyWith(fontFamily: family); + + /// Changes the letter spacing to the given [space]. + TextStyle changeLetterSpacing(double space) => copyWith(letterSpacing: space); + + /// Changes the word spacing to the given [space]. + TextStyle changeWordSpacing(double space) => copyWith(wordSpacing: space); + + /// Changes the text color to the given [color]. + TextStyle changeColor(Color color) => copyWith(color: color); + + /// Changes the text baseline to the given [textBaseline]. + TextStyle changeBaseline(TextBaseline textBaseline) => + copyWith(textBaseline: textBaseline); + + /// Changes the color of the text based on a [condition]. + /// + /// If the [condition] is true, the color is set to [ifTrue]; otherwise, it's set to [ifFalse]. + TextStyle changeColorWithCondition({ + required bool condition, + Color? ifTrue, + Color? ifFalse, + }) => + copyWith(color: condition ? ifTrue ?? color : ifFalse ?? color); +} diff --git a/infrastructure/lib/_core/commons/failures/parsing_failures.dart b/infrastructure/lib/_core/commons/failures/parsing_failures.dart new file mode 100644 index 0000000..4f147e7 --- /dev/null +++ b/infrastructure/lib/_core/commons/failures/parsing_failures.dart @@ -0,0 +1,24 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:infrastructure/_core/commons/enums/failure_tag.enum.dart'; +import 'package:infrastructure/_core/commons/enums/failure_type.enum.dart'; +import 'package:infrastructure/analytics/failure/failure.dart'; + +/// A specific failure class for handling errors related to API response parsing. +/// +/// This error occurs when an unexpected issue arises during the parsing of an API response +/// into the expected output type. +class ApiResponseParsingError extends Failure { + ApiResponseParsingError({super.exception, super.stack}) + : super( + code: 'api-response-parsing-error', + type: FailureTypeEnum.error, + tag: FailureTagEnum.operation, + message: + 'An unexpected error occurred while parsing the API response for the desired (O)utput type.', + ); +} diff --git a/infrastructure/lib/_core/commons/failures/unexpected_failures.dart b/infrastructure/lib/_core/commons/failures/unexpected_failures.dart new file mode 100644 index 0000000..3b4b92a --- /dev/null +++ b/infrastructure/lib/_core/commons/failures/unexpected_failures.dart @@ -0,0 +1,61 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:infrastructure/_core/commons/enums/failure_tag.enum.dart'; +import 'package:infrastructure/_core/commons/enums/failure_type.enum.dart'; +import 'package:infrastructure/analytics/failure/failure.dart'; + +/// A failure class for unexpected exceptions that do not fit into other categories. +/// +/// This type of failure is used when an unexpected exception occurs during runtime. +class UnexpectedFailure extends Failure { + UnexpectedFailure({super.exception}) + : super( + code: 'unexpected-failure', + type: FailureTypeEnum.exception, + tag: FailureTagEnum.uncaught, + message: 'An unexpected exception occurred.', + ); +} + +/// A failure class for unexpected runtime errors that are not exceptions. +/// +/// This type of error typically represents unexpected runtime issues that are not categorized as exceptions. +class UnexpectedError extends Failure { + UnexpectedError({super.exception, super.stack}) + : super( + code: 'unexpected-error', + type: FailureTypeEnum.error, + tag: FailureTagEnum.uncaught, + message: 'An unexpected error occurred.', + ); +} + +/// A failure class representing unexpected errors specifically within the Flutter framework. +/// +/// This type of error occurs when a Flutter-specific issue arises during runtime. +class UnexpectedFlutterError extends Failure { + UnexpectedFlutterError({super.exception, super.stack}) + : super( + code: 'unexpected-flutter-error', + type: FailureTypeEnum.error, + tag: FailureTagEnum.uncaught, + message: 'An unexpected flutter error occurred.', + ); +} + +/// A failure class for unexpected errors that occur at the platform level. +/// +/// This type of error occurs when there are platform-specific issues that disrupt normal operation. +class UnexpectedPlatformError extends Failure { + UnexpectedPlatformError({super.exception, super.stack}) + : super( + code: 'unexpected-platform-error', + type: FailureTypeEnum.error, + tag: FailureTagEnum.uncaught, + message: 'An unexpected platform error occurred.', + ); +} diff --git a/infrastructure/lib/_core/commons/typedefs/either.typedef.dart b/infrastructure/lib/_core/commons/typedefs/either.typedef.dart new file mode 100644 index 0000000..62d85e1 --- /dev/null +++ b/infrastructure/lib/_core/commons/typedefs/either.typedef.dart @@ -0,0 +1,38 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/fpdart.dart'; +import 'package:infrastructure/analytics/failure/failure.dart'; + +/// Type alias for an asynchronous operation that returns an `Either` type with +/// a `Failure` on the left side (indicating an error) or a value of type `T` +/// on the right side (indicating success). +/// +/// This is commonly used in operations that perform asynchronous tasks such as +/// network requests or database queries. +/// +/// Example usage: +/// ```dart +/// AsyncEither fetchData() { +/// // Implementation here +/// } +/// ``` +typedef AsyncEither = Future>; + +/// Type alias for a synchronous operation that returns an `Either` type with +/// a `Failure` on the left side (indicating an error) or a value of type `T` +/// on the right side (indicating success). +/// +/// This is useful for operations that do not involve asynchronous tasks but +/// can still fail and need to return a result or an error. +/// +/// Example usage: +/// ```dart +/// SyncEither validateData() { +/// // Implementation here +/// } +/// ``` +typedef SyncEither = Either; diff --git a/infrastructure/lib/_core/commons/typedefs/future_or_callback.typedef.dart b/infrastructure/lib/_core/commons/typedefs/future_or_callback.typedef.dart new file mode 100644 index 0000000..9dc89db --- /dev/null +++ b/infrastructure/lib/_core/commons/typedefs/future_or_callback.typedef.dart @@ -0,0 +1,22 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'dart:async'; + +/// A type alias for a callback function that can return either a `Future` or +/// a synchronous value of any type (`dynamic`). +/// +/// This type is useful for situations where a callback might return either an +/// asynchronous operation (like a `Future`) or a synchronous operation, allowing +/// the function to handle both scenarios. +/// +/// Example usage: +/// ```dart +/// FutureOrCallback myCallback = () async { +/// // Do something asynchronously or synchronously +/// }; +/// ``` +typedef FutureOrCallback = FutureOr Function()?; diff --git a/infrastructure/lib/_core/commons/typedefs/future_void_callback.typedef.dart b/infrastructure/lib/_core/commons/typedefs/future_void_callback.typedef.dart new file mode 100644 index 0000000..97d3c47 --- /dev/null +++ b/infrastructure/lib/_core/commons/typedefs/future_void_callback.typedef.dart @@ -0,0 +1,18 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +/// A type alias for a callback function that returns a `Future`. +/// +/// This is useful for defining asynchronous functions that do not return any +/// value but may still need to perform asynchronous operations. +/// +/// Example usage: +/// ```dart +/// FutureVoidCallback myCallback = () async { +/// // Perform some asynchronous operation +/// }; +/// ``` +typedef FutureVoidCallback = Future Function(); diff --git a/infrastructure/lib/_core/commons/typedefs/model_from_json.typedef.dart b/infrastructure/lib/_core/commons/typedefs/model_from_json.typedef.dart new file mode 100644 index 0000000..0db5bb8 --- /dev/null +++ b/infrastructure/lib/_core/commons/typedefs/model_from_json.typedef.dart @@ -0,0 +1,20 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +/// A type alias for a function that takes a `Map` (typically JSON) +/// and returns an instance of a model of type `I`. +/// +/// This is commonly used in deserialization, where a model object is constructed +/// from a JSON representation. +/// +/// Example usage: +/// ```dart +/// ModelFromJson fromJson = (json) => MyModel.fromJson(json); +/// ``` +/// +/// In this example, `MyModel.fromJson` would be a factory constructor or a +/// static method that takes a `Map` and returns a `MyModel` instance. +typedef ModelFromJson = I Function(Map json); diff --git a/infrastructure/lib/_core/commons/typedefs/widget_builder.typedef.dart b/infrastructure/lib/_core/commons/typedefs/widget_builder.typedef.dart new file mode 100644 index 0000000..bc43c8d --- /dev/null +++ b/infrastructure/lib/_core/commons/typedefs/widget_builder.typedef.dart @@ -0,0 +1,23 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter/material.dart'; + +/// A type alias for a function that builds a `Widget` using the provided `BuildContext`. +/// +/// This is typically used in Flutter to create widgets dynamically, for example, +/// in navigation, dialog builders, or list builders. +/// +/// Example usage: +/// ```dart +/// WidgetBuilder myWidgetBuilder = (BuildContext context) { +/// return Text('Hello, world!'); +/// }; +/// ``` +/// +/// This allows you to define widget-building functions that take in a `BuildContext` +/// and return a widget, which is essential in many parts of Flutter’s widget tree. +typedef WidgetBuilder = Widget Function(BuildContext context); diff --git a/infrastructure/lib/analytics/failure/failure.dart b/infrastructure/lib/analytics/failure/failure.dart new file mode 100644 index 0000000..49d9f94 --- /dev/null +++ b/infrastructure/lib/analytics/failure/failure.dart @@ -0,0 +1,99 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/infrastructure/infrastructure.dart'; +import 'package:deps/locator/locator.dart'; +import 'package:infrastructure/analytics/observers/i_failure_observer.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'failure.g.dart'; + +/// `Failure` class represents an error or an exceptional case in the app. +/// +/// It captures details such as the error code, message, tag, and type. +/// Additionally, it can hold an exception and a stack trace for further analysis. +@JsonSerializable() +class Failure implements Exception { + Failure({ + required this.code, + required this.message, + required this.tag, + required this.type, + dynamic exception, + this.stack, + }) : exception = (type == FailureTypeEnum.exception) + ? (exception ?? + (throw ArgumentError( + 'Exception must be provided for FailureTypeEnum.exception.'))) + : exception, + _failureObserver = locator() { + if (type == FailureTypeEnum.error) { + _failureObserver.onFailure(this); + } + } + + /// Factory constructor for generating a `Failure` instance from JSON. + factory Failure.fromJson(Map json) => + _$FailureFromJson(json); + + /// Converts the current `Failure` instance to JSON. + Map toJson() => _$FailureToJson(this); + + /// Factory constructor for creating an empty `Failure` instance. + factory Failure.empty() => Failure( + code: '', + message: '', + tag: FailureTagEnum.empty, + type: FailureTypeEnum.empty, + ); + + /// Error code related to the failure. + final String code; + + /// Exception associated with the failure, if applicable. + @JsonKey(includeFromJson: false, includeToJson: false) + final dynamic exception; + + /// Error message providing details about the failure. + final String message; + + /// Stack trace associated with the failure. + @StackTraceConverter() + final StackTrace? stack; + + /// Tag categorizing the failure. + final FailureTagEnum tag; + + /// Type of failure (exception or error). + final FailureTypeEnum type; + + /// Observer that handles failure events and logs them. + final IFailureObserver _failureObserver; + + @override + String toString() => message; + + /// Returns true if this failure instance is empty. + bool get isEmpty => type == FailureTypeEnum.empty; +} + +/// Custom converter for serializing and deserializing `StackTrace` objects in JSON. +class StackTraceConverter implements JsonConverter { + const StackTraceConverter(); + + @override + StackTrace? fromJson(String? json) { + if (json == null) { + return null; + } + return StackTrace.fromString(json); + } + + @override + String? toJson(StackTrace? object) { + return object?.toString(); + } +} diff --git a/infrastructure/lib/analytics/observers/failure_observer.dart b/infrastructure/lib/analytics/observers/failure_observer.dart new file mode 100644 index 0000000..019b6bd --- /dev/null +++ b/infrastructure/lib/analytics/observers/failure_observer.dart @@ -0,0 +1,50 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/injectable.dart'; +import 'package:infrastructure/_core/commons/enums/failure_type.enum.dart'; +import 'package:infrastructure/analytics/failure/failure.dart'; +import 'package:infrastructure/analytics/observers/i_failure_observer.dart'; +import 'package:infrastructure/analytics/reporters/i_analytics.dart'; +import 'package:infrastructure/analytics/reporters/i_logger.dart'; +import 'package:infrastructure/flavors/i_env.dart'; + +/// Implementation of `IFailureObserver` to log and report failures. +/// This observer integrates with analytics and logging systems. +@LazySingleton(as: IFailureObserver) +class FailureObserver implements IFailureObserver { + const FailureObserver(this._analytics, this._env, this._logger); + + /// Analytics service for reporting failures. + final IAnalytics _analytics; + + /// Environment configuration to check if the app is in debug mode. + final IEnv _env; + + /// Logger for logging error and exception details. + final ILogger _logger; + + /// Handles failure based on its type and logs or reports it. + @override + void onFailure(Failure failure) { + // Only send to analytics if not in debug mode + if (!_env.isDebug) { + _analytics.send(failure.message); + } + + // Log failure based on its type + switch (failure.type) { + case FailureTypeEnum.exception: + _logger.exception(failure); + + case FailureTypeEnum.error: + _logger.error(failure); + + default: + return; + } + } +} diff --git a/infrastructure/lib/analytics/observers/i_failure_observer.dart b/infrastructure/lib/analytics/observers/i_failure_observer.dart new file mode 100644 index 0000000..046799c --- /dev/null +++ b/infrastructure/lib/analytics/observers/i_failure_observer.dart @@ -0,0 +1,13 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:infrastructure/analytics/failure/failure.dart'; + +/// Interface for observing and responding to failure events. +abstract class IFailureObserver { + /// Called when a failure occurs. + void onFailure(Failure failure); +} diff --git a/infrastructure/lib/analytics/observers/talker/bloc_talker_observer.dart b/infrastructure/lib/analytics/observers/talker/bloc_talker_observer.dart new file mode 100644 index 0000000..635ed98 --- /dev/null +++ b/infrastructure/lib/analytics/observers/talker/bloc_talker_observer.dart @@ -0,0 +1,91 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/flutter_bloc.dart'; +import 'package:deps/packages/talker_bloc_logger.dart' + hide BlocChangeLog, BlocCloseLog, BlocCreateLog, BlocEventLog, BlocStateLog; +import 'package:deps/packages/talker_flutter.dart'; +import 'package:flutter/foundation.dart'; +import 'package:infrastructure/analytics/reporters/talker/logs/bloc_logs.dart'; + +/// Custom `BlocObserver` that logs Bloc events, transitions, and errors using Talker. +@immutable +final class BlocTalkerObserver extends BlocObserver { + const BlocTalkerObserver({required this.settings, required this.talker}); + + /// Talker instance for logging Bloc events. + final Talker talker; + + /// Settings for customizing Bloc logging. + final TalkerBlocLoggerSettings settings; + + @override + @mustCallSuper + void onEvent(Bloc bloc, Object? event) { + super.onEvent(bloc, event); + if (!settings.enabled || !settings.printEvents) { + return; + } + final bool isAccepted = settings.eventFilter?.call(bloc, event) ?? true; + if (!isAccepted) { + return; + } + talker.logTyped(BlocEventLog(bloc: bloc, event: event, settings: settings)); + } + + @override + @mustCallSuper + void onTransition( + Bloc bloc, Transition transition) { + super.onTransition(bloc, transition); + if (!settings.enabled || !settings.printTransitions) { + return; + } + final bool isAccepted = + settings.transitionFilter?.call(bloc, transition) ?? true; + if (!isAccepted) { + return; + } + talker.logTyped( + BlocStateLog(bloc: bloc, settings: settings, transition: transition)); + } + + @override + void onChange(BlocBase bloc, Change change) { + super.onChange(bloc, change); + if (!settings.enabled || !settings.printChanges) { + return; + } + talker.logTyped( + BlocChangeLog(bloc: bloc, change: change, settings: settings), + ); + } + + @override + @mustCallSuper + void onError(BlocBase bloc, Object error, StackTrace stackTrace) { + super.onError(bloc, error, stackTrace); + talker.error(bloc.runtimeType, error, stackTrace); + } + + @override + void onCreate(BlocBase bloc) { + super.onCreate(bloc); + if (!settings.enabled || !settings.printCreations) { + return; + } + talker.logTyped(BlocCreateLog(bloc: bloc)); + } + + @override + void onClose(BlocBase bloc) { + super.onClose(bloc); + if (!settings.enabled || !settings.printClosings) { + return; + } + talker.logTyped(BlocCloseLog(bloc: bloc)); + } +} diff --git a/infrastructure/lib/analytics/observers/talker/dio_talker_observer.dart b/infrastructure/lib/analytics/observers/talker/dio_talker_observer.dart new file mode 100644 index 0000000..36dc214 --- /dev/null +++ b/infrastructure/lib/analytics/observers/talker/dio_talker_observer.dart @@ -0,0 +1,92 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/dio.dart'; +import 'package:deps/packages/talker_dio_logger.dart'; +import 'package:deps/packages/talker_flutter.dart'; +import 'package:infrastructure/analytics/reporters/talker/logs/dio_logs.dart'; + +/// Custom `Interceptor` for Dio to log HTTP requests, responses, and errors using Talker. +final class DioTalkerObserver extends Interceptor { + DioTalkerObserver({required this.settings, required this.talker}); + + /// Talker instance for logging. + final Talker talker; + + /// Settings for customizing Talker Dio logging. + TalkerDioLoggerSettings settings; + + /// Allows for the configuration of Dio logger settings. + void configure({ + AnsiPen? errorPen, + bool? printRequestData, + bool? printRequestHeaders, + bool? printResponseData, + bool? printResponseHeaders, + bool? printResponseMessage, + AnsiPen? requestPen, + AnsiPen? responsePen, + }) { + settings = settings.copyWith( + printResponseData: printResponseData, + printResponseHeaders: printResponseHeaders, + printResponseMessage: printResponseMessage, + printRequestData: printRequestData, + printRequestHeaders: printRequestHeaders, + requestPen: requestPen, + responsePen: responsePen, + errorPen: errorPen, + ); + } + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + super.onRequest(options, handler); + final bool accepted = settings.requestFilter?.call(options) ?? true; + if (!accepted) { + return; + } + + final String message = '${options.uri}'; + final DioRequestLog httpLog = DioRequestLog( + message, + requestOptions: options, + settings: settings, + ); + talker.logTyped(httpLog); + } + + @override + void onResponse( + Response response, ResponseInterceptorHandler handler) { + super.onResponse(response, handler); + final bool accepted = settings.responseFilter?.call(response) ?? true; + if (!accepted) { + return; + } + + final String message = '${response.requestOptions.uri}'; + final DioResponseLog httpLog = DioResponseLog( + message, + response: response, + settings: settings, + ); + talker.logTyped(httpLog); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + super.onError(err, handler); + + final String message = '${err.requestOptions.uri}'; + final DioErrorLog httpErrorLog = DioErrorLog( + message, + dioException: err, + settings: settings, + ); + talker.logTyped(httpErrorLog); + } +} diff --git a/infrastructure/lib/analytics/observers/talker/router_talker_observer.dart b/infrastructure/lib/analytics/observers/talker/router_talker_observer.dart new file mode 100644 index 0000000..8e2ea45 --- /dev/null +++ b/infrastructure/lib/analytics/observers/talker/router_talker_observer.dart @@ -0,0 +1,37 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/talker_flutter.dart' hide TalkerRouteLog; +import 'package:flutter/material.dart'; +import 'package:infrastructure/analytics/reporters/talker/logs/route_log.dart'; + +/// Custom `NavigatorObserver` that logs route changes (push/pop) using Talker. +class RouterTalkerObserver extends NavigatorObserver { + RouterTalkerObserver({required this.talker}); + + /// Instance of Talker used for logging route changes. + final Talker talker; + + @override + void didPop(Route route, Route? previousRoute) { + super.didPop(route, previousRoute); + if (route.settings.name == null) { + return; + } + // Log when a route is popped + talker.logTyped(RouteLog(route: route, isPush: false)); + } + + @override + void didPush(Route route, Route? previousRoute) { + super.didPush(route, previousRoute); + if (route.settings.name == null) { + return; + } + // Log when a new route is pushed + talker.logTyped(RouteLog(route: route)); + } +} diff --git a/infrastructure/lib/analytics/reporters/branch_io/branch_io_analytics.dart b/infrastructure/lib/analytics/reporters/branch_io/branch_io_analytics.dart new file mode 100644 index 0000000..01eccb6 --- /dev/null +++ b/infrastructure/lib/analytics/reporters/branch_io/branch_io_analytics.dart @@ -0,0 +1,57 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/injectable.dart'; +import 'package:infrastructure/analytics/reporters/i_analytics.dart'; + +/// A concrete implementation of the [IAnalytics] interface that uses Branch.io analytics. +/// +/// The [BranchIoAnalytics] class provides methods for sending analytics data, +/// tracking page views, and setting user IDs. This class is annotated with +/// `@LazySingleton` to ensure that a single instance is used throughout the app. +@LazySingleton(as: IAnalytics) +class BranchIoAnalytics implements IAnalytics { + /// A constant constructor for the [BranchIoAnalytics] class. + /// + /// This ensures that the class is stateless and can be instantiated as a singleton. + const BranchIoAnalytics(); + + /// Sends an analytics message using Branch.io. + /// + /// This method is intended to send a message or event to the analytics system. In a + /// real implementation, this would involve interacting with Branch.io's API to log the event. + /// + /// * [message]: A string representing the event or message to be sent to analytics. + @override + Future send(String message) async { + // Add Branch.io event sending logic here. + } + + /// Tracks a page view event using Branch.io. + /// + /// This method tracks when a user views a page in the app, providing information + /// about the page name and widget that triggered the event. Typically used for + /// navigation and screen tracking. + /// + /// * [name]: The name of the page viewed by the user. + /// * [widgetName]: The name of the widget associated with the page. + @override + Future setPage( + {required String name, required String widgetName}) async { + // Add Branch.io page view tracking logic here. + } + + /// Sets the user ID in Branch.io for tracking purposes. + /// + /// This method assigns a unique identifier to a user in Branch.io, which allows + /// the analytics system to track events and actions tied to a specific user. + /// + /// * [id]: The unique user ID. If null, it could mean the user is logged out or anonymous. + @override + Future setUserId(String? id) async { + // Add Branch.io user ID setting logic here. + } +} diff --git a/infrastructure/lib/analytics/reporters/i_analytics.dart b/infrastructure/lib/analytics/reporters/i_analytics.dart new file mode 100755 index 0000000..196c1ce --- /dev/null +++ b/infrastructure/lib/analytics/reporters/i_analytics.dart @@ -0,0 +1,18 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +/// Analytics interface for tracking user behavior and interactions. +/// +/// - `setUserId`: Sets the user ID for analytics tracking. +/// - `setPage`: Logs page navigation or views with a specific page name and widget. +/// - `send`: Sends a custom analytics event or message. +abstract interface class IAnalytics { + void setUserId(String? id); + + void setPage({required String name, required String widgetName}); + + void send(String message); +} diff --git a/infrastructure/lib/analytics/reporters/i_logger.dart b/infrastructure/lib/analytics/reporters/i_logger.dart new file mode 100755 index 0000000..17a9f4f --- /dev/null +++ b/infrastructure/lib/analytics/reporters/i_logger.dart @@ -0,0 +1,20 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:infrastructure/analytics/failure/failure.dart'; + +/// Logger interface defining methods for handling different types of logs. +/// +/// - `debug`: Logs debugging information. +/// - `error`: Logs an error, usually caused by a `Failure`. +/// - `exception`: Logs an exception, typically associated with a `Failure`. +abstract class ILogger { + void debug(dynamic data, [String? message]); + + void error(Failure failure); + + void exception(Failure failure); +} diff --git a/infrastructure/lib/analytics/reporters/talker/formatter/fancy_talker_log_formatter.dart b/infrastructure/lib/analytics/reporters/talker/formatter/fancy_talker_log_formatter.dart new file mode 100644 index 0000000..1e82c9c --- /dev/null +++ b/infrastructure/lib/analytics/reporters/talker/formatter/fancy_talker_log_formatter.dart @@ -0,0 +1,93 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/talker_flutter.dart'; +import 'package:flutter/material.dart'; + +/// A fancy log formatter for `Talker` that adds more detailed borders and formatting to logs. +/// +/// This formatter wraps log messages in a border with additional visual indicators +/// like bullet points, and it adjusts the log format to be more visually distinct. +class FancyTalkerLogFormatter implements LoggerFormatter { + @override + String fmt(LogDetails details, TalkerLoggerSettings settings) { + final String msg = details.message?.toString() ?? ''; + final int maxLineWidth = settings.maxLineWidth; + final int maxContentWidth = maxLineWidth - 4; + + const List noPrefixLabels = ['•', '«']; + + final List coloredLines = [ + for (final String line in msg.split('\n')) + for (final String wrappedLine in _wrapLine( + maxContentWidth, + line, + noPrefixKeywords: noPrefixLabels, + prefix: ' ' * 16, + )) + _formatLine(wrappedLine, maxContentWidth, details.pen), + ]; + + final String topBorder = + details.pen.write('┌${'─' * (maxLineWidth - 2)}┐\n'); + final String bottomBorder = + details.pen.write('└${'─' * (maxLineWidth - 2)}┘'); + + return '$topBorder${coloredLines.join('\n')}\n$bottomBorder'; + } + + /// Formats a line of text to fit within a border. + String _formatLine(String line, int maxContentWidth, AnsiPen pen) { + final int paddingSize = maxContentWidth - line.length; + final String padding = ' ' * (paddingSize > 0 ? paddingSize : 0); + + return pen.write('│ $line$padding │'); + } + + /// Wraps lines that exceed the specified width, with optional prefixes. + Iterable _wrapLine( + int maxContentWidth, + String text, { + List noPrefixKeywords = const [], + String prefix = '', + }) { + final List wrappedLines = []; + String currentLine = text.replaceAll('\t', ' '); + + while (currentLine.isNotEmpty) { + int currentMaxWidth = maxContentWidth; + + final bool isStartWithKeyword = noPrefixKeywords + .any((String keyword) => currentLine.startsWith(keyword)); + + if (wrappedLines.isNotEmpty || !isStartWithKeyword) { + currentMaxWidth -= prefix.length; + } + + int cutPoint = currentLine.length > currentMaxWidth + ? currentMaxWidth + : currentLine.length; + + if (cutPoint < currentLine.length) { + final int lastSpace = currentLine.characters + .getRange(0, cutPoint) + .toString() + .lastIndexOf(' '); + if (lastSpace > -1) { + cutPoint = lastSpace; + } + } + + wrappedLines.add( + ((wrappedLines.isEmpty && isStartWithKeyword) ? '' : prefix) + + currentLine.characters.getRange(0, cutPoint).toString(), + ); + currentLine = currentLine.characters.getRange(cutPoint).toString().trim(); + } + + return wrappedLines; + } +} diff --git a/infrastructure/lib/analytics/reporters/talker/formatter/simple_talker_log_formatter.dart b/infrastructure/lib/analytics/reporters/talker/formatter/simple_talker_log_formatter.dart new file mode 100644 index 0000000..1ecd397 --- /dev/null +++ b/infrastructure/lib/analytics/reporters/talker/formatter/simple_talker_log_formatter.dart @@ -0,0 +1,55 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/talker_flutter.dart'; +import 'package:flutter/material.dart'; + +/// A simple log formatter for `Talker` that formats logs with consistent padding and borders. +/// +/// This formatter adds a border around each log entry and formats lines to a fixed width +/// by wrapping them when necessary. It ensures that logs are easily readable within a terminal. +class SimpleTalkerLogFormatter implements LoggerFormatter { + @override + String fmt(LogDetails details, TalkerLoggerSettings settings) { + final String msg = details.message?.toString() ?? ''; + final int maxLineWidth = settings.maxLineWidth; + + final List coloredLines = [ + for (final String line in msg.split('\n')) + for (final String wrappedLine in _wrapLine(maxLineWidth, line)) + formattedLine(wrappedLine, maxLineWidth, details.pen), + ]; + + final String topBorder = details.pen.write('${'*' * maxLineWidth}\n'); + final String bottomBorder = details.pen.write('*' * maxLineWidth); + + return '$topBorder${coloredLines.join('\n')}\n$bottomBorder'; + } + + /// Formats a line of text to fit within a specific width. + String formattedLine(String line, int maxLineWidth, AnsiPen pen) { + final int paddingSize = maxLineWidth - line.length; + final String padding = ' ' * (paddingSize > 0 ? paddingSize : 0); + + return pen.write('$line$padding'); + } + + /// Wraps lines that exceed the specified width, splitting them into multiple lines. + Iterable _wrapLine(int maxContentWidth, String text) { + final List wrappedLines = []; + String currentLine = text.replaceAll('\t', ' '); + + while (currentLine.isNotEmpty) { + final int cutPoint = currentLine.length > maxContentWidth + ? maxContentWidth + : currentLine.length; + wrappedLines.add(currentLine.characters.getRange(0, cutPoint).toString()); + currentLine = currentLine.characters.getRange(cutPoint).toString().trim(); + } + + return wrappedLines; + } +} diff --git a/infrastructure/lib/analytics/reporters/talker/logs/bloc_logs.dart b/infrastructure/lib/analytics/reporters/talker/logs/bloc_logs.dart new file mode 100644 index 0000000..d98ba0f --- /dev/null +++ b/infrastructure/lib/analytics/reporters/talker/logs/bloc_logs.dart @@ -0,0 +1,269 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: avoid_dynamic_calls + +import 'package:deps/packages/flutter_bloc.dart'; +import 'package:deps/packages/intl.dart'; +import 'package:deps/packages/talker_bloc_logger.dart'; +import 'package:deps/packages/talker_flutter.dart'; + +/// Custom log for logging Bloc events using Talker. +/// +/// This class is responsible for formatting and logging events received by a Bloc, +/// showing details like the Bloc's type, event received, and the time of the event. +class BlocEventLog extends TalkerLog { + BlocEventLog({ + required this.bloc, + required this.event, + required this.settings, + }) : super(''); + + final Bloc bloc; + final dynamic event; + final TalkerBlocLoggerSettings settings; + + @override + String generateTextMessage({TimeFormat? timeFormat}) { + return _createMessage; + } + + @override + AnsiPen get pen => AnsiPen()..xterm(51); + + /// Generates a formatted message for logging the Bloc event. + String get _createMessage { + final StringBuffer stringBuffer = StringBuffer() + ..writeln('\n« BLOC on $_formatTime »') + ..writeln('• NAME\t ─► ${bloc.runtimeType}') + ..writeln( + '• STATUS\t─► Received event: ${settings.printEventFullData ? event : event.runtimeType}'); + return stringBuffer.toString(); + } + + /// Formats the current time as `HH:mm:ss.SSS` for log entry. + String get _formatTime => DateFormat('HH:mm:ss.SSS').format(DateTime.now()); +} + +/// Custom log for logging Bloc state transitions using Talker. +/// +/// This class logs the details of a Bloc's state transition, including the +/// current and next state, and the event that triggered the transition. +class BlocStateLog extends TalkerLog { + BlocStateLog({ + required this.bloc, + required this.settings, + required this.transition, + }) : super(''); + + final Bloc bloc; + final TalkerBlocLoggerSettings settings; + final Transition transition; + + @override + String generateTextMessage({TimeFormat? timeFormat}) { + return _createMessage; + } + + @override + AnsiPen get pen => AnsiPen()..xterm(49); + + /// Generates a formatted message for logging the state transition. + String get _createMessage { + final StringBuffer stringBuffer = StringBuffer() + ..writeln('\n« BLOC on $_formatTime »') + ..writeln('• NAME\t ─► ${bloc.runtimeType}') + ..writeln( + '• STATUS\t─► Transitioning with event ${transition.event.runtimeType}'); + + final dynamic currentState = _serializeObject(transition.currentState); + final dynamic nextState = _serializeObject(transition.nextState); + + if (settings.printStateFullData && + (currentState is Map || currentState is List) && + (nextState is Map || nextState is List)) { + if (settings.printStateFullData) { + stringBuffer + ..writeln( + '• FROM\t ─► ${currentState.entries.map( + (MapEntry entry) => + '- "${entry.key}": "${entry.value}"', + ).join(',\n')}', + ) + ..writeln( + '• TO\t\t─► ${nextState.entries.map( + (MapEntry entry) => + '- "${entry.key}": "${entry.value}"', + ).join(',\n')}', + ); + } + } else { + stringBuffer + ..writeln( + '• FROM\t ─► ${transition.currentState.runtimeType}', + ) + ..writeln( + '• TO\t\t─► ${transition.nextState.runtimeType}', + ); + } + + return stringBuffer.toString(); + } + + /// Attempts to serialize the state object, or falls back to its string representation. + dynamic _serializeObject(dynamic object) { + if (object == null) { + return null; + } + try { + return object.toJson(); + } catch (e) { + return object.toString(); + } + } + + /// Formats the current time as `HH:mm:ss.SSS` for log entry. + String get _formatTime => DateFormat('HH:mm:ss.SSS').format(DateTime.now()); +} + +/// Custom log for logging Bloc state changes using Talker. +/// +/// This class logs state changes within a Bloc, including the current and next state. +class BlocChangeLog extends TalkerLog { + BlocChangeLog({ + required this.bloc, + required this.change, + required this.settings, + }) : super(''); + + final BlocBase bloc; + final Change change; + final TalkerBlocLoggerSettings settings; + + @override + String generateTextMessage({TimeFormat? timeFormat}) { + return _createMessage; + } + + @override + AnsiPen get pen => AnsiPen()..xterm(49); + + /// Generates a formatted message for logging the state change. + String get _createMessage { + final StringBuffer stringBuffer = StringBuffer() + ..writeln('\n« BLOC on $_formatTime »') + ..writeln('• NAME\t ─► ${bloc.runtimeType}') + ..writeln('• STATUS\t─► Changed'); + + final dynamic currentState = _serializeObject(change.currentState); + final dynamic nextState = _serializeObject(change.nextState); + + if (settings.printStateFullData && + (currentState is Map || currentState is List) && + (nextState is Map || nextState is List)) { + if (settings.printStateFullData) { + stringBuffer + ..writeln( + '• FROM\t ─► ${currentState.entries.map( + (MapEntry entry) => + '- "${entry.key}": "${entry.value}"', + ).join(',\n')}', + ) + ..writeln( + '• TO\t\t─► ${nextState.entries.map( + (MapEntry entry) => + '- "${entry.key}": "${entry.value}"', + ).join(',\n')}', + ); + } + } else { + stringBuffer + ..writeln( + '• FROM\t ─► ${change.currentState.runtimeType}', + ) + ..writeln( + '• TO\t\t─► ${change.nextState.runtimeType}', + ); + } + + return stringBuffer.toString(); + } + + /// Attempts to serialize the state object, or falls back to its string representation. + dynamic _serializeObject(dynamic object) { + if (object == null) { + return null; + } + try { + return object.toJson(); + } catch (e) { + return object.toString(); + } + } + + /// Formats the current time as `HH:mm:ss.SSS` for log entry. + String get _formatTime => DateFormat('HH:mm:ss.SSS').format(DateTime.now()); +} + +/// Custom log for logging Bloc creation using Talker. +/// +/// This class logs when a Bloc is created, showing the Bloc's runtime type and the timestamp. +class BlocCreateLog extends TalkerLog { + BlocCreateLog({required this.bloc}) : super(''); + + final BlocBase bloc; + + @override + String generateTextMessage({TimeFormat? timeFormat}) { + return _createMessage; + } + + @override + AnsiPen get pen => AnsiPen()..xterm(8); + + /// Generates a formatted message for logging the creation of a Bloc. + String get _createMessage { + final StringBuffer stringBuffer = StringBuffer() + ..writeln('\n« BLOC on $_formatTime »') + ..writeln('• NAME\t ─► ${bloc.runtimeType}') + ..writeln('• STATUS\t─► Created'); + + return stringBuffer.toString(); + } + + /// Formats the current time as `HH:mm:ss.SSS` for log entry. + String get _formatTime => DateFormat('HH:mm:ss.SSS').format(DateTime.now()); +} + +/// Custom log for logging Bloc closure using Talker. +/// +/// This class logs when a Bloc is closed, showing the Bloc's runtime type and the timestamp. +class BlocCloseLog extends TalkerLog { + BlocCloseLog({required this.bloc}) : super(''); + + final BlocBase bloc; + + @override + String generateTextMessage({TimeFormat? timeFormat}) { + return _createMessage; + } + + @override + AnsiPen get pen => AnsiPen()..xterm(13); + + /// Generates a formatted message for logging the closure of a Bloc. + String get _createMessage { + final StringBuffer stringBuffer = StringBuffer() + ..writeln('\n« BLOC on $_formatTime »') + ..writeln('• NAME\t ─► ${bloc.runtimeType}') + ..writeln('• STATUS\t─► Closed'); + + return stringBuffer.toString(); + } + + /// Formats the current time as `HH:mm:ss.SSS` for log entry. + String get _formatTime => DateFormat('HH:mm:ss.SSS').format(DateTime.now()); +} diff --git a/infrastructure/lib/analytics/reporters/talker/logs/debug_log.dart b/infrastructure/lib/analytics/reporters/talker/logs/debug_log.dart new file mode 100644 index 0000000..a3343d4 --- /dev/null +++ b/infrastructure/lib/analytics/reporters/talker/logs/debug_log.dart @@ -0,0 +1,73 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/intl.dart'; +import 'package:deps/packages/talker_flutter.dart'; + +/// A custom log class for handling debug-level logs using Talker. +/// +/// The `DebugLog` class formats and logs debug information, including optional messages and data. +/// It is used for logging runtime details that help developers trace the flow of the application +/// during development or debugging sessions. +class DebugLog extends TalkerLog { + DebugLog(this.data, [this.msg]) : super(''); + + /// Data to be logged, can be any type (e.g., a variable or complex object). + final dynamic data; + + /// An optional message to provide additional context to the logged data. + final String? msg; + + /// Generates a debug log message, including formatted data and optional message. + @override + String generateTextMessage({TimeFormat? timeFormat}) { + return _createDebugLog(); + } + + /// Specifies the log level for this log, which is set to `debug`. + @override + LogLevel get logLevel => LogLevel.debug; + + /// Specifies the pen (color) used for formatting the debug log in the terminal. + /// The color is set to a light gray (`xterm(15)`). + @override + AnsiPen get pen => AnsiPen()..xterm(15); + + /// Formats the current time as `HH:mm:ss.SSS` for inclusion in the log message. + String get _formatTime => DateFormat('HH:mm:ss.SSS').format(DateTime.now()); + + /// Creates the formatted debug log message with data and optional message. + /// + /// This method formats the log message by including the data type, the data itself, + /// and an optional message. It also includes the timestamp to indicate when the log was generated. + String _createDebugLog() { + final StringBuffer stringBuffer = StringBuffer() + ..writeln('\n« DEBUG on $_formatTime »'); + + // If data is present, format and log the data and its type. + if (data != null) { + final String formattedData = _formatData(data); + stringBuffer + ..writeln('• TYPE\t ─► ${data.runtimeType}') + ..writeln('• DATA\t ─► $formattedData'); + } + + // If a message is provided, include it in the log output. + if (msg != null) { + stringBuffer.writeln('• MESSAGE ─► $msg'); + } + + return stringBuffer.toString(); + } + + /// Formats the data into a readable string. + /// + /// If the data is an iterable (e.g., a list or set), it joins the elements + /// with commas. Otherwise, it converts the data to a string. + String _formatData(dynamic value) { + return value is Iterable ? value.join(', ') : data.toString(); + } +} diff --git a/infrastructure/lib/analytics/reporters/talker/logs/dio_logs.dart b/infrastructure/lib/analytics/reporters/talker/logs/dio_logs.dart new file mode 100644 index 0000000..03d76ee --- /dev/null +++ b/infrastructure/lib/analytics/reporters/talker/logs/dio_logs.dart @@ -0,0 +1,284 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'dart:convert'; + +import 'package:deps/packages/dio.dart'; +import 'package:deps/packages/intl.dart'; +import 'package:deps/packages/talker_dio_logger.dart'; +import 'package:deps/packages/talker_flutter.dart'; + +/// Converts common HTTP status codes to a descriptive emoji string. +/// +/// This helper function maps HTTP status codes to human-readable descriptions +/// to make logs more informative. If a status code isn't mapped, it returns the code itself. +String statusCodeToEmoji(int statusCode) { + const Map statusCodeEmojis = { + 200: '(OK)', + 201: '(Created)', + 400: '(Bad Request)', + 401: '(Unauthorized)', + 403: '(Forbidden)', + 404: '(Not Found)', + 408: '(Timeout)', + 429: '(Too Many Requests)', + 500: '(Internal Server Error)', + 503: '(Service Unavailable)', + }; + + final String? emojiDescription = statusCodeEmojis[statusCode]; + if (emojiDescription != null) { + return '$statusCode - $emojiDescription'; + } else { + return '$statusCode'; + } +} + +/// Custom log for logging `Dio` HTTP requests using Talker. +/// +/// This class captures and formats the details of an HTTP request made via `Dio`. +/// It logs the request method, URL, headers, and body if available, making it easier +/// to track outgoing network requests. +class DioRequestLog extends TalkerLog { + DioRequestLog( + this.uri, { + required this.requestOptions, + required this.settings, + }) : super(''); + + final RequestOptions requestOptions; + final TalkerDioLoggerSettings settings; + final String uri; + + @override + String generateTextMessage({TimeFormat? timeFormat}) { + return _createMessage(); + } + + @override + AnsiPen get pen => settings.requestPen ?? (AnsiPen()..xterm(219)); + + /// Formats the current time as `HH:mm:ss.SSS` for log entry. + String get _formatTime => DateFormat('HH:mm:ss.SSS').format(DateTime.now()); + + /// Creates a formatted log message for the HTTP request, including method, URL, headers, and body data. + String _createMessage() { + const JsonEncoder encoder = JsonEncoder.withIndent(' '); + final dynamic data = requestOptions.data; + final Map headers = requestOptions.headers; + + final StringBuffer stringBuffer = StringBuffer() + ..writeln('\n« DIO on $_formatTime »') + ..writeln('• TYPE\t ─► ${requestOptions.method} Request') + ..writeln('• URL\t ─► $uri'); + + // Log request headers if enabled. + if (settings.printRequestHeaders && headers.isNotEmpty) { + final Map stringHeaders = headers.map( + (String key, dynamic value) => + MapEntry(key, value.toString()), + ); + + final String prettyHeaders = encoder.convert(stringHeaders); + final Map parsedJson = jsonDecode(prettyHeaders); + final String formattedEntries = parsedJson.entries + .map( + (MapEntry entry) => + '"${entry.key}": "${entry.value}"', + ) + .join(',\n'); + stringBuffer.writeln('• HEADERS ─► $formattedEntries'); + } + + // Log request data if enabled. + if (settings.printRequestData && data != null) { + final String prettyData = encoder.convert(data); + final Map parsedJson = jsonDecode(prettyData); + final String formattedEntries = parsedJson.entries + .map( + (MapEntry entry) => + '"${entry.key}": "${entry.value}"', + ) + .join(',\n'); + stringBuffer.writeln('• DATA\t ─► $formattedEntries'); + } + + return stringBuffer.toString(); + } +} + +/// Custom log for logging `Dio` HTTP responses using Talker. +/// +/// This class captures and formats the details of an HTTP response received via `Dio`. +/// It logs the response status, headers, and body data, making it easier to track incoming network responses. +class DioResponseLog extends TalkerLog { + DioResponseLog(this.uri, {required this.response, required this.settings}) + : super(''); + + final Response response; + final TalkerDioLoggerSettings settings; + final String uri; + + @override + String generateTextMessage({TimeFormat? timeFormat}) { + return _createMessage(); + } + + @override + AnsiPen get pen => settings.responsePen ?? (AnsiPen()..xterm(46)); + + /// Formats the current time as `HH:mm:ss.SSS` for log entry. + String get _formatTime => DateFormat('HH:mm:ss.SSS').format(DateTime.now()); + + /// Creates a formatted log message for the HTTP response, including status, headers, and body data. + String _createMessage() { + const JsonEncoder encoder = JsonEncoder.withIndent(' '); + final String? responseMessage = response.statusMessage; + final dynamic data = response.data; + final Map> headers = response.headers.map; + + final StringBuffer stringBuffer = StringBuffer() + ..writeln('\n« DIO on $_formatTime »') + ..writeln('• TYPE\t ─► ${response.requestOptions.method} Response') + ..writeln('• URL\t ─► $uri'); + + // Log status code with description. + if (response.statusCode != null) { + final String statusWithEmoji = statusCodeToEmoji(response.statusCode!); + stringBuffer.writeln('• STATUS\t─► $statusWithEmoji'); + } + + // Log response message if available. + if (settings.printResponseMessage && responseMessage != null) { + stringBuffer.writeln('• MESSAGE ─► $responseMessage'); + } + + // Log response headers if enabled. + if (settings.printRequestHeaders && headers.isNotEmpty) { + final Map stringHeaders = headers.map( + (String key, dynamic value) => + MapEntry(key, value.toString()), + ); + + final String prettyHeaders = encoder.convert(stringHeaders); + final Map parsedJson = jsonDecode(prettyHeaders); + final String formattedEntries = parsedJson.entries + .map( + (MapEntry entry) => + '"${entry.key}": "${entry.value}"', + ) + .join(',\n'); + stringBuffer.writeln('• HEADERS ─► $formattedEntries'); + } + + // Log response data if enabled. + if (settings.printResponseData && data != null) { + final String prettyData = encoder.convert(data); + if (data is Map) { + final Map parsedJson = jsonDecode(prettyData); + final String formattedEntries = parsedJson.entries + .map( + (MapEntry entry) => + '"${entry.key}": "${entry.value}"', + ) + .join(',\n'); + stringBuffer.writeln('• DATA\t ─► $formattedEntries'); + } else if (data is List) { + final List parsedJson = jsonDecode(prettyData); + final String formattedEntries = + parsedJson.map((dynamic entry) => '- $entry').join('\n'); + stringBuffer.writeln('• DATA\t ─► $formattedEntries'); + } + } + + return stringBuffer.toString(); + } +} + +/// Custom log for logging `Dio` HTTP errors using Talker. +/// +/// This class captures and formats the details of an HTTP error encountered via `Dio`. +/// It logs the error status, headers, and any data, making it easier to debug failed network requests. +class DioErrorLog extends TalkerLog { + DioErrorLog( + this.uri, { + required this.dioException, + required this.settings, + }) : super(''); + + final DioException dioException; + final TalkerDioLoggerSettings settings; + final String uri; + + @override + String generateTextMessage({TimeFormat? timeFormat}) { + return _createMessage(); + } + + @override + AnsiPen get pen => settings.errorPen ?? (AnsiPen()..red()); + + /// Formats the current time as `HH:mm:ss.SSS` for log entry. + String get _formatTime => DateFormat('HH:mm:ss.SSS').format(DateTime.now()); + + /// Creates a formatted log message for the HTTP error, including status, headers, and data. + String _createMessage() { + const JsonEncoder encoder = JsonEncoder.withIndent(' '); + final String? responseMessage = dioException.message; + final int? statusCode = dioException.response?.statusCode; + final dynamic data = dioException.response?.data; + final Map headers = dioException.requestOptions.headers; + + final StringBuffer stringBuffer = StringBuffer() + ..writeln('\n« DIO on $_formatTime »') + ..writeln('• TYPE\t ─► ${dioException.requestOptions.method} Error') + ..writeln('• URL\t ─► $uri'); + + // Log status code with description. + if (statusCode != null) { + final String statusWithEmoji = statusCodeToEmoji(statusCode); + stringBuffer.writeln('• STATUS\t─► $statusWithEmoji'); + } + + // Log response message if available. + if (responseMessage != null) { + stringBuffer.writeln('• MESSAGE ─► $responseMessage'); + } + + // Log request headers if enabled. + if (settings.printRequestHeaders && headers.isNotEmpty) { + final Map stringHeaders = headers.map( + (String key, dynamic value) => + MapEntry(key, value.toString()), + ); + + final String prettyHeaders = encoder.convert(stringHeaders); + final Map parsedJson = jsonDecode(prettyHeaders); + final String formattedEntries = parsedJson.entries + .map( + (MapEntry entry) => + '"${entry.key}": "${entry.value}"', + ) + .join(',\n'); + stringBuffer.writeln('• HEADERS ─► $formattedEntries'); + } + + // Log response data if available. + if (data != null) { + final String prettyData = encoder.convert(data); + final Map parsedJson = jsonDecode(prettyData); + final String formattedEntries = parsedJson.entries + .map( + (MapEntry entry) => + '"${entry.key}": "${entry.value}"', + ) + .join(',\n'); + stringBuffer.writeln('• DATA\t ─► $formattedEntries'); + } + + return stringBuffer.toString(); + } +} diff --git a/infrastructure/lib/analytics/reporters/talker/logs/failure_log.dart b/infrastructure/lib/analytics/reporters/talker/logs/failure_log.dart new file mode 100644 index 0000000..1e9e17b --- /dev/null +++ b/infrastructure/lib/analytics/reporters/talker/logs/failure_log.dart @@ -0,0 +1,70 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/intl.dart'; +import 'package:deps/packages/talker_flutter.dart'; +import 'package:infrastructure/_core/commons/enums/failure_type.enum.dart'; +import 'package:infrastructure/_core/commons/extensions/string.ext.dart'; +import 'package:infrastructure/analytics/failure/failure.dart'; + +/// A custom log class for handling and logging system failures using Talker. +/// +/// The `FailureLog` class formats and logs failure information, including the failure type, +/// code, message, and optionally an exception if one is provided. It distinguishes between +/// different levels of severity, setting the log level to either `warning` or `critical` +/// based on the type of failure. +class FailureLog extends TalkerLog { + FailureLog(this.failure) : super(''); + + /// The `Failure` instance that contains the details of the failure to be logged. + final Failure failure; + + /// Generates a log message for the failure, including relevant details such as + /// the failure code, message, tag, and exception if present. + @override + String generateTextMessage({TimeFormat? timeFormat}) { + return _createMessage(); + } + + /// Determines the log level based on the failure type. + /// + /// If the failure is an exception, the log level is set to `warning`. + /// For other types of failures, the log level is set to `critical`. + @override + LogLevel get logLevel => failure.type == FailureTypeEnum.exception + ? LogLevel.warning + : LogLevel.critical; + + /// Sets the color of the log output based on the failure type. + /// + /// Exceptions are displayed in an orange-like color (`xterm(208)`), while other failures + /// are shown in red (`xterm(196)`). + @override + AnsiPen get pen => + AnsiPen()..xterm(failure.type == FailureTypeEnum.exception ? 208 : 196); + + /// Formats the current time as `HH:mm:ss.SSS` for inclusion in the log message. + String get _formatTime => DateFormat('HH:mm:ss.SSS').format(DateTime.now()); + + /// Creates the log message with failure details, including the type of failure, + /// tag, code, message, and exception if available. + String _createMessage() { + final String type = + failure.type == FailureTypeEnum.exception ? 'EXCEPTION' : 'FAILURE'; + final StringBuffer stringBuffer = StringBuffer() + ..writeln('\n« $type on $_formatTime »') + ..writeln('• TAG ─► ${failure.tag.name.capitalize}') + ..writeln('• CODE ─► ${failure.code}') + ..writeln('• MESSAGE ─► ${failure.message}'); + + // If an exception is present, include it in the log. + if (failure.exception != null) { + stringBuffer.writeln('• EXCEPTION ─► ${failure.exception}'); + } + + return stringBuffer.toString(); + } +} diff --git a/infrastructure/lib/analytics/reporters/talker/logs/route_log.dart b/infrastructure/lib/analytics/reporters/talker/logs/route_log.dart new file mode 100644 index 0000000..57f0a92 --- /dev/null +++ b/infrastructure/lib/analytics/reporters/talker/logs/route_log.dart @@ -0,0 +1,57 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/intl.dart'; +import 'package:deps/packages/talker_flutter.dart'; +import 'package:flutter/material.dart'; + +/// A custom log class for handling and logging route changes using Talker. +/// +/// The `RouteLog` class formats and logs details about route changes (push and pop events) +/// in a Flutter app's navigation stack. It logs the route name, status (whether the route +/// was pushed or popped), and any arguments passed to the route. +class RouteLog extends TalkerLog { + RouteLog({required this.route, this.isPush = true}) : super(''); + + /// Indicates whether the route was pushed or popped. + /// + /// If `true`, the route was pushed onto the navigation stack. If `false`, the route was popped off. + final bool isPush; + + /// The route that was pushed or popped. + final Route route; + + /// Generates the log message for the route event, including route name, status, and arguments. + @override + String generateTextMessage({TimeFormat? timeFormat}) { + return _createMessage(); + } + + /// Specifies the color of the log output. + /// + /// The color is set to a light green (`xterm(153)`). + @override + AnsiPen get pen => AnsiPen()..xterm(153); + + /// Formats the current time as `HH:mm:ss.SSS` for inclusion in the log message. + String get _formatTime => DateFormat('HH:mm:ss.SSS').format(DateTime.now()); + + /// Creates the log message with route details, including the name, status, and any arguments passed to the route. + String _createMessage() { + final StringBuffer stringBuffer = StringBuffer() + ..writeln('\n« ROUTER on $_formatTime »') + ..writeln('• NAME\t ─► ${route.settings.name ?? 'null'}') + ..writeln('• STATUS\t─► ${isPush ? 'Pushed' : 'Popped'}'); + + // If arguments were passed to the route, include them in the log. + final Object? args = route.settings.arguments; + if (args != null) { + stringBuffer.write('• ARGS\t ─► $args'); + } + + return stringBuffer.toString(); + } +} diff --git a/infrastructure/lib/analytics/reporters/talker/talker_logger.dart b/infrastructure/lib/analytics/reporters/talker/talker_logger.dart new file mode 100644 index 0000000..dd32b11 --- /dev/null +++ b/infrastructure/lib/analytics/reporters/talker/talker_logger.dart @@ -0,0 +1,42 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/injectable.dart'; +import 'package:deps/packages/talker_flutter.dart'; +import 'package:infrastructure/analytics/failure/failure.dart'; +import 'package:infrastructure/analytics/reporters/i_logger.dart'; +import 'package:infrastructure/analytics/reporters/talker/logs/debug_log.dart'; +import 'package:infrastructure/analytics/reporters/talker/logs/failure_log.dart'; + +/// A concrete implementation of the `ILogger` interface using `Talker` for logging. +/// +/// This logger supports logging debug information, errors, and exceptions +/// through the `Talker` package. It wraps the `Talker` functionality with a +/// custom log type for each logging level (e.g., `DebugLog`, `FailureLog`). +@LazySingleton(as: ILogger) +class TalkerLogger implements ILogger { + const TalkerLogger(this._talker); + + final Talker _talker; + + /// Logs debug information with an optional message. + @override + void debug(dynamic data, [String? message]) { + _talker.logTyped(DebugLog(data, message)); + } + + /// Logs an error using the `Failure` log type. + @override + void error(Failure failure) { + _talker.logTyped(FailureLog(failure)); + } + + /// Logs an exception using the `Failure` log type. + @override + void exception(Failure failure) { + _talker.logTyped(FailureLog(failure)); + } +} diff --git a/infrastructure/lib/flavors/dev.env.dart b/infrastructure/lib/flavors/dev.env.dart new file mode 100644 index 0000000..2c1df97 --- /dev/null +++ b/infrastructure/lib/flavors/dev.env.dart @@ -0,0 +1,38 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/envied.dart'; +import 'package:deps/packages/injectable.dart'; +import 'package:infrastructure/flavors/i_env.dart'; + +part 'dev.env.g.dart'; + +/// Development environment implementation of the `IEnv` interface. +/// This class fetches its configuration from the `../.env.dev` file. +/// +/// The `Envied` annotation is used to load the environment variables +/// from the `.env.dev` file into the fields defined in this class. +@Environment('dev') +@Singleton(as: IEnv) +@Envied(path: '../.env.dev') +class DevEnv implements IEnv { + /// Indicates whether the development environment is in debug mode. + /// Set to `true` for debugging purposes. + @override + final bool isDebug = true; + + /// URL for analytics services in the development environment. + /// This value is fetched from the `ANALYTICS_URL` variable in the `.env.dev` file. + @override + @EnviedField(varName: 'ANALYTICS_URL') + final String analyticsUrl = _DevEnv.analyticsUrl; + + /// API base URL for the development environment. + /// This value is fetched from the `API_URL` variable in the `.env.dev` file. + @override + @EnviedField(varName: 'API_URL') + final String apiUrl = _DevEnv.apiUrl; +} diff --git a/infrastructure/lib/flavors/i_env.dart b/infrastructure/lib/flavors/i_env.dart new file mode 100644 index 0000000..a98fc42 --- /dev/null +++ b/infrastructure/lib/flavors/i_env.dart @@ -0,0 +1,19 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +/// Interface that defines the environment configuration. +/// This class is designed to be implemented by different +/// environment-specific configurations (e.g., `EnvProd`, `EnvDev`). +abstract interface class IEnv { + /// API base URL for the environment. + abstract final String apiUrl; + + /// URL for analytics services for the environment. + abstract final String analyticsUrl; + + /// Indicates whether the environment is in debug mode. + abstract final bool isDebug; +} diff --git a/infrastructure/lib/flavors/prod.env.dart b/infrastructure/lib/flavors/prod.env.dart new file mode 100644 index 0000000..f942eff --- /dev/null +++ b/infrastructure/lib/flavors/prod.env.dart @@ -0,0 +1,38 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/envied.dart'; +import 'package:deps/packages/injectable.dart'; +import 'package:infrastructure/flavors/i_env.dart'; + +part 'prod.env.g.dart'; + +/// Production environment implementation of the `IEnv` interface. +/// This class fetches its configuration from the `../.env.prod` file. +/// +/// The `Envied` annotation is used to load the environment variables +/// from the `.env.prod` file into the fields defined in this class. +@Environment('prod') +@Singleton(as: IEnv) +@Envied(path: '../.env.prod') +class ProdEnv implements IEnv { + /// Indicates whether the production environment is in debug mode. + /// Set to `true` for debugging purposes in this example. + @override + final bool isDebug = true; + + /// URL for analytics services in the production environment. + /// This value is fetched from the `ANALYTICS_URL` variable in the `.env.prod` file. + @override + @EnviedField(varName: 'ANALYTICS_URL') + final String analyticsUrl = _ProdEnv.analyticsUrl; + + /// API base URL for the production environment. + /// This value is fetched from the `API_URL` variable in the `.env.prod` file. + @override + @EnviedField(varName: 'API_URL') + final String apiUrl = _ProdEnv.apiUrl; +} diff --git a/infrastructure/lib/infrastructure.dart b/infrastructure/lib/infrastructure.dart new file mode 100644 index 0000000..b8473c6 --- /dev/null +++ b/infrastructure/lib/infrastructure.dart @@ -0,0 +1,69 @@ +export '_core/_router/router.dart'; +export '_core/_router/router.gr.dart'; +export '_core/commons/enums/auth_status.enum.dart'; +export '_core/commons/enums/connectivity_status.enum.dart'; +export '_core/commons/enums/env.enum.dart'; +export '_core/commons/enums/failure_tag.enum.dart'; +export '_core/commons/enums/failure_type.enum.dart'; +export '_core/commons/enums/log_type.enum.dart'; +export '_core/commons/enums/request_type.enum.dart'; +export '_core/commons/extensions/color.ext.dart'; +export '_core/commons/extensions/context.ext.dart'; +export '_core/commons/extensions/date_time.ext.dart'; +export '_core/commons/extensions/double.ext.dart'; +export '_core/commons/extensions/duration.ext.dart'; +export '_core/commons/extensions/generics.ext.dart'; +export '_core/commons/extensions/int.ext.dart'; +export '_core/commons/extensions/string.ext.dart'; +export '_core/commons/extensions/text_style.ext.dart'; +export '_core/commons/failures/unexpected_failures.dart'; +export '_core/commons/typedefs/either.typedef.dart'; +export 'analytics/failure/failure.dart'; +export 'analytics/observers/talker/bloc_talker_observer.dart'; +export 'analytics/observers/talker/dio_talker_observer.dart'; +export 'analytics/observers/talker/router_talker_observer.dart'; +export 'analytics/reporters/i_analytics.dart'; +export 'analytics/reporters/i_logger.dart'; +export 'flavors/i_env.dart'; +export 'networking/api_client/dio/dio_token_refresh.dart'; +export 'networking/api_client/i_api_client.dart'; +export 'networking/connectivity/connectivity.cubit.dart'; +export 'networking/failures/network_failures.dart'; +export 'networking/models/token.model.dart'; +export 'presentation/_core/dialog/dialog_config.dart'; +export 'presentation/_core/modal/modal_config.dart'; +export 'presentation/_core/sheet/sheet_config.dart'; +export 'presentation/contexts/dialog_context.dart'; +export 'presentation/contexts/overlay_context.dart'; +export 'presentation/contexts/toast_context.dart'; +export 'presentation/cubits/paginated_list.cubit.dart'; +export 'presentation/extensions/styled_text.ext.dart'; +export 'presentation/main_binding.dart'; +export 'presentation/models/paginated.model.dart'; +export 'presentation/super_class.dart'; +export 'presentation/validators/reactive_validators.dart'; +export 'presentation/widgets/_core/paddings/padding_all.dart'; +export 'presentation/widgets/_core/paddings/padding_bottom.dart'; +export 'presentation/widgets/_core/paddings/padding_gap.dart'; +export 'presentation/widgets/_core/paddings/padding_left.dart'; +export 'presentation/widgets/_core/paddings/padding_only.dart'; +export 'presentation/widgets/_core/paddings/padding_right.dart'; +export 'presentation/widgets/_core/paddings/padding_symmetric.dart'; +export 'presentation/widgets/_core/paddings/padding_top.dart'; +export 'presentation/widgets/_core/radiuses/radius_all.dart'; +export 'presentation/widgets/_core/radiuses/radius_horizontal.dart'; +export 'presentation/widgets/_core/radiuses/radius_only.dart'; +export 'presentation/widgets/_core/radiuses/radius_vertical.dart'; +export 'presentation/widgets/animated_box_decoration/smooth_animated_container.dart'; +export 'presentation/widgets/animated_box_decoration/smooth_decoration_tween.dart'; +export 'presentation/widgets/blend_mask.dart'; +export 'presentation/widgets/nil.dart'; +export 'presentation/widgets/paginated_list/paginated_list.dart'; +export 'presentation/widgets/transparent_pointer.dart'; +export 'presentation/widgets/widget_fader.dart'; +export 'presentation/wrappers/app_wrapper.dart'; +export 'storage/caches/i_cache.dart'; +export 'storage/file_storage/i_file_storage.dart'; +export 'storage/file_storage/token/secure_token_file_storage.dart'; +export 'storage/file_storage/token/token_file_storage_mixin.dart'; +export 'translations/translations.cubit.dart'; diff --git a/infrastructure/lib/networking/api_client/dio/dio_client.dart b/infrastructure/lib/networking/api_client/dio/dio_client.dart new file mode 100644 index 0000000..ec31543 --- /dev/null +++ b/infrastructure/lib/networking/api_client/dio/dio_client.dart @@ -0,0 +1,185 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'dart:io'; + +import 'package:deps/packages/dio.dart'; +import 'package:deps/packages/dio_smart_retry.dart'; +import 'package:deps/packages/fpdart.dart'; +import 'package:deps/packages/injectable.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:infrastructure/_core/commons/enums/request_type.enum.dart'; +import 'package:infrastructure/_core/commons/failures/parsing_failures.dart'; +import 'package:infrastructure/_core/commons/failures/unexpected_failures.dart'; +import 'package:infrastructure/_core/commons/typedefs/either.typedef.dart'; +import 'package:infrastructure/_core/commons/typedefs/model_from_json.typedef.dart'; +import 'package:infrastructure/analytics/failure/failure.dart'; +import 'package:infrastructure/analytics/reporters/i_logger.dart'; +import 'package:infrastructure/networking/api_client/dio/dio_failure.dart'; +import 'package:infrastructure/networking/api_client/dio/dio_token_refresh.dart'; +import 'package:infrastructure/networking/api_client/dio/interceptors/dio_http_failures_interceptor.dart'; +import 'package:infrastructure/networking/api_client/dio/interceptors/dio_other_failures_interceptor.dart'; +import 'package:infrastructure/networking/api_client/i_api_client.dart'; +import 'package:infrastructure/networking/models/token.model.dart'; +import 'package:infrastructure/presentation/models/paginated.model.dart'; +import 'package:infrastructure/storage/file_storage/token/token_file_storage_mixin.dart'; + +@LazySingleton(as: IApiClient) +class DioClient implements IApiClient { + DioClient(this._dio, this._logger, this._tokenRefresh) { + _dio + ..options.headers['Accept-Language'] = + kIsWeb ? 'en-US' : Platform.localeName.characters.getRange(0, 2) + ..options.connectTimeout = const Duration(seconds: 5) + ..options.receiveTimeout = const Duration(seconds: 5) + ..interceptors.add(_tokenRefresh.interceptor) + ..interceptors.add(const DioHttpFailuresInterceptor()) + ..interceptors.add(const DioOtherFailuresInterceptor()) + ..interceptors.add( + RetryInterceptor(dio: _dio, logPrint: _logger.debug, retries: 2), + ); + } + + final Dio _dio; + + final ILogger _logger; + + final DioTokenRefresh _tokenRefresh; + + @override + AsyncEither invoke( + String path, + RequestTypeEnum requestType, { + ModelFromJson? fromJson, + Map? queryParameters, + Map? data, + }) async { + Response response; + + final bool isMbasicType = M.toString() == 'String' || + M.toString() == 'double' || + M.toString() == 'int' || + M.toString() == 'bool'; + final bool isSMbasicType = SM.toString() == 'String' || + SM.toString() == 'double' || + SM.toString() == 'int' || + SM.toString() == 'bool'; + final bool isMCollection = M.toString().startsWith('List<') || + M.toString().startsWith('PaginatedModel<'); + final bool isSMCollection = SM.toString().startsWith('List<') || + SM.toString().startsWith('PaginatedModel<'); + + assert( + M != Unit && !isMbasicType && fromJson != null, + 'When (M)odel is not void and not a basic type then fromJson cannot be null.', + ); + + assert( + !isMCollection || SM.toString() != 'void', + 'When (M)odel is a List or PaginatedModel, then (S)ingle (M)odel cannot be void.', + ); + + assert( + !isMCollection || (SM.toString() != 'void' && !isSMCollection), + 'When (M)odel is a List or PaginatedModel, then (S)ingle (M)odel cannot be void or another List or PaginatedModel.', + ); + + assert( + !isMCollection || !isSMbasicType || fromJson != null, + 'When (M)odel is a List or PaginatedModel and (S)ingle (M)odel is not a basic type, then fromJson cannot be null.', + ); + + try { + // Handling different request types. + switch (requestType) { + case RequestTypeEnum.get: + response = await _dio.get( + path, + queryParameters: queryParameters, + ); + + case RequestTypeEnum.post: + response = await _dio.post( + path, + data: data, + queryParameters: queryParameters, + ); + + case RequestTypeEnum.put: + response = await _dio.put( + path, + data: data, + ); + + case RequestTypeEnum.delete: + response = await _dio.delete( + path, + data: data, + queryParameters: queryParameters, + ); + + case RequestTypeEnum.patch: + response = await _dio.patch( + path, + data: data, + queryParameters: queryParameters, + ); + } + + try { + if (M == Unit) { + return Right(unit as M); + } else { + if (isMbasicType) { + return Right(response.data as M); + } else if (M.toString().startsWith('List<')) { + final List list = + (response.data as List).map((dynamic item) { + if (isSMbasicType) { + return item as SM; + } + + return fromJson!(item as Map) as SM; + }).toList(); + + return Right(list as M); + } else if (M.toString().startsWith('PaginatedModel<')) { + final PaginatedModel paginatedList = + PaginatedModel.fromJson( + response.data as Map, + (dynamic json) => fromJson!(json as Map), + ); + return Right(paginatedList as M); + } else { + return Right( + fromJson!(response.data as Map) as M); + } + } + } catch (exception) { + return Left(ApiResponseParsingError(exception: exception)); + } + } on DioFailure catch (exception) { + return Left(exception.failure); + } catch (exception) { + return Left(UnexpectedError(exception: exception)); + } + } + + @override + void changeBaseUrl(String baseUrl) { + _dio.options.baseUrl = baseUrl; + } + + @override + void setObserver(Interceptor interceptor) { + _dio.interceptors.add(interceptor); + } + + @override + TokenFileStorageMixin get tokenStorage => + _tokenRefresh.interceptor; +} diff --git a/infrastructure/lib/networking/api_client/dio/dio_failure.dart b/infrastructure/lib/networking/api_client/dio/dio_failure.dart new file mode 100644 index 0000000..e1201f4 --- /dev/null +++ b/infrastructure/lib/networking/api_client/dio/dio_failure.dart @@ -0,0 +1,25 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/dio.dart'; +import 'package:infrastructure/analytics/failure/failure.dart'; + +/// A custom exception class for handling failures in Dio-based HTTP requests. +/// +/// [DioFailure] extends [DioException], encapsulating a [Failure] object. This +/// class is useful for managing custom error handling in network operations, +/// allowing the app to gracefully handle different types of failures. +class DioFailure extends DioException { + /// Constructs a [DioFailure] with the given [failure] and [requestOptions]. + /// + /// * [failure]: The [Failure] object representing the specific failure that occurred. + /// * [requestOptions]: The options that were set on the Dio request that triggered the failure. + DioFailure({required this.failure, required super.requestOptions}) + : super(error: failure); + + /// The [Failure] instance encapsulating details about the failure. + final Failure failure; +} diff --git a/infrastructure/lib/networking/api_client/dio/dio_token_refresh.dart b/infrastructure/lib/networking/api_client/dio/dio_token_refresh.dart new file mode 100644 index 0000000..9d2aac0 --- /dev/null +++ b/infrastructure/lib/networking/api_client/dio/dio_token_refresh.dart @@ -0,0 +1,95 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/dio.dart'; +import 'package:deps/packages/injectable.dart'; +import 'package:infrastructure/networking/api_client/dio/interceptors/dio_token_refresh_interceptor.dart'; +import 'package:infrastructure/networking/failures/network_errors.dart'; +import 'package:infrastructure/networking/models/token.model.dart'; +import 'package:infrastructure/storage/file_storage/i_file_storage.dart'; + +/// Handles the logic for refreshing access tokens when they expire in Dio-based HTTP requests. +/// +/// The [DioTokenRefresh] class provides the logic for refreshing tokens via the +/// `DioTokenRefreshInterceptor`, storing the tokens in secure storage, and ensuring the +/// authorization header is properly updated for authenticated API calls. +@lazySingleton +class DioTokenRefresh { + /// Constructor to initialize [DioTokenRefresh] with required dependencies. + /// + /// * [_storage]: A storage instance to persist the token, typically using `IFileStorage`. + DioTokenRefresh(this._storage); + + /// The interceptor responsible for refreshing tokens on failed requests. + /// + /// The [DioTokenRefreshInterceptor] listens for failed requests due to token expiration and + /// attempts to refresh the token automatically. + late final DioTokenRefreshInterceptor interceptor = + DioTokenRefreshInterceptor( + onRefreshToken: _onRefreshToken, + handleTokenHeaderBuilder: _handleTokenHeaderBuilder, + tokenStorage: _storage, + ); + + /// The storage system for persisting the token. + final IFileStorage _storage; + + /// Tracks the number of retry attempts to refresh the token. + double _totalRetryCount = 0; + + /// Builds the `Authorization` header using the provided token. + /// + /// This function constructs a map containing the `Authorization` header + /// with the format `Bearer `. + /// + /// * [token]: The token model containing the access token and token type. + Map _handleTokenHeaderBuilder(TokenModel token) { + return { + 'Authorization': '${token.tokenType} ${token.accessToken}', + }; + } + + /// Refreshes the access token when the current token is expired. + /// + /// This function interacts with the `SsoService` to obtain a new token by using + /// the refresh token. If the token has expired or too many retries have been attempted, + /// an [UnexpectedTokenRefreshNetworkError] is thrown. + /// + /// * [dio]: The Dio instance to make the request with. + /// * [token]: The current token to be refreshed. + /// + /// Returns a new [TokenModel] containing the refreshed access and refresh tokens. + /// + /// Throws [UnexpectedTokenRefreshNetworkError] if the refresh fails. + Future _onRefreshToken( + {required Dio dio, required TokenModel? token}) async { + _totalRetryCount += 1; + + if (token == null) { + throw UnexpectedTokenRefreshNetworkError(); + } + + // Check if the token needs refreshing based on the expiration date. + final bool shouldRefreshToken = + token.expirationDate.difference(DateTime.now()).isNegative; + + if (shouldRefreshToken || _totalRetryCount > 2) { + try { + // TODO: The request to renew the token will be sent here. + // Attempt to refresh the token using the refresh token. + // const TokenModel? result = null; + + return TokenModel.empty(); + } catch (exception) { + // Throw an error if token refresh fails. + throw UnexpectedTokenRefreshNetworkError(exception: exception); + } + } + + // Return the existing token if it doesn't require refreshing. + return token; + } +} diff --git a/infrastructure/lib/networking/api_client/dio/interceptors/dio_http_failures_interceptor.dart b/infrastructure/lib/networking/api_client/dio/interceptors/dio_http_failures_interceptor.dart new file mode 100644 index 0000000..948fab6 --- /dev/null +++ b/infrastructure/lib/networking/api_client/dio/interceptors/dio_http_failures_interceptor.dart @@ -0,0 +1,99 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/dio.dart'; +import 'package:infrastructure/analytics/failure/failure.dart'; +import 'package:infrastructure/networking/api_client/dio/dio_failure.dart'; +import 'package:infrastructure/networking/failures/network_failures.dart'; + +/// Interceptor that catches HTTP errors and maps them to custom network failure classes. +/// +/// The [DioHttpFailuresInterceptor] extends Dio's [Interceptor] and provides custom +/// error handling for common HTTP status codes (e.g., 400, 401, 404, 500). It converts +/// the errors into `Failure` objects, which can then be processed by the app. +class DioHttpFailuresInterceptor extends Interceptor { + /// Constructor for [DioHttpFailuresInterceptor]. + /// + /// This interceptor is stateless, so it can be reused as a constant instance. + const DioHttpFailuresInterceptor(); + + /// Handles HTTP errors by checking the response's status code and mapping it to + /// appropriate network failure types. + /// + /// If the response contains a known error status code, such as 400 or 500, it is + /// converted into a `Failure` subclass. These failure objects are then wrapped + /// in a [DioFailure] and passed to the error handler. + /// + /// * [err]: The [DioException] containing details about the failed request. + /// * [handler]: The error handler for this interceptor. + /// + /// This method overrides Dio's default [onError] behavior to provide more + /// specific error handling based on the HTTP status code. + @override + Future onError( + DioException err, + ErrorInterceptorHandler handler, + ) async { + // Extract the HTTP response from the error. + final Response? response = err.response; + + // Check if the response and status code are available. + if (response != null && response.statusCode != null) { + final int statusCode = response.statusCode ?? 0; + Failure? failure; + + // Map specific HTTP status codes to corresponding Failure types. + switch (statusCode) { + case 400: + failure = + BadRequestNetworkFailure(exception: err, stack: err.stackTrace); + + case 401: + failure = + UnauthorizedNetworkFailure(exception: err, stack: err.stackTrace); + + case 403: + failure = + ForbiddenNetworkFailure(exception: err, stack: err.stackTrace); + + case 404: + failure = + NotFoundNetworkFailure(exception: err, stack: err.stackTrace); + + case 408: + failure = RequestTimeoutNetworkFailure( + exception: err, stack: err.stackTrace); + + case 429: + failure = TooManyRequestsNetworkFailure( + exception: err, stack: err.stackTrace); + + case 500: + failure = InternalServerNetworkFailure( + exception: err, stack: err.stackTrace); + + case > 400 && < 500: + failure = + ClientErrorNetworkFailure(exception: err, stack: err.stackTrace); + + case > 500: + failure = + ServerErrorNetworkFailure(exception: err, stack: err.stackTrace); + } + + // If a failure was identified, reject the request and pass the custom error. + if (failure != null) { + handler.reject( + DioFailure(failure: failure, requestOptions: err.requestOptions), + ); + return; + } + } + + // If no specific failure type matches, proceed with the original error handling. + handler.next(err); + } +} diff --git a/infrastructure/lib/networking/api_client/dio/interceptors/dio_other_failures_interceptor.dart b/infrastructure/lib/networking/api_client/dio/interceptors/dio_other_failures_interceptor.dart new file mode 100644 index 0000000..05270a4 --- /dev/null +++ b/infrastructure/lib/networking/api_client/dio/interceptors/dio_other_failures_interceptor.dart @@ -0,0 +1,99 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/locator/locator.dart'; +import 'package:deps/packages/dio.dart'; +import 'package:infrastructure/_core/commons/enums/connectivity_status.enum.dart'; +import 'package:infrastructure/analytics/failure/failure.dart'; +import 'package:infrastructure/networking/api_client/dio/dio_failure.dart'; +import 'package:infrastructure/networking/connectivity/connectivity.cubit.dart'; +import 'package:infrastructure/networking/failures/network_errors.dart'; +import 'package:infrastructure/networking/failures/network_failures.dart'; + +/// Interceptor that handles non-HTTP related failures, such as timeouts, connection issues, etc. +/// +/// The [DioOtherFailuresInterceptor] extends Dio's [Interceptor] and provides custom +/// error handling for connection issues, timeouts, certificate errors, and more. It maps +/// these errors to specific `Failure` types. +class DioOtherFailuresInterceptor extends Interceptor { + /// Constructor for [DioOtherFailuresInterceptor]. + /// + /// This interceptor is stateless, so it can be reused as a constant instance. + const DioOtherFailuresInterceptor(); + + /// Handles Dio exceptions that are not related to specific HTTP status codes. + /// + /// This method overrides Dio's default [onError] behavior to provide more + /// specific error handling based on the [DioExceptionType]. Each type of + /// DioException is mapped to a custom [Failure] subclass. + /// + /// * [err]: The [DioException] containing details about the failed request. + /// * [handler]: The error handler for this interceptor. + /// + /// It categorizes errors such as timeouts, connection issues, or certificate + /// problems and maps them to corresponding [Failure] classes. + @override + Future onError( + DioException err, + ErrorInterceptorHandler handler, + ) async { + Failure failure; + + // Map DioExceptionType to corresponding Failure classes + switch (err.type) { + case DioExceptionType.connectionTimeout: + failure = ConnectionTimeoutNetworkFailure( + exception: err, stack: err.stackTrace); + + case DioExceptionType.sendTimeout: + failure = + SendTimeoutNetworkFailure(exception: err, stack: err.stackTrace); + + case DioExceptionType.receiveTimeout: + failure = + ReceiveTimeoutNetworkFailure(exception: err, stack: err.stackTrace); + + case DioExceptionType.badCertificate: + failure = + BadCertificateNetworkFailure(exception: err, stack: err.stackTrace); + + case DioExceptionType.badResponse: + failure = + BadResponseNetworkFailure(exception: err, stack: err.stackTrace); + + case DioExceptionType.cancel: + failure = + RequestCancelNetworkFailure(exception: err, stack: err.stackTrace); + + case DioExceptionType.connectionError: + failure = + ConnectionNetworkFailure(exception: err, stack: err.stackTrace); + + // Handle unknown exceptions or uncategorized errors + case DioExceptionType.unknown: + default: + // Check the current connectivity status using the ConnectivityCubit + final ConnectivityStatusEnum connectivityStatus = + locator().state; + + // If there's no response and the network is disconnected, return NoNetworkFailure + if (err.response == null && + connectivityStatus == ConnectivityStatusEnum.disconnected) { + failure = NoNetworkFailure(exception: err); + } else { + // Otherwise, categorize it as an unexpected network error + failure = + UnexpectedNetworkError(exception: err, stack: err.stackTrace); + } + break; + } + + // Reject the request with the custom DioFailure wrapping the mapped Failure + handler.reject( + DioFailure(failure: failure, requestOptions: err.requestOptions), + ); + } +} diff --git a/infrastructure/lib/networking/api_client/dio/interceptors/dio_token_refresh_interceptor.dart b/infrastructure/lib/networking/api_client/dio/interceptors/dio_token_refresh_interceptor.dart new file mode 100644 index 0000000..15c1481 --- /dev/null +++ b/infrastructure/lib/networking/api_client/dio/interceptors/dio_token_refresh_interceptor.dart @@ -0,0 +1,252 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'dart:async'; + +import 'package:deps/packages/dio.dart'; +import 'package:infrastructure/networking/failures/network_errors.dart'; +import 'package:infrastructure/storage/file_storage/i_file_storage.dart'; +import 'package:infrastructure/storage/file_storage/token/token_file_storage_mixin.dart'; + +/// A typedef for the function that determines if the token should be refreshed based on the response. +typedef ShouldRefresh = bool Function(Response? response); + +/// A typedef for the function that handles refreshing the token. +typedef RefreshToken = Future Function( + {required Dio dio, required T? token}); + +/// A typedef for the function that builds headers using the refreshed token. +typedef TokenHeaderBuilder = Map Function(T token); + +/// Represents a request that is waiting for a token refresh before being retried. +class _RetryRequest { + const _RetryRequest(this.options, this.completer); + + /// The original request options that failed due to token expiration. + final RequestOptions options; + + /// The completer that will complete once the request is retried. + final Completer> completer; +} + +/// [DioTokenRefreshInterceptor] manages token refreshing when API requests encounter token expiration. +/// +/// It intercepts failed requests, refreshes the token if necessary, retries the failed requests, +/// and attaches the new token to the retried requests. +class DioTokenRefreshInterceptor extends Interceptor + with TokenFileStorageMixin { + DioTokenRefreshInterceptor({ + required RefreshToken onRefreshToken, + required TokenHeaderBuilder handleTokenHeaderBuilder, + required IFileStorage tokenStorage, + Dio? httpClient, + ShouldRefresh? shouldRefresh, + }) : _onRefreshToken = onRefreshToken, + _handleTokenHeaderBuilder = handleTokenHeaderBuilder, + _onShouldRefresh = shouldRefresh ?? shouldRefreshDefault, + _dio = httpClient ?? Dio() { + this.tokenStorage = tokenStorage; + } + + final Dio _dio; + final TokenHeaderBuilder _handleTokenHeaderBuilder; + final RefreshToken _onRefreshToken; + final ShouldRefresh _onShouldRefresh; + + bool _isRefreshing = false; + final List<_RetryRequest> _failedRequests = <_RetryRequest>[]; + + /// Intercepts failed requests due to token expiration and refreshes the token. + /// + /// This method checks if the error is related to token expiration. If so, it initiates the + /// token refresh process, then retries the failed request with the new token. If the token + /// is already being refreshed, it queues the request until the token is available. + @override + Future onError( + DioException err, ErrorInterceptorHandler handler) async { + final Response? response = err.response; + + // If no response, no token, or another refresh error, proceed with the error handling. + if (response == null || + await token == null || + err.error is UnexpectedTokenRefreshNetworkError || + !_onShouldRefresh(response)) { + handler.next(err); + return; + } + + if (_isRefreshing) { + // Queue the request until the token refresh is completed. + final Completer> completer = + Completer>(); + _failedRequests.add(_RetryRequest(err.requestOptions, completer)); + try { + final Response response = await completer.future; + handler.resolve(response); + } on DioException catch (exception) { + handler.next( + DioException( + requestOptions: exception.requestOptions, + response: exception.response, + error: exception, + ), + ); + } catch (exception) { + handler.next( + DioException(requestOptions: err.requestOptions, error: exception)); + } + } else { + _isRefreshing = true; + try { + // Refresh the token and retry the failed requests. + final T newToken = await _onRefreshToken(dio: _dio, token: await token); + await setToken(newToken); + await _retryFailedRequests(newToken); + final Response retryResponse = + await _retryRequest(err.requestOptions, newToken); + handler.resolve(retryResponse); + } on UnexpectedTokenRefreshNetworkError catch (exception) { + await clearToken(); + _rejectFailedRequests(exception); + handler.next( + DioException(requestOptions: err.requestOptions, error: exception)); + } on DioException catch (exception) { + _rejectFailedRequests(exception); + handler.next( + DioException( + requestOptions: exception.requestOptions, + response: exception.response, + error: exception, + ), + ); + } catch (exception) { + _rejectFailedRequests(exception); + handler.next( + DioException(requestOptions: err.requestOptions, error: exception)); + } finally { + _isRefreshing = false; + } + } + } + + /// Intercepts outgoing requests and attaches the token if available. + /// + /// This method adds the stored token to the headers of each outgoing request. + @override + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + final T? currentToken = await token; + final RequestOptions requestOptions = options; + final Map headers = currentToken == null + ? const {} + : _handleTokenHeaderBuilder(currentToken); + requestOptions.headers = { + ...requestOptions.headers, + ...headers + }; + handler.next(requestOptions); + } + + /// Intercepts incoming responses to refresh the token if needed. + /// + /// If the response indicates that the token has expired (e.g., a 401 status code), + /// this method attempts to refresh the token. + @override + Future onResponse( + Response response, + ResponseInterceptorHandler handler, + ) async { + if (await token == null || !_onShouldRefresh(response)) { + handler.next(response); + return; + } + try { + final Response refreshResponse = await _tryRefresh(response); + handler.resolve(refreshResponse); + } on DioException catch (error) { + handler.reject(error); + } + } + + /// Default implementation for determining if the token should be refreshed. + /// + /// By default, the token is refreshed if the response has a 401 status code. + static bool shouldRefreshDefault(Response? response) { + return response?.statusCode == 401; + } + + /// Retry all failed requests after refreshing the token. + Future _retryFailedRequests(T newToken) async { + final List<_RetryRequest> requests = + List<_RetryRequest>.of(_failedRequests); + _failedRequests.clear(); + for (final _RetryRequest request in requests) { + try { + final Response response = + await _retryRequest(request.options, newToken); + request.completer.complete(response); + } catch (error) { + request.completer.completeError(error); + } + } + } + + /// Retry a single failed request with the refreshed token. + Future> _retryRequest( + RequestOptions requestOptions, T token) { + _dio.options.baseUrl = requestOptions.baseUrl; + return _dio.request( + requestOptions.path, + data: requestOptions.data, + queryParameters: requestOptions.queryParameters, + cancelToken: requestOptions.cancelToken, + options: Options( + method: requestOptions.method, + headers: requestOptions.headers + ..addAll(_handleTokenHeaderBuilder(token)), + ), + ); + } + + /// Reject all queued requests with the provided error. + void _rejectFailedRequests(Object error) { + for (final _RetryRequest request in _failedRequests) { + request.completer.completeError(error); + } + _failedRequests.clear(); + } + + /// Attempts to refresh the token and retry the request that triggered the refresh. + Future> _tryRefresh(Response response) async { + final T refreshedToken; + try { + refreshedToken = await _onRefreshToken(dio: _dio, token: await token); + } on UnexpectedTokenRefreshNetworkError catch (error) { + await clearToken(); + throw DioException( + requestOptions: response.requestOptions, + response: response, + error: error, + ); + } + + await setToken(refreshedToken); + final Map newHeaders = { + ...response.requestOptions.headers, + ..._handleTokenHeaderBuilder(refreshedToken), + }; + + return _dio.request( + response.requestOptions.path, + options: Options( + method: response.requestOptions.method, + headers: newHeaders, + ), + ); + } +} diff --git a/infrastructure/lib/networking/api_client/i_api_client.dart b/infrastructure/lib/networking/api_client/i_api_client.dart new file mode 100644 index 0000000..4785621 --- /dev/null +++ b/infrastructure/lib/networking/api_client/i_api_client.dart @@ -0,0 +1,45 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/dio.dart'; +import 'package:infrastructure/_core/commons/enums/request_type.enum.dart'; +import 'package:infrastructure/_core/commons/typedefs/either.typedef.dart'; +import 'package:infrastructure/_core/commons/typedefs/model_from_json.typedef.dart'; +import 'package:infrastructure/networking/models/token.model.dart'; +import 'package:infrastructure/storage/file_storage/token/token_file_storage_mixin.dart'; + +/// Defines the contract for API clients, specifying core functionalities such as: +/// - Invoking API calls +/// - Changing the base URL +/// - Managing request interceptors +/// - Accessing token storage +abstract interface class IApiClient { + /// Invokes an API request with the given [path] and [requestType]. + /// + /// * [path]: The endpoint to which the request is made. + /// * [requestType]: The HTTP method (GET, POST, PUT, etc.). + /// * [modelFromJson]: A function to deserialize the JSON response into a model. + /// * [queryParameters]: Query parameters for the request. + /// * [data]: The request payload. + /// + /// Returns an `Either` representing the result of the request. + AsyncEither invoke( + String path, + RequestTypeEnum requestType, { + ModelFromJson? fromJson, + Map? queryParameters, + Map data, + }); + + /// Changes the base URL of the API client. + void changeBaseUrl(String baseUrl); + + /// Sets an observer for intercepting requests and responses. + void setObserver(Interceptor interceptor); + + /// Retrieves the token storage, which manages tokens like access and refresh tokens. + TokenFileStorageMixin get tokenStorage; +} diff --git a/infrastructure/lib/networking/connectivity/connectivity.cubit.dart b/infrastructure/lib/networking/connectivity/connectivity.cubit.dart new file mode 100644 index 0000000..bd70568 --- /dev/null +++ b/infrastructure/lib/networking/connectivity/connectivity.cubit.dart @@ -0,0 +1,44 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'dart:async'; + +import 'package:deps/packages/flutter_bloc.dart'; +import 'package:deps/packages/injectable.dart'; +import 'package:deps/packages/internet_connection_checker_plus.dart'; +import 'package:infrastructure/_core/commons/enums/connectivity_status.enum.dart'; + +/// [ConnectivityCubit] manages the network connectivity status of the application. +/// +/// It uses [InternetConnectionCheckerPlus] to check if the device has an active +/// internet connection and emits the corresponding [ConnectivityStatusEnum] to notify +/// listeners of the network status. +@lazySingleton +class ConnectivityCubit extends Cubit { + /// Creates a [ConnectivityCubit] with the given [InternetConnection] service. + /// + /// It initializes the network check by calling [checkNetwork] on instantiation. + ConnectivityCubit(this._internetConnection) + : super(ConnectivityStatusEnum.initial) { + unawaited(checkNetwork()); + } + + /// The [InternetConnection] service to check network access. + final InternetConnection _internetConnection; + + /// Checks the network status and emits the appropriate [ConnectivityStatusEnum]. + /// + /// This method checks whether the device has internet access and updates the cubit state + /// by emitting [ConnectivityStatusEnum.connected] if the internet is available, or + /// [ConnectivityStatusEnum.disconnected] otherwise. + Future checkNetwork() async { + if (await _internetConnection.hasInternetAccess) { + emit(ConnectivityStatusEnum.connected); + } else { + emit(ConnectivityStatusEnum.disconnected); + } + } +} diff --git a/infrastructure/lib/networking/failures/network_errors.dart b/infrastructure/lib/networking/failures/network_errors.dart new file mode 100644 index 0000000..1121a51 --- /dev/null +++ b/infrastructure/lib/networking/failures/network_errors.dart @@ -0,0 +1,50 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:infrastructure/_core/commons/enums/failure_tag.enum.dart'; +import 'package:infrastructure/_core/commons/enums/failure_type.enum.dart'; +import 'package:infrastructure/analytics/failure/failure.dart'; + +/// [UnexpectedNetworkError] represents a network failure when an unexpected +/// error occurs during network operations. +/// +/// This failure is typically triggered when the network state is unknown +/// or when a network request fails without specific error handling in place. +class UnexpectedNetworkError extends Failure { + /// Creates an instance of [UnexpectedNetworkError]. + /// + /// The [exception] and [stack] can be optionally provided to include more + /// detailed information about the error. + UnexpectedNetworkError({super.exception, super.stack}) + : super( + code: 'unexpected-network-error', + type: FailureTypeEnum.error, + tag: FailureTagEnum.network, + message: + 'An unexpected network error occurred. Please check your connection and try again.', + ); +} + +/// [UnexpectedTokenRefreshNetworkError] represents a network failure +/// occurring when the application is trying to refresh the user's authentication token. +/// +/// This failure indicates that an error occurred while attempting to +/// refresh the token, and a re-authentication is likely required. +class UnexpectedTokenRefreshNetworkError extends Failure { + /// Creates an instance of [UnexpectedTokenRefreshNetworkError]. + /// + /// The [exception] and [stack] parameters allow for the inclusion of more detailed + /// error information, such as the actual exception or the stack trace. + UnexpectedTokenRefreshNetworkError({super.exception, super.stack}) + : super( + code: 'unexpected-token-refresh-network-error', + type: FailureTypeEnum.error, + tag: FailureTagEnum.network, + message: + 'An unexpected error occurred while refreshing the authentication token. ' + 'Please re-authenticate and try again.', + ); +} diff --git a/infrastructure/lib/networking/failures/network_failures.dart b/infrastructure/lib/networking/failures/network_failures.dart new file mode 100644 index 0000000..e492d33 --- /dev/null +++ b/infrastructure/lib/networking/failures/network_failures.dart @@ -0,0 +1,224 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:infrastructure/_core/commons/enums/failure_tag.enum.dart'; +import 'package:infrastructure/_core/commons/enums/failure_type.enum.dart'; +import 'package:infrastructure/analytics/failure/failure.dart'; + +/// Represents a failure that occurs when there is no network connection. +class NoNetworkFailure extends Failure { + NoNetworkFailure({super.exception, super.stack}) + : super( + code: 'no-network-failure', + type: FailureTypeEnum.exception, + tag: FailureTagEnum.network, + message: + 'No network connection. Please check your internet connection and try again.', + ); +} + +/// Represents a failure that occurs when a network request times out. +class TimeoutNetworkFailure extends Failure { + TimeoutNetworkFailure({super.exception, super.stack}) + : super( + code: 'timeout-network-failure', + type: FailureTypeEnum.exception, + tag: FailureTagEnum.network, + message: + 'The network request timed out. Please check your connection and try again.', + ); +} + +/// Represents a failure that occurs when a request is formatted incorrectly. +class BadRequestNetworkFailure extends Failure { + BadRequestNetworkFailure({super.exception, super.stack}) + : super( + code: 'bad-request-failure', + type: FailureTypeEnum.exception, + tag: FailureTagEnum.network, + message: + 'The request was not formatted correctly. Please check the request format and try again.', + ); +} + +/// Represents a failure when access to a resource is unauthorized. +class UnauthorizedNetworkFailure extends Failure { + UnauthorizedNetworkFailure({super.exception, super.stack}) + : super( + code: 'unauthorized-network-failure', + type: FailureTypeEnum.exception, + tag: FailureTagEnum.network, + message: + 'Attempted to access a restricted resource without proper authorization.', + ); +} + +/// Represents a failure that occurs when access to a resource is forbidden. +class ForbiddenNetworkFailure extends Failure { + ForbiddenNetworkFailure({super.exception, super.stack}) + : super( + code: 'forbidden-network-failure', + type: FailureTypeEnum.exception, + tag: FailureTagEnum.network, + message: + 'Request was valid but server is refusing action. Please check your permissions and try again.', + ); +} + +/// Represents a failure when the requested resource cannot be found. +class NotFoundNetworkFailure extends Failure { + NotFoundNetworkFailure({super.exception, super.stack}) + : super( + code: 'not-found-network-failure', + type: FailureTypeEnum.exception, + tag: FailureTagEnum.network, + message: + 'The requested resource could not be found. Please check the URL and try again.', + ); +} + +/// Represents a failure when the server takes too long to process a request. +class RequestTimeoutNetworkFailure extends Failure { + RequestTimeoutNetworkFailure({super.exception, super.stack}) + : super( + code: 'request-timeout-network-failure', + type: FailureTypeEnum.exception, + tag: FailureTagEnum.network, + message: + 'The server timed out waiting for the request. Please attempt to reconnect and try again.', + ); +} + +/// Represents a failure when too many requests are made in a short time. +class TooManyRequestsNetworkFailure extends Failure { + TooManyRequestsNetworkFailure({super.exception, super.stack}) + : super( + code: 'too-many-requests-network-failure', + type: FailureTypeEnum.exception, + tag: FailureTagEnum.network, + message: + 'Too many requests in a given time. Please wait before trying again.', + ); +} + +/// Represents a failure when the server encounters an internal error. +class InternalServerNetworkFailure extends Failure { + InternalServerNetworkFailure({super.exception, super.stack}) + : super( + code: 'internal-server-network-failure', + type: FailureTypeEnum.exception, + tag: FailureTagEnum.network, + message: + 'The server encountered an unexpected condition. Please try again later.', + ); +} + +/// Represents a general server-side error. +class ServerErrorNetworkFailure extends Failure { + ServerErrorNetworkFailure({super.exception, super.stack}) + : super( + code: 'server-error-network-failure', + type: FailureTypeEnum.exception, + tag: FailureTagEnum.network, + message: + 'The server encountered a condition that prevented it from fulfilling the request. Please try again later.', + ); +} + +/// Represents a failure due to client-side errors. +class ClientErrorNetworkFailure extends Failure { + ClientErrorNetworkFailure({super.exception, super.stack}) + : super( + code: 'client-error-network-failure', + type: FailureTypeEnum.exception, + tag: FailureTagEnum.network, + message: + 'A client error occurred. Please check the request and try again.', + ); +} + +/// Represents a failure when the connection to the server times out. +class ConnectionTimeoutNetworkFailure extends Failure { + ConnectionTimeoutNetworkFailure({super.exception, super.stack}) + : super( + code: 'connection-timeout-network-failure', + type: FailureTypeEnum.exception, + tag: FailureTagEnum.network, + message: + 'The connection timed out. Please check your network settings and try again.', + ); +} + +/// Represents a failure when the request to the server times out while sending data. +class SendTimeoutNetworkFailure extends Failure { + SendTimeoutNetworkFailure({super.exception, super.stack}) + : super( + code: 'send-timeout-network-failure', + type: FailureTypeEnum.exception, + tag: FailureTagEnum.network, + message: + 'The request timed out while sending data. Please check your connection and try again.', + ); +} + +/// Represents a failure when the server takes too long to respond. +class ReceiveTimeoutNetworkFailure extends Failure { + ReceiveTimeoutNetworkFailure({super.exception, super.stack}) + : super( + code: 'receive-timeout-network-failure', + type: FailureTypeEnum.exception, + tag: FailureTagEnum.network, + message: + 'The server took too long to respond. Please check your connection and try again.', + ); +} + +/// Represents a failure when the server certificate is not valid. +class BadCertificateNetworkFailure extends Failure { + BadCertificateNetworkFailure({super.exception, super.stack}) + : super( + code: 'bad-certificate-network-failure', + type: FailureTypeEnum.exception, + tag: FailureTagEnum.network, + message: + "The server certificate is not valid. Please check the server's certificate and try again.", + ); +} + +/// Represents a failure when the server responds with an unexpected format or status code. +class BadResponseNetworkFailure extends Failure { + BadResponseNetworkFailure({super.exception, super.stack}) + : super( + code: 'bad-response-network-failure', + type: FailureTypeEnum.exception, + tag: FailureTagEnum.network, + message: + 'The server responded with an unexpected format or status code. Please check the response and try again.', + ); +} + +/// Represents a failure when a network request is cancelled. +class RequestCancelNetworkFailure extends Failure { + RequestCancelNetworkFailure({super.exception, super.stack}) + : super( + code: 'request-cancel-network-failure', + type: FailureTypeEnum.exception, + tag: FailureTagEnum.network, + message: 'The request was cancelled. Please try again.', + ); +} + +/// Represents a failure when there is an issue connecting to the server. +class ConnectionNetworkFailure extends Failure { + ConnectionNetworkFailure({super.exception, super.stack}) + : super( + code: 'connection-network-failure', + type: FailureTypeEnum.exception, + tag: FailureTagEnum.network, + message: + 'Failed to connect to the server. Please check your network connection and try again.', + ); +} diff --git a/infrastructure/lib/networking/models/token.model.dart b/infrastructure/lib/networking/models/token.model.dart new file mode 100644 index 0000000..9c4a0b1 --- /dev/null +++ b/infrastructure/lib/networking/models/token.model.dart @@ -0,0 +1,48 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/freezed_annotation.dart'; + +part 'token.model.freezed.dart'; +part 'token.model.g.dart'; + +/// A data class representing an OAuth token, which includes +/// access, refresh, and ID tokens, as well as token type and expiration information. +@freezed +class TokenModel with _$TokenModel { + /// The main factory constructor for the `TokenModel` class. + /// + /// - [accessToken]: The access token required for API requests. + /// - [refreshToken]: The refresh token used to obtain a new access token. + /// - [expirationDate]: The date and time when the access token expires. + /// - [idToken]: The ID token, typically used in identity verification. + /// - [tokenType]: The type of the token, with a default value of 'Bearer'. + const factory TokenModel({ + required String accessToken, + required String refreshToken, + required DateTime expirationDate, + required String idToken, + @Default('Bearer') String tokenType, + }) = _TokenModel; + + /// Creates an instance of `TokenModel` from a JSON object. + /// + /// This factory allows for easy deserialization of the token data from JSON format. + factory TokenModel.fromJson(Map json) => + _$TokenModelFromJson(json); + + /// Creates an initial empty `TokenModel`. + /// + /// This factory can be used to instantiate a default or empty `TokenModel` + /// when no valid token is available. + factory TokenModel.empty() => TokenModel( + accessToken: '', + refreshToken: '', + expirationDate: DateTime(2100), + idToken: '', + tokenType: '', + ); +} diff --git a/infrastructure/lib/permissions/_core/permission.failures.dart b/infrastructure/lib/permissions/_core/permission.failures.dart new file mode 100644 index 0000000..4b54807 --- /dev/null +++ b/infrastructure/lib/permissions/_core/permission.failures.dart @@ -0,0 +1,50 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: prefer-match-file-name, prefer-single-declaration-per-file + +import 'package:deps/infrastructure/infrastructure.dart'; +import 'package:infrastructure/_core/_i18n/infrastructure_i18n_cubit_locale.ext.dart'; +import 'package:infrastructure/_core/_i18n/translations.g.dart'; + +/// A failure that occurs when an unknown error is encountered during a permission request. +/// +/// This failure class is used to handle unexpected errors that occur when +/// requesting a permission, with the exact cause not being recognized. +/// +/// - [exception]: The underlying exception that caused the error (optional). +/// - [stack]: The stack trace of the error (optional). +class UnknownPermissionRequestError extends Failure { + UnknownPermissionRequestError({super.exception, super.stack}) + : super( + code: 'unknown-permission-request-error', + type: FailureTypeEnum.exception, + tag: FailureTagEnum.permission, + message: + $.tr.infrastructure.permissions.failures.unknownPermissionRequest, + ); +} + +/// A failure that occurs when an invalid or unsupported permission type is requested. +/// +/// This failure is thrown when an invalid or unrecognized [PermissionTypeEnum] +/// is used in a permission request, helping developers identify incorrect or +/// unsupported permission types. +/// +/// - [type]: The invalid permission type that was requested. +/// - [exception]: The underlying exception that caused the error (optional). +/// - [stack]: The stack trace of the error (optional). +class InvalidPermissionTypeFailure extends Failure { + InvalidPermissionTypeFailure( + {required PermissionTypeEnum type, super.exception, super.stack}) + : super( + code: 'invalid-permission-failure', + type: FailureTypeEnum.exception, + tag: FailureTagEnum.permission, + message: $.tr.infrastructure.permissions.failures + .invalidPermissionType(type: type), + ); +} diff --git a/infrastructure/lib/permissions/_core/permission_dialogs.dart b/infrastructure/lib/permissions/_core/permission_dialogs.dart new file mode 100644 index 0000000..bdf8272 --- /dev/null +++ b/infrastructure/lib/permissions/_core/permission_dialogs.dart @@ -0,0 +1,179 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'dart:async'; + +import 'package:deps/packages/permission_handler.dart'; +import 'package:flutter/material.dart'; +import 'package:infrastructure/_core/_i18n/infrastructure_i18n_cubit_locale.ext.dart'; +import 'package:infrastructure/_core/_i18n/translations.g.dart'; +import 'package:infrastructure/_core/commons/typedefs/future_void_callback.typedef.dart'; +import 'package:infrastructure/presentation/super_class.dart'; + +/// A class responsible for showing dialogs related to permission handling. +/// +/// It provides informative dialogs for different permission states such as +/// denied, permanently denied, restricted, limited, and provisional permissions. +/// These dialogs are designed to inform users about the permission request status +/// and guide them on next actions like retrying, opening settings, or simply +/// acknowledging the permission status. +@immutable +final class PermissionDialogs { + const PermissionDialogs(); + + /// Displays a dialog informing the user that a permission request was denied. + /// The dialog offers two actions: retrying the permission request or cancelling the request. + /// + /// - [permissionType]: The type of permission that was denied. + /// - [onRetry]: A callback function to retry the permission request. + Future informDenied( + PermissionTypeEnum permissionType, { + required FutureVoidCallback onRetry, + }) async { + await $.dialog.pushDialog( + builder: (_) => AlertDialog( + title: Text( + $.tr.infrastructure.permissions.dialog.denied + .title(context: permissionType), + ), + content: Text( + $.tr.infrastructure.permissions.dialog.denied + .description(context: permissionType), + ), + actions: [ + TextButton( + onPressed: () { + $.dialog.popDialog(); + unawaited(onRetry()); + }, + child: Text($.tr.infrastructure.permissions.dialog.buttons.retry), + ), + TextButton( + onPressed: $.dialog.popDialog, + child: Text($.tr.infrastructure.permissions.dialog.buttons.cancel), + ), + ], + ), + ); + } + + /// Displays a dialog informing the user that a permission is permanently denied. + /// The dialog guides the user to open the app settings to manually enable the permission. + /// + /// - [permissionType]: The type of permission that is permanently denied. + Future informPermanentlyDenied( + PermissionTypeEnum permissionType) async { + await $.dialog.pushDialog( + builder: (_) => AlertDialog( + title: Text( + $.tr.infrastructure.permissions.dialog.permanentlyDenied + .title(context: permissionType), + ), + content: Text( + $.tr.infrastructure.permissions.dialog.permanentlyDenied + .description(context: permissionType), + ), + actions: [ + TextButton( + onPressed: () { + $.dialog.popDialog(); + unawaited(openAppSettings()); + }, + child: Text( + $.tr.infrastructure.permissions.dialog.buttons.openSettings), + ), + TextButton( + onPressed: $.dialog.popDialog, + child: Text($.tr.infrastructure.permissions.dialog.buttons.cancel), + ), + ], + ), + ); + } + + /// Displays a dialog informing the user that the requested permission is restricted. + /// This generally occurs when parental controls or similar restrictions are in place. + /// + /// - [permissionType]: The type of permission that is restricted. + Future informRestricted(PermissionTypeEnum permissionType) async { + await $.dialog.pushDialog( + builder: (_) => AlertDialog( + title: Text( + $.tr.infrastructure.permissions.dialog.restricted + .title(context: permissionType), + ), + content: Text( + $.tr.infrastructure.permissions.dialog.restricted + .description(context: permissionType), + ), + actions: [ + TextButton( + onPressed: $.dialog.popDialog, + child: + Text($.tr.infrastructure.permissions.dialog.buttons.understood), + ), + ], + ), + ); + } + + /// Displays a dialog informing the user that limited permission access is granted. + /// It encourages the user to open the app settings to allow full access. + /// + /// - [permissionType]: The type of permission that is limited. + Future informLimited(PermissionTypeEnum permissionType) async { + await $.dialog.pushDialog( + builder: (_) => AlertDialog( + title: Text( + $.tr.infrastructure.permissions.dialog.limited + .title(context: permissionType), + ), + content: Text( + $.tr.infrastructure.permissions.dialog.limited + .description(context: permissionType), + ), + actions: [ + TextButton( + onPressed: () { + $.dialog.popDialog(); + unawaited(openAppSettings()); + }, + child: Text( + $.tr.infrastructure.permissions.dialog.buttons.openSettings), + ), + TextButton( + onPressed: $.dialog.popDialog, + child: Text($.tr.infrastructure.permissions.dialog.buttons.ok), + ), + ], + ), + ); + } + + /// Displays a dialog informing the user that the permission is granted provisionally. + /// + /// This typically occurs for non-interruptive notifications on iOS (iOS12+). + /// + /// - [permissionType]: The type of permission granted provisionally. + Future informProvisional(PermissionTypeEnum permissionType) async { + await $.dialog.pushDialog( + builder: (_) => AlertDialog( + title: Text( + $.tr.infrastructure.permissions.dialog.provisional + .title(context: permissionType), + ), + content: Text( + $.tr.infrastructure.permissions.dialog.provisional.description), + actions: [ + TextButton( + onPressed: $.dialog.popDialog, + child: Text($.tr.infrastructure.permissions.dialog.buttons.ok), + ), + ], + ), + ); + } +} diff --git a/infrastructure/lib/permissions/_core/permission_type.enum.dart b/infrastructure/lib/permissions/_core/permission_type.enum.dart new file mode 100644 index 0000000..092ebf1 --- /dev/null +++ b/infrastructure/lib/permissions/_core/permission_type.enum.dart @@ -0,0 +1,159 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +/// Enum representing different types of permissions. +enum PermissionTypeEnum { + /// Permission for accessing media location. + accessMediaLocation, + + /// Permission for accessing activity recognition. + /// Permission for accessing notification policy. + accessNotificationPolicy, + + /// Permission for scanning Bluetooth devices. + /// Permission for accessing activity recognition. + activityRecognition, + + /// Unknown permission, used as a placeholder. + /// Permission for app tracking transparency. + appTrackingTransparency, + + /// Permission for sending critical alerts. + /// Permission for accessing audio files from external storage. + audio, + + /// Permission for scheduling exact alarms. + /// Permission for accessing Bluetooth adapter state. + bluetooth, + + /// Permission for managing external storage. + /// Permission for advertising Bluetooth devices. + bluetoothAdvertise, + + /// Permission for connecting Bluetooth devices. + /// Permission for connecting Bluetooth devices. + bluetoothConnect, + + /// Permission for connecting to nearby Wi-Fi devices. + /// Permission for scanning Bluetooth devices. + bluetoothScan, + + /// Permission for advertising Bluetooth devices. + /// Permission for accessing the device's calendar. + /// Deprecated: Use [calendarWriteOnly] and [calendarFullAccess]. + calendar, + + /// Permission for accessing the device's camera. + /// Permission for full access to the calendar. + calendarFullAccess, + + /// Permission for writing to the calendar. + calendarWriteOnly, + + /// Permission for full access to the calendar. + /// Permission for accessing the device's camera. + camera, + + /// Permission for accessing the device's contacts. + /// Permission for accessing the device's contacts. + contacts, + + /// Permission for accessing the device's location. + /// Permission for sending critical alerts. + criticalAlerts, + + /// Permission for accessing notification policy. + /// Permission for ignoring battery optimizations. + ignoreBatteryOptimizations, + + /// Permission for pushing notifications. + /// Permission for accessing the device's location. + location, + + /// Permission for accessing the device's location always. + /// Permission for accessing the device's location always. + locationAlways, + + /// Permission for accessing the device's location when in use. + /// Permission for accessing the device's location when in use. + locationWhenInUse, + + /// Permission for accessing the device's media library. + /// Permission for managing external storage. + manageExternalStorage, + + /// Permission for creating system alert window. + /// Permission for accessing the device's media library. + mediaLibrary, + + /// Permission for accessing the device's microphone. + /// Permission for accessing the device's microphone. + microphone, + + /// Permission for accessing the device's phone state. + /// Permission for connecting to nearby Wi-Fi devices. + nearbyWifiDevices, + + /// Permission for accessing video files from external storage. + /// Permission for pushing notifications. + notification, + + /// Permission for accessing media location. + /// Permission for accessing the device's phone state. + phone, + + /// Permission for accessing the device's photos. + /// Permission for accessing the device's photos. + photos, + + /// Permission for adding photos only. + /// Permission for adding photos only. + photosAddOnly, + + /// Permission for accessing the device's reminders. + /// Permission for accessing the device's reminders. + reminders, + + /// Permission for accessing the device's sensors. + /// Permission for requesting package installation. + requestInstallPackages, + + /// Permission for app tracking transparency. + /// Permission for scheduling exact alarms. + scheduleExactAlarm, + + /// Permission for accessing sensors always. + /// Permission for accessing the device's sensors. + sensors, + + /// Permission for sending and reading SMS messages. + /// Permission for accessing sensors always. + sensorsAlways, + + /// Permission for writing to the calendar. + /// Permission for sending and reading SMS messages. + sms, + + /// Permission for accessing speech recognition. + /// Permission for accessing speech recognition. + speech, + + /// Permission for accessing external storage. + /// Permission for accessing external storage. + storage, + + /// Permission for ignoring battery optimizations. + /// Permission for creating system alert window. + systemAlertWindow, + + /// Permission for requesting package installation. + /// Unknown permission, used as a placeholder. + unknown, + + /// Permission for accessing Bluetooth adapter state. + /// Permission for accessing video files from external storage. + videos, +} diff --git a/infrastructure/lib/permissions/permissions.dart b/infrastructure/lib/permissions/permissions.dart new file mode 100644 index 0000000..3290561 --- /dev/null +++ b/infrastructure/lib/permissions/permissions.dart @@ -0,0 +1,166 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: avoid_returning_this + +import 'package:deps/infrastructure/infrastructure.dart'; +import 'package:deps/packages/permission_handler.dart'; +import 'package:flutter/widgets.dart'; +import 'package:infrastructure/_core/_i18n/translations.g.dart'; +import 'package:infrastructure/_core/commons/typedefs/future_void_callback.typedef.dart'; +import 'package:infrastructure/permissions/_core/permission.failures.dart'; +import 'package:infrastructure/permissions/_core/permission_dialogs.dart'; + +typedef FailureCallback = ValueChanged; + +/// A class that manages permissions by handling requests, user responses, +/// and failures related to permission requests. +/// +/// This class provides methods to request permissions and to handle different +/// permission states like granted, denied, or permanently denied. The class +/// also supports callbacks for handling custom logic based on the status of +/// a permission request. +final class Permissions { + final PermissionDialogs _permissionDialogs = const PermissionDialogs(); + + /// Callback when the user denies access to the requested permission. + FutureVoidCallback? _onDeniedCallback; + + /// Callback when the user grants access to the requested permission. + FutureVoidCallback? _onGrantedCallback; + + /// Callback when permission is permanently denied. + /// + /// *On Android*: + /// - Android 11+ (API 30+): The user denied the permission for a second time. + /// - Below Android 11 (API 30): The user denied access and selected not to show the request again. + /// + /// *On iOS*: + /// - The user denied access to the requested feature. + FutureVoidCallback? _onPermanentlyDeniedCallback; + + /// Callback when the OS restricts access to the requested feature. + /// + /// *Only supported on iOS*, typically for restrictions like parental controls. + FutureVoidCallback? _onRestrictedCallback; + + /// Callback when the user grants limited access, relevant for photo library access on iOS (iOS14+). + FutureVoidCallback? _onLimitedCallback; + + /// Callback when the application is provisionally authorized for non-interruptive notifications. + /// + /// *Only supported on iOS (iOS12+)*. + FutureVoidCallback? _onProvisionalCallback; + + /// Callback for handling failures during permission requests. + FailureCallback? _onFailureCallback; + + /// Requests the permission specified by [permissionType], optionally handling alerts based on [shouldHandleAlerts]. + /// + /// If a permission request results in any of the permission statuses (granted, denied, permanently denied, etc.), + /// the appropriate callback is triggered. Alerts can be handled via permission dialogs if enabled. + Future request( + PermissionTypeEnum permissionType, { + bool shouldHandleAlerts = true, + }) async { + try { + final Permission permission = _getPermissionFromType(permissionType); + + // Special case: For location permissions, handle request rationale. + if (permission == Permission.locationWhenInUse || + permission == Permission.locationAlways || + permission == Permission.location) { + await permission.shouldShowRequestRationale; + } + + // Request the permission and handle the result based on status. + final PermissionStatus status = await permission.request(); + + switch (status) { + case PermissionStatus.granted: + await _onGrantedCallback?.call(); + + case PermissionStatus.denied: + await _onDeniedCallback?.call(); + if (shouldHandleAlerts) { + await _permissionDialogs.informDenied( + permissionType, + onRetry: () => request(permissionType), + ); + } + + case PermissionStatus.permanentlyDenied: + await _onPermanentlyDeniedCallback?.call(); + if (shouldHandleAlerts) { + await _permissionDialogs.informPermanentlyDenied(permissionType); + } + + case PermissionStatus.restricted: + await _onRestrictedCallback?.call(); + if (shouldHandleAlerts) { + await _permissionDialogs.informRestricted(permissionType); + } + + case PermissionStatus.limited: + await _onLimitedCallback?.call(); + if (shouldHandleAlerts) { + await _permissionDialogs.informLimited(permissionType); + } + + case PermissionStatus.provisional: + await _onProvisionalCallback?.call(); + if (shouldHandleAlerts) { + await _permissionDialogs.informProvisional(permissionType); + } + } + } on InvalidPermissionTypeFailure catch (exception) { + _onFailureCallback?.call(exception); + } on Exception catch (exception) { + _onFailureCallback + ?.call(UnknownPermissionRequestError(exception: exception)); + if (shouldHandleAlerts) { + await _permissionDialogs.informProvisional(permissionType); + } + } + } + + /// Sets up the permission handlers for various permission states and failures. + /// Returns the [Permissions] object to allow method chaining. + Permissions when({ + FutureVoidCallback? denied, + FutureVoidCallback? granted, + FutureVoidCallback? limited, + FailureCallback? onFailure, + FutureVoidCallback? permanentlyDenied, + FutureVoidCallback? provisional, + FutureVoidCallback? restricted, + }) { + _onDeniedCallback = denied; + _onGrantedCallback = granted; + _onPermanentlyDeniedCallback = permanentlyDenied; + _onRestrictedCallback = restricted; + _onLimitedCallback = limited; + _onProvisionalCallback = provisional; + _onFailureCallback = onFailure; + + return this; + } + + /// Retrieves the [Permission] object corresponding to the given [permissionType]. + /// Throws [InvalidPermissionTypeFailure] if the permission type is invalid. + Permission _getPermissionFromType(PermissionTypeEnum permissionType) { + final String? permissionName = + permissionType.toString().split('.').lastOrNull; + + for (final Permission permission in Permission.values) { + if (permission.toString().split('.').lastOrNull == permissionName) { + return permission; + } + } + + throw InvalidPermissionTypeFailure(type: permissionType); + } +} diff --git a/infrastructure/lib/presentation/_core/dialog/dialog_builder.dart b/infrastructure/lib/presentation/_core/dialog/dialog_builder.dart new file mode 100644 index 0000000..3d90438 --- /dev/null +++ b/infrastructure/lib/presentation/_core/dialog/dialog_builder.dart @@ -0,0 +1,44 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:infrastructure/presentation/_core/dialog/dialog_config.dart'; +import 'package:infrastructure/presentation/_core/dialog/dialog_wrapper.route.dart'; + +/// The `DialogBuilder` class provides a static method to configure and build a +/// dialog route based on a [DialogWrapperRoute] configuration. +abstract final class DialogBuilder { + /// Builds a dialog route using the [DialogWrapperRoute] configuration. + /// + /// The [context] represents the build context where the dialog will be displayed. + /// The [child] is the widget that represents the dialog's content. + /// The [page] is the [AutoRoutePage] containing a [DialogWrapperRoute]. + /// + /// Throws an [ArgumentError] if the page's child is not of type [DialogWrapperRoute]. + static Route route( + BuildContext context, Widget child, AutoRoutePage page) { + if (page.child is! DialogWrapperRoute) { + throw ArgumentError('Child page must be of type DialogWrapperRoute.'); + } + + final DialogWrapperRoute dialogRoute = page.child as DialogWrapperRoute; + final DialogConfig config = dialogRoute.dialogConfig; + + return DialogRoute( + context: context, + builder: (_) => child, + themes: config.themes, + barrierColor: config.barrierColor, + barrierDismissible: config.isBarrierDismissible, + barrierLabel: config.barrierLabel, + useSafeArea: config.shouldUseSafeArea, + settings: page, + anchorPoint: config.anchorPoint, + traversalEdgeBehavior: config.traversalEdgeBehavior, + ); + } +} diff --git a/infrastructure/lib/presentation/_core/dialog/dialog_config.dart b/infrastructure/lib/presentation/_core/dialog/dialog_config.dart new file mode 100644 index 0000000..e3a029c --- /dev/null +++ b/infrastructure/lib/presentation/_core/dialog/dialog_config.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +/// Configuration class that holds settings for how the dialog should be displayed. +/// This class is used by the `DialogBuilder` to apply specific configurations +/// when building a dialog route. +class DialogConfig { + const DialogConfig({ + this.anchorPoint, + this.barrierLabel, + this.settings, + this.themes, + this.traversalEdgeBehavior, + this.barrierColor = Colors.black54, + this.isBarrierDismissible = true, + this.shouldUseSafeArea = true, + }); + + /// Optional anchor point for the dialog (top-left corner). + final Offset? anchorPoint; + + /// Color of the dialog's barrier. + final Color? barrierColor; + + /// Determines if the dialog can be dismissed by tapping outside the dialog. + final bool isBarrierDismissible; + + /// Optional label for the barrier, used for accessibility. + final String? barrierLabel; + + /// Additional route settings. + final RouteSettings? settings; + + /// Captured themes to apply to the dialog. + final CapturedThemes? themes; + + /// Determines how the dialog behaves when traversing edges. + final TraversalEdgeBehavior? traversalEdgeBehavior; + + /// Indicates whether to apply safe area insets. + final bool shouldUseSafeArea; +} diff --git a/infrastructure/lib/presentation/_core/dialog/dialog_wrapper.route.dart b/infrastructure/lib/presentation/_core/dialog/dialog_wrapper.route.dart new file mode 100644 index 0000000..70bc375 --- /dev/null +++ b/infrastructure/lib/presentation/_core/dialog/dialog_wrapper.route.dart @@ -0,0 +1,27 @@ +import 'package:deps/packages/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:infrastructure/presentation/_core/dialog/dialog_config.dart'; + +/// A stateless widget that wraps a dialog configuration and content. +/// This is the actual dialog route that is passed into the `DialogBuilder` +/// for display. +@RoutePage() +class DialogWrapperRoute extends StatelessWidget { + const DialogWrapperRoute({ + required this.builder, + super.key, + this.dialogConfig = const DialogConfig(), + }); + + /// Builds the widget (dialog content) when this route is pushed. + @override + Widget build(BuildContext context) { + return builder(context); + } + + /// The builder that provides the dialog content. + final WidgetBuilder builder; + + /// Configuration settings for the dialog (such as barrier color, dismissibility, etc.). + final DialogConfig dialogConfig; +} diff --git a/infrastructure/lib/presentation/_core/modal/modal_builder.dart b/infrastructure/lib/presentation/_core/modal/modal_builder.dart new file mode 100644 index 0000000..fefc22c --- /dev/null +++ b/infrastructure/lib/presentation/_core/modal/modal_builder.dart @@ -0,0 +1,40 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:infrastructure/presentation/_core/modal/modal_config.dart'; +import 'package:infrastructure/presentation/_core/modal/modal_wrapper.route.dart'; + +/// The `ModalBuilder` class provides a static method to configure and build a +/// modal route based on a [ModalWrapperRoute] configuration. +abstract final class ModalBuilder { + /// Builds a modal route using the [ModalWrapperRoute] configuration. + /// + /// The [context] represents the build context where the modal will be displayed. + /// The [child] is the widget that represents the modal's content. + /// The [page] is the [AutoRoutePage] containing a [ModalWrapperRoute]. + /// + /// Throws an [ArgumentError] if the page's child is not of type [ModalWrapperRoute]. + static Route route( + BuildContext context, Widget child, AutoRoutePage page) { + if (page.child is! ModalWrapperRoute) { + throw ArgumentError( + 'Child page must be of type ModalWrapperRoute to use cupertinoModalRouteBuilder.'); + } + + final ModalWrapperRoute dialogWrapperRoute = + page.child as ModalWrapperRoute; + final ModalConfig config = dialogWrapperRoute.modalConfig; + + return ModalBottomSheetRoute( + isScrollControlled: config.isScrollControlled, + settings: page, + builder: (_) => child, + barrierLabel: config.barrierLabel, + ); + } +} diff --git a/infrastructure/lib/presentation/_core/modal/modal_config.dart b/infrastructure/lib/presentation/_core/modal/modal_config.dart new file mode 100644 index 0000000..7e719d1 --- /dev/null +++ b/infrastructure/lib/presentation/_core/modal/modal_config.dart @@ -0,0 +1,23 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter/material.dart'; + +/// Configuration class that holds settings for modal dialog presentation. +/// This class is used by the `ModalBuilder` to apply specific configurations +/// when building a modal route. +class ModalConfig { + const ModalConfig({ + this.isScrollControlled = true, + this.barrierLabel, + }); + + /// Optional label for the barrier, used for accessibility. + final String? barrierLabel; + + /// Specifies whether this is a route for a bottom sheet that will utilize [DraggableScrollableSheet]. + final bool isScrollControlled; +} diff --git a/infrastructure/lib/presentation/_core/modal/modal_wrapper.route.dart b/infrastructure/lib/presentation/_core/modal/modal_wrapper.route.dart new file mode 100644 index 0000000..3b067f8 --- /dev/null +++ b/infrastructure/lib/presentation/_core/modal/modal_wrapper.route.dart @@ -0,0 +1,33 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:infrastructure/presentation/_core/modal/modal_config.dart'; + +/// A stateless widget that wraps a modal configuration and content. +/// This is the actual modal route that is passed into the `ModalBuilder` +/// for display. +@RoutePage() +class ModalWrapperRoute extends StatelessWidget { + const ModalWrapperRoute({ + required this.builder, + this.modalConfig = const ModalConfig(), + super.key, + }); + + /// Builds the widget (modal content) when this route is pushed. + @override + Widget build(BuildContext context) { + return builder(context); + } + + /// The builder that provides the modal content. + final Widget Function(BuildContext context) builder; + + /// Configuration settings for the modal (such as barrier color, dismissibility, etc.). + final ModalConfig modalConfig; +} diff --git a/infrastructure/lib/presentation/_core/sheet/sheet_config.dart b/infrastructure/lib/presentation/_core/sheet/sheet_config.dart new file mode 100644 index 0000000..cb34daa --- /dev/null +++ b/infrastructure/lib/presentation/_core/sheet/sheet_config.dart @@ -0,0 +1,42 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter/material.dart'; + +/// Configuration class that holds settings for the appearance and behavior +/// of a sheet in the UI. These settings are applied when displaying the sheet. +class SheetConfig { + const SheetConfig({ + this.backgroundColor, + this.clipBehavior, + this.constraints, + this.elevation, + this.shape, + this.transitionAnimationController, + this.shouldEnableDrag = true, + }); + + /// The background color of the sheet. + final Color? backgroundColor; + + /// Defines how the sheet content should be clipped. + final Clip? clipBehavior; + + /// Constraints that can limit the sheet's size. + final BoxConstraints? constraints; + + /// Elevation value, which controls the shadow cast by the sheet. + final double? elevation; + + /// Specifies whether the sheet can be dragged to close. + final bool shouldEnableDrag; + + /// The shape of the sheet, useful for rounding corners or other visual effects. + final ShapeBorder? shape; + + /// An optional animation controller that can control the sheet's transition animations. + final AnimationController? transitionAnimationController; +} diff --git a/infrastructure/lib/presentation/_core/sheet/sheet_wrapper.route.dart b/infrastructure/lib/presentation/_core/sheet/sheet_wrapper.route.dart new file mode 100644 index 0000000..f8203b7 --- /dev/null +++ b/infrastructure/lib/presentation/_core/sheet/sheet_wrapper.route.dart @@ -0,0 +1,22 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter/material.dart'; + +/// A wrapper widget for displaying content inside a sheet. +/// The content is passed via the [builder] parameter and wrapped in a [SafeArea]. +class SheetWrapperRoute extends StatelessWidget { + const SheetWrapperRoute({required this.builder, super.key}); + + /// Builds the content of the sheet using the provided [builder] function. + @override + Widget build(BuildContext context) { + return SafeArea(child: builder(context)); + } + + /// The function that builds the content of the sheet. + final WidgetBuilder builder; +} diff --git a/infrastructure/lib/presentation/_core/toast/toast.dart b/infrastructure/lib/presentation/_core/toast/toast.dart new file mode 100644 index 0000000..9083910 --- /dev/null +++ b/infrastructure/lib/presentation/_core/toast/toast.dart @@ -0,0 +1,85 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter/material.dart'; +import 'package:infrastructure/_core/commons/extensions/double.ext.dart'; +import 'package:infrastructure/presentation/super_class.dart'; + +/// [Toast] is a customizable widget to display toast notifications in the UI. +/// It supports various options including custom content, a close button, and +/// animations for showing/hiding the toast. +class Toast extends StatelessWidget { + const Toast({ + required this.controller, + required this.onTap, + this.backgroundColor, + this.child, + this.curve, + this.isClosable, + super.key, + this.leading, + this.message, + this.messageStyle, + this.onClose, + this.shadowColor, + this.isInFront = false, + }) : assert((message != null || message != '') || child != null); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: Stack( + children: [ + InkWell( + child: child ?? + Container( + padding: $.paddings.md.all, + width: MediaQuery.sizeOf(context).width, + child: Row( + children: [ + if (leading != null) ...[ + leading!, + const SizedBox(width: 10), + ], + if (message != null) + Expanded( + child: Text(message ?? '', style: messageStyle), + ), + ], + ), + ), + onTap: onTap, + borderRadius: const BorderRadius.all(Radius.circular(16)), + ), + if (isClosable ?? false) + Positioned( + top: 0, + right: 16, + bottom: 0, + child: InkWell( + child: const Icon(Icons.close, size: 18), + onTap: onClose, + ), + ), + ], + ), + ); + } + + final Color? backgroundColor; + final Widget? child; + final AnimationController? controller; + final Curve? curve; + final bool? isClosable; + final bool isInFront; + final Widget? leading; + final String? message; + final TextStyle? messageStyle; + final VoidCallback? onClose; + final VoidCallback onTap; + final Color? shadowColor; +} diff --git a/infrastructure/lib/presentation/_core/toast/toast_wrapper.dart b/infrastructure/lib/presentation/_core/toast/toast_wrapper.dart new file mode 100644 index 0000000..2527814 --- /dev/null +++ b/infrastructure/lib/presentation/_core/toast/toast_wrapper.dart @@ -0,0 +1,169 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter/material.dart'; +import 'package:infrastructure/presentation/contexts/toast_context.dart'; + +/// A [ToastWrapper] is a stateful widget responsible for managing the animation, +/// position, and dismiss behavior of a toast notification in the app. It wraps +/// the toast content and applies necessary transitions and dismiss logic. +class ToastWrapper extends StatefulWidget { + const ToastWrapper({ + required this.alignment, + required this.animatedOpacity, + required this.child, + required this.controller, + required this.dismissDirection, + required this.expandedPaddingHorizontal, + required this.expandedPositionedPadding, + required this.onDismissed, + required this.positionCurve, + required this.shouldUseSafeArea, + super.key, + }); + + /// Defines the alignment of the toast, either top or bottom. + final ToastAlignment alignment; + + /// Controls the opacity animation for the toast. + final double animatedOpacity; + + /// The actual widget content of the toast. + final Widget child; + + /// Animation controller for controlling slide transitions. + final AnimationController controller; + + /// Defines the direction in which the toast can be dismissed. + final ToastDismissDirection dismissDirection; + + /// Padding applied horizontally for the toast content. + final double expandedPaddingHorizontal; + + /// Padding applied to the position of the toast. + final double expandedPositionedPadding; + + /// Callback triggered when the toast is dismissed. + final VoidCallback onDismissed; + + /// Curve used for controlling the position animation. + final Curve positionCurve; + + /// Whether the toast should respect the device's safe area (like status bar, notch). + final bool shouldUseSafeArea; + + @override + State createState() => _ToastWrapperState(); +} + +class _ToastWrapperState extends State { + final GlobalKey> _childKey = GlobalKey(); + bool _isHeightCalculated = false; + double _widgetHeight = 0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance + .addPostFrameCallback((_) => _calculateChildHeight()); + } + + @override + Widget build(BuildContext context) { + return Offstage( + offstage: !_isHeightCalculated, + child: SlideTransition( + position: _position.animate( + CurvedAnimation( + parent: widget.controller, + curve: widget.positionCurve, + reverseCurve: widget.positionCurve, + ), + ), + child: Stack( + children: [ + AnimatedPositioned( + child: _buildDismissibleContent, + left: 10, + top: _isTopAligned + ? widget.expandedPositionedPadding + _safeAreaPadding + : null, + right: 10, + bottom: _isTopAligned + ? null + : widget.expandedPositionedPadding + _safeAreaPadding, + curve: widget.positionCurve, + duration: const Duration(milliseconds: 500), + ), + ], + ), + ), + ); + } + + Widget get _buildDismissibleContent { + return Dismissible( + key: UniqueKey(), + child: AnimatedPadding( + padding: + EdgeInsets.symmetric(horizontal: widget.expandedPaddingHorizontal), + child: AnimatedOpacity( + child: SizedBox(key: _childKey, child: widget.child), + opacity: widget.animatedOpacity, + duration: const Duration(milliseconds: 500), + ), + curve: widget.positionCurve, + duration: const Duration(milliseconds: 500), + ), + onDismissed: (_) => widget.onDismissed(), + direction: _mapToastDismissToDismissDirection, + ); + } + + bool get _isTopAligned => widget.alignment == ToastAlignment.top; + + DismissDirection get _mapToastDismissToDismissDirection { + switch (widget.dismissDirection) { + case ToastDismissDirection.up: + return DismissDirection.up; + case ToastDismissDirection.down: + return DismissDirection.down; + case ToastDismissDirection.horizontal: + return DismissDirection.horizontal; + } + } + + double get _mediaQueryHeight => MediaQuery.sizeOf(context).height; + + Offset get _offset => Offset( + 0, + (_widgetHeight + widget.expandedPositionedPadding + _safeAreaPadding) / + _mediaQueryHeight * + (_isTopAligned ? -1 : 1), + ); + + Tween get _position => + Tween(begin: _offset, end: Offset.zero); + + double get _safeAreaPadding { + return widget.shouldUseSafeArea + ? _isTopAligned + ? MediaQuery.paddingOf(context).top + : MediaQuery.paddingOf(context).bottom + : 0; + } + + void _calculateChildHeight() { + final RenderBox? renderBox = + _childKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox != null) { + setState(() { + _widgetHeight = renderBox.size.height; + _isHeightCalculated = true; + }); + } + } +} diff --git a/infrastructure/lib/presentation/constants/paddings.dart b/infrastructure/lib/presentation/constants/paddings.dart new file mode 100644 index 0000000..67afd19 --- /dev/null +++ b/infrastructure/lib/presentation/constants/paddings.dart @@ -0,0 +1,39 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: prefer-correct-identifier-length + +import 'package:flutter/material.dart'; + +/// The [Paddings] class provides a set of predefined padding values for consistent +/// spacing throughout the UI. This immutable class can be used to ensure uniformity +/// in padding across different parts of the app. +@immutable +final class Paddings { + /// Extra extra extra small padding: 2 pixels. + final double xxxs = 2; + + /// Extra extra small padding: 4 pixels. + final double xxs = 4; + + /// Extra small padding: 8 pixels. + final double xs = 8; + + /// Small padding: 12 pixels. + final double sm = 12; + + /// Medium padding: 16 pixels. + final double md = 16; + + /// Large padding: 24 pixels. + final double lg = 24; + + /// Extra large padding: 32 pixels. + final double xl = 32; + + /// Extra extra large padding: 40 pixels. + final double xxl = 40; +} diff --git a/infrastructure/lib/presentation/constants/platform.dart b/infrastructure/lib/presentation/constants/platform.dart new file mode 100644 index 0000000..e7896aa --- /dev/null +++ b/infrastructure/lib/presentation/constants/platform.dart @@ -0,0 +1,66 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter/foundation.dart'; +import 'package:infrastructure/presentation/widgets/_core/platform/platform_io_web.dart' + if (dart.library.io) '../widgets/_core/platform/platform_io.dart'; + +/// The [Platform] class provides platform-specific flags to identify +/// the environment in which the app is running. This is useful for writing +/// platform-aware code, enabling different behaviors or UI depending on the +/// current operating system (OS) or platform type. +@immutable +final class Platform { + /// Default constructor for the [Platform] class. + Platform(); + + /// **isWeb**: A boolean flag indicating whether the app is running on the web. + /// + /// - Returns `true` if running on the web platform, `false` otherwise. + final bool isWeb = PlatformIo.isWeb; + + /// **isMacOS**: A boolean flag indicating whether the app is running on macOS. + /// + /// - Returns `true` if running on macOS, `false` otherwise. + final bool isMacOS = PlatformIo.isMacOS; + + /// **isWindows**: A boolean flag indicating whether the app is running on Windows. + /// + /// - Returns `true` if running on Windows, `false` otherwise. + final bool isWindows = PlatformIo.isWindows; + + /// **isLinux**: A boolean flag indicating whether the app is running on Linux. + /// + /// - Returns `true` if running on Linux, `false` otherwise. + final bool isLinux = PlatformIo.isLinux; + + /// **isAndroid**: A boolean flag indicating whether the app is running on Android. + /// + /// - Returns `true` if running on Android, `false` otherwise. + final bool isAndroid = PlatformIo.isAndroid; + + /// **isIOS**: A boolean flag indicating whether the app is running on iOS. + /// + /// - Returns `true` if running on iOS, `false` otherwise. + final bool isIOS = PlatformIo.isIOS; + + /// **isFuchsia**: A boolean flag indicating whether the app is running on Fuchsia. + /// + /// - Returns `true` if running on Fuchsia OS, `false` otherwise. + final bool isFuchsia = PlatformIo.isFuchsia; + + /// **isMobile**: A derived boolean flag that returns `true` if the platform is mobile + /// (i.e., Android or iOS), `false` otherwise. + /// + /// It is lazily initialized based on the `isAndroid` and `isIOS` properties. + late final bool isMobile = isIOS || isAndroid; + + /// **isDesktop**: A derived boolean flag that returns `true` if the platform is a desktop + /// environment (i.e., macOS, Windows, or Linux), `false` otherwise. + /// + /// It is lazily initialized based on the `isMacOS`, `isWindows`, and `isLinux` properties. + late final bool isDesktop = isMacOS || isWindows || isLinux; +} diff --git a/infrastructure/lib/presentation/constants/radiuses.dart b/infrastructure/lib/presentation/constants/radiuses.dart new file mode 100644 index 0000000..280ab3e --- /dev/null +++ b/infrastructure/lib/presentation/constants/radiuses.dart @@ -0,0 +1,42 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +/// The [Radiuses] class defines a set of predefined border radius values that +/// can be used throughout the app to ensure consistent corner rounding. Each +/// radius value corresponds to a specific size ranging from no radius (`none`) +/// to extra-large radius (`xxl`). +/// +/// Example usage: +/// ```dart +/// BorderRadius.circular($.radiuses.md); +/// ``` + +@immutable +final class Radiuses { + /// No border radius. + final double none = 0; + + /// Extra small border radius, typically used for minimal rounding. + final double xs = 4; + + /// Small border radius, commonly used for slightly rounded corners. + final double sm = 8; + + /// Medium border radius, offering moderate rounding. + final double md = 12; + + /// Large border radius, providing more noticeable rounding for elements. + final double lg = 16; + + /// Extra large border radius, used for highly rounded corners. + final double xl = 20; + + /// Double extra large border radius, the largest available, used for highly rounded elements. + final double xxl = 24; +} diff --git a/infrastructure/lib/presentation/constants/timings.dart b/infrastructure/lib/presentation/constants/timings.dart new file mode 100644 index 0000000..5e055d7 --- /dev/null +++ b/infrastructure/lib/presentation/constants/timings.dart @@ -0,0 +1,48 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter/material.dart'; + +/// The [Timings] class defines a set of predefined [Duration] values used +/// throughout the app for animations, delays, and transitions. These constants +/// allow for consistent timing across different UI components. +/// +/// Example usage: +/// ```dart +/// Future.delayed($.timings.mil200, () { +/// // Execute some delayed action after 200 milliseconds +/// }); +/// ``` + +@immutable +final class Timings { + /// A duration of zero, commonly used for immediate actions. + final Duration zero = Duration.zero; + + /// A short duration of 50 milliseconds, often used for quick transitions or animations. + final Duration mil050 = const Duration(milliseconds: 50); + + /// A duration of 100 milliseconds, suitable for fast animations. + final Duration mil100 = const Duration(milliseconds: 100); + + /// A duration of 200 milliseconds, commonly used for moderate animations or small delays. + final Duration mil200 = const Duration(milliseconds: 200); + + /// A duration of 400 milliseconds, appropriate for standard UI animations. + final Duration mil400 = const Duration(milliseconds: 400); + + /// A duration of 600 milliseconds, typically used for slightly longer animations. + final Duration mil600 = const Duration(milliseconds: 600); + + /// A duration of 800 milliseconds, used for extended animations or transitions. + final Duration mil800 = const Duration(milliseconds: 800); + + /// A duration of 1 second, typically used for noticeable delays or extended animations. + final Duration sec = const Duration(seconds: 1); + + /// A duration of 2 seconds, generally used for significant pauses or longer animations. + final Duration sec2 = const Duration(seconds: 2); +} diff --git a/infrastructure/lib/presentation/contexts/bloc_context.dart b/infrastructure/lib/presentation/contexts/bloc_context.dart new file mode 100644 index 0000000..2d023dd --- /dev/null +++ b/infrastructure/lib/presentation/contexts/bloc_context.dart @@ -0,0 +1,57 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/flutter_bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:infrastructure/presentation/contexts/navigator_context.dart'; +import 'package:infrastructure/presentation/super_class.dart'; + +/// The [BlocContext] class serves as a convenient utility for accessing +/// BLoC (Business Logic Component) instances within the application. It uses +/// the [NavigatorContext] to retrieve the current `BuildContext` and exposes +/// methods to read or watch BLoC instances tied to that context. +/// +/// Example usage: +/// ```dart +/// final MyBloc myBloc = $.read(); +/// ``` +/// +/// This class should be initialized with a [NavigatorContext] that provides +/// access to the current navigation context. +/// +/// See also: +/// * [read] for accessing the BLoC without subscribing to changes. +/// * [watch] for subscribing to changes in the BLoC. + +@immutable +final class BlocContext { + /// Constructs a [BlocContext] with the given [NavigatorContext]. + const BlocContext(); + + /// Retrieves a BLoC instance of type [T] from the current context without + /// subscribing to its changes. This is useful for one-time access to a BLoC + /// instance's current state or behavior. + /// + /// Example: + /// ```dart + /// final MyBloc myBloc = $.read(); + /// ``` + T read() { + return $.navigator.context!.read(); + } + + /// Subscribes to the BLoC instance of type [T] from the current context, + /// and triggers a rebuild when the BLoC's state changes. Use this method + /// when you want the widget to rebuild automatically on BLoC state changes. + /// + /// Example: + /// ```dart + /// final MyBloc myBloc = $.watch(); + /// ``` + T watch() { + return $.navigator.context!.watch(); + } +} diff --git a/infrastructure/lib/presentation/contexts/dialog_context.dart b/infrastructure/lib/presentation/contexts/dialog_context.dart new file mode 100644 index 0000000..6bceea2 --- /dev/null +++ b/infrastructure/lib/presentation/contexts/dialog_context.dart @@ -0,0 +1,178 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:infrastructure/_core/_router/router.gr.dart'; +import 'package:infrastructure/presentation/_core/dialog/dialog_config.dart'; +import 'package:infrastructure/presentation/_core/modal/modal_config.dart'; +import 'package:infrastructure/presentation/_core/sheet/sheet_config.dart'; +import 'package:infrastructure/presentation/_core/sheet/sheet_wrapper.route.dart'; +import 'package:infrastructure/presentation/contexts/navigator_context.dart'; +import 'package:infrastructure/presentation/contexts/overlay_context.dart'; +import 'package:infrastructure/presentation/super_class.dart'; + +/// [DialogContext] handles the display and management of dialogs, modals, and +/// bottom sheets in the application. It integrates with the app's navigation +/// context and provides an overlay to handle pop-ups and dialogs. +/// +/// This class allows showing various types of dialogs, modals, and bottom +/// sheets while ensuring proper dialog state management by tracking currently +/// visible dialogs and removing them when dismissed. +/// +/// ### Usage: +/// ```dart +/// $.dialog.pushDialog(builder: (_) => MyDialogWidget()); +/// ``` +/// +/// It also includes utility methods for popping and forcefully removing dialogs, +/// as well as automatically hiding overlays when needed. + +@immutable +final class DialogContext { + /// Constructs a [DialogContext] with the given [NavigatorContext] and [OverlayContext]. + DialogContext(); + + /// Tracks currently visible dialogs. + final ValueNotifier> _dialogs = + ValueNotifier>([]); + + /// Retrieves the current [BuildContext] from the scaffold key in the navigator. + BuildContext? get _scaffoldContext => $.navigator.scaffoldKey.currentContext; + + /// Retrieves the current [ScaffoldState] from the scaffold key in the navigator. + ScaffoldState? get _scaffoldState => $.navigator.scaffoldKey.currentState; + + /// Returns `true` if any dialog is currently visible. + bool get hasDialogVisible => _dialogs.value.isNotEmpty; + + /// Adds the given [widget] to the visible dialogs list. + void _addDialogVisible(Widget widget) { + _dialogs.value.add(widget); + } + + /// Removes the specified [widget] from the visible dialogs list. + /// If no widget is provided, it removes the last visible dialog. + void _removeDialogVisible({Widget? widget}) { + if (widget == null) { + _dialogs.value.removeLast(); + } else { + _dialogs.value.remove(widget); + } + } + + /// Pops all visible dialogs and clears the dialog registry. + void popAllDialogs() { + for (final Widget _ in _dialogs.value) { + popDialog(); + } + _resetDialogRegisters(); + } + + /// Pops the top-most dialog if any are visible. + Future popDialog([T? result]) async { + if (hasDialogVisible) { + await $.navigator.pop(result); + } + } + + /// Forcefully pops the top-most dialog. + Future popDialogForced([T? result]) async { + if (hasDialogVisible) { + await $.navigator.popForced(result); + } + } + + /// Clears the dialog register. + void _resetDialogRegisters() { + _dialogs.value.clear(); + } + + /// Pushes a new dialog with the provided [builder] and [config]. + /// Optionally, hides the overlay before showing the dialog. + Future pushDialog({ + required WidgetBuilder builder, + DialogConfig config = const DialogConfig(), + bool shouldAutoDismissOverlay = false, + }) async { + final BuildContext context = await $.navigator.ensuredContext; + + if (shouldAutoDismissOverlay) { + await $.overlay.popOverlay(); + } + + await popDialog(); + + if (context.mounted) { + final Widget dialog = builder(context); + _addDialogVisible(dialog); + + return $.navigator + .push( + DialogWrapperRoute(builder: (_) => dialog, dialogConfig: config), + ) + .whenComplete(() => _removeDialogVisible(widget: dialog)); + } + + return null; + } + + /// Pushes a new modal with the provided [builder] and [config]. + /// Optionally, hides the overlay before showing the modal. + Future pushModal({ + required WidgetBuilder builder, + ModalConfig config = const ModalConfig(), + bool shouldAutoDismissOverlay = true, + }) async { + final BuildContext context = await $.navigator.ensuredContext; + + if (shouldAutoDismissOverlay) { + await $.overlay.popOverlay(); + } + + if (context.mounted) { + final Widget dialog = builder(context); + _addDialogVisible(dialog); + + return $.navigator + .push( + ModalWrapperRoute(builder: (_) => dialog, modalConfig: config), + ) + .whenComplete(() => _removeDialogVisible(widget: dialog)); + } + + return null; + } + + /// Pushes a new bottom sheet with the provided [builder] and [config]. + Future pushSheet({ + required WidgetBuilder builder, + SheetConfig config = const SheetConfig(), + }) async { + if (!(_scaffoldContext?.mounted ?? true)) { + return; + } + + final Widget dialog = builder(_scaffoldContext!); + _addDialogVisible(dialog); + + final PersistentBottomSheetController bottomSheetController = + _scaffoldState!.showBottomSheet( + (_) => SheetWrapperRoute(builder: (_) => dialog), + backgroundColor: config.backgroundColor, + elevation: config.elevation, + shape: config.shape, + clipBehavior: config.clipBehavior, + constraints: config.constraints, + enableDrag: config.shouldEnableDrag, + transitionAnimationController: config.transitionAnimationController, + ); + + await bottomSheetController.closed; + _removeDialogVisible(widget: dialog); + } +} diff --git a/infrastructure/lib/presentation/contexts/navigator_context.dart b/infrastructure/lib/presentation/contexts/navigator_context.dart new file mode 100644 index 0000000..709d320 --- /dev/null +++ b/infrastructure/lib/presentation/contexts/navigator_context.dart @@ -0,0 +1,204 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:infrastructure/presentation/super_class.dart'; + +/// [NavigatorContext] is a helper class that simplifies navigation operations +/// in a Flutter application using `auto_route`. It provides easy access to the +/// navigator and auto-router states through global keys and exposes methods for +/// navigating between pages, replacing routes, and popping navigation stacks. +/// +/// This class ensures safe navigation by checking the availability of a valid +/// [BuildContext] before performing any actions, preventing navigation +/// failures in edge cases where the context might not be mounted. + +@immutable +final class NavigatorContext { + /// GlobalKey to access [NavigatorState] for managing standard Flutter navigation. + final GlobalKey navigatorKey = GlobalKey(); + + /// GlobalKey to access [AutoRouterState] for managing `auto_route` navigation. + final GlobalKey autoRouterKey = GlobalKey(); + + /// GlobalKey to access [ScaffoldState] for handling scaffold-related actions. + final GlobalKey scaffoldKey = GlobalKey(); + + /// Returns `true` if a valid [BuildContext] is available. + bool get hasContext => context != null; + + /// Retrieves the [BuildContext] from the current [NavigatorState]. + /// Throws an assertion error if no context is found. + BuildContext? get context { + final BuildContext? context = navigatorKey.currentContext; + assert(context != null, 'No context found.'); + + return context; + } + + /// Retrieves the [BuildContext] from the current [NavigatorState]. + /// Waits context related operations until context is ready. + Future get ensuredContext async { + BuildContext? context = navigatorKey.currentContext; + + while (context == null) { + await Future.delayed(Duration.zero); + context = navigatorKey.currentContext; + } + + return context; + } + + /// Retrieves the [NavigatorState] using the navigator key. + NavigatorState? get state { + return navigatorKey.currentState; + } + + /// Retrieves the current [StackRouter] for the auto-route controller. + StackRouter? get _controller { + return autoRouterKey.currentState?.controller; + } + + /// Pushes a new [PageRouteInfo] onto the stack and navigates to the route. + /// + /// If the [context] is mounted, it pushes the route via the auto-route controller. + /// Returns `null` if no context is available. + Future push( + PageRouteInfo route, { + OnNavigationFailure? onFailure, + bool shouldAutoDismissOthers = false, + }) async { + final BuildContext context = await ensuredContext; + + if (context.mounted) { + if (shouldAutoDismissOthers) { + $.dialog.popAllDialogs(); + await $.overlay.popOverlay(); + } + + await _controller?.push(route, onFailure: onFailure); + } + + return null; + } + + /// Navigates to the specified [PageRouteInfo] without adding it to the history stack. + Future navigate( + PageRouteInfo route, { + OnNavigationFailure? onFailure, + bool shouldAutoDismissOthers = false, + }) async { + final BuildContext context = await ensuredContext; + + if (context.mounted) { + if (shouldAutoDismissOthers) { + $.dialog.popAllDialogs(); + await $.overlay.popOverlay(); + } + + await _controller?.navigate(route, onFailure: onFailure); + } + + return; + } + + /// Navigates to a named route via its [path], with optional failure handling and prefix matching. + Future pushNamed( + String path, { + OnNavigationFailure? onFailure, + bool shouldIncludePrefixMatches = false, + bool shouldAutoDismissOthers = false, + }) async { + final BuildContext context = await ensuredContext; + + if (context.mounted) { + if (shouldAutoDismissOthers) { + $.dialog.popAllDialogs(); + await $.overlay.popOverlay(); + } + + await _controller?.navigateNamed( + path, + includePrefixMatches: shouldIncludePrefixMatches, + onFailure: onFailure, + ); + } + + return; + } + + /// Replaces the current route with the given [PageRouteInfo] in the stack. + /// + /// Optionally handles navigation failures. + Future replace( + PageRouteInfo route, { + OnNavigationFailure? onFailure, + bool shouldAutoDismissOthers = true, + }) async { + final BuildContext context = await ensuredContext; + + if (context.mounted) { + if (shouldAutoDismissOthers) { + $.dialog.popAllDialogs(); + await $.overlay.popOverlay(); + } + + if (_controller?.current.name != route.routeName) { + await _controller?.replace(route, onFailure: onFailure); + } + } + + return null; + } + + /// Replaces all existing routes with the specified [routes] list. + /// Optionally, it can update existing routes or handle navigation failures. + Future replaceAll( + List routes, { + OnNavigationFailure? onFailure, + bool shouldUpdateExistingRoutes = true, + bool shouldAutoDismissOthers = true, + }) async { + final BuildContext context = await ensuredContext; + + if (context.mounted) { + if (shouldAutoDismissOthers) { + $.dialog.popAllDialogs(); + await $.overlay.popOverlay(); + } + + await _controller?.replaceAll(routes, + onFailure: onFailure, + updateExistingRoutes: shouldUpdateExistingRoutes); + } + } + + /// Pops the top-most route from the stack, returning an optional result. + Future pop([T? result]) async { + final BuildContext context = await ensuredContext; + + if (context.mounted) { + await _controller?.maybePop(result); + } + + return false; + } + + /// Forces the pop of the top-most route from the stack, returning an optional result. + Future popForced([T? result]) async { + final BuildContext context = await ensuredContext; + + if (context.mounted) { + _controller?.popForced(result); + } + + return; + } + + /// Navigates back to the previous route in the stack. + void back() => _controller?.back(); +} diff --git a/infrastructure/lib/presentation/contexts/overlay_context.dart b/infrastructure/lib/presentation/contexts/overlay_context.dart new file mode 100644 index 0000000..e101401 --- /dev/null +++ b/infrastructure/lib/presentation/contexts/overlay_context.dart @@ -0,0 +1,66 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter/cupertino.dart'; +import 'package:infrastructure/presentation/contexts/navigator_context.dart'; +import 'package:infrastructure/presentation/super_class.dart'; + +/// [OverlayContext] is a utility class designed to manage overlay widgets in +/// a Flutter application. It allows you to insert or remove widgets from the +/// application's overlay stack, which is useful for creating floating widgets +/// like toasts, popups, or loading indicators. +/// +/// This class works in conjunction with the [NavigatorContext] to get access +/// to the current [BuildContext] required for interacting with the [Overlay]. + +final class OverlayContext { + OverlayContext(); + + /// The current [OverlayEntry] used to display the widget in the overlay. + OverlayEntry? _overlayEntry; + + /// Inserts a new overlay into the overlay stack using the provided [builder]. + /// + /// The [builder] is used to create the widget that will be displayed in the + /// overlay. If there is already an active overlay, this method does nothing. + /// + /// This method uses [WidgetsBinding.instance.addPostFrameCallback] to ensure + /// the overlay is inserted after the current frame is completed. + Future pushOverlay({required WidgetBuilder builder}) async { + final BuildContext context = await $.navigator.ensuredContext; + + if (context.mounted) { + if (_overlayEntry != null) { + return; + } + + final OverlayState overlayState = Overlay.of(context); + _overlayEntry = OverlayEntry(builder: builder); + overlayState.insert(_overlayEntry!); + } + + return; + } + + /// Removes the current overlay entry from the overlay stack. + /// + /// If no overlay is active, this method does nothing. Once the overlay is + /// removed, the reference to the [OverlayEntry] is set to `null`. + Future popOverlay() async { + final BuildContext context = await $.navigator.ensuredContext; + + if (context.mounted) { + if (_overlayEntry == null) { + return; + } + + _overlayEntry!.remove(); + _overlayEntry = null; + } + + return; + } +} diff --git a/infrastructure/lib/presentation/contexts/toast_context.dart b/infrastructure/lib/presentation/contexts/toast_context.dart new file mode 100644 index 0000000..1ccc4e3 --- /dev/null +++ b/infrastructure/lib/presentation/contexts/toast_context.dart @@ -0,0 +1,339 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:infrastructure/presentation/_core/toast/toast.dart'; +import 'package:infrastructure/presentation/_core/toast/toast_wrapper.dart'; +import 'package:infrastructure/presentation/super_class.dart'; + +enum ToastAlignment { bottom, top } + +enum ToastLength { ages, long, medium, never, short } + +enum ToastDismissDirection { down, horizontal, up } + +/// A model that holds the state and properties of a toast message. +class ToastModel { + ToastModel({ + required this.controller, + this.overlayEntry, + this.isExpanded = false, + this.position = 10, + }); + + /// The animation controller used to animate the toast appearance and dismissal. + AnimationController controller; + + /// Whether the toast is expanded. + bool isExpanded; + + /// The vertical position of the toast. + double position; + + /// The [OverlayEntry] that represents the toast widget in the overlay. + OverlayEntry? overlayEntry; + + /// Disposes of the toast and its resources, removing it from the overlay. + void dispose() { + overlayEntry + ?..remove() + ..dispose(); + controller.dispose(); + } +} + +/// The [ToastContext] class manages the display and dismissal of toast messages. +/// It allows for multiple toasts to be shown in a stack, with configurable +/// properties such as alignment, duration, and dismissal direction. +final class ToastContext { + ToastContext(); + + /// List of active toast overlays. + final List _overlays = []; + + /// The current overlay state. + OverlayState? _overlayState; + + /// Maximum number of toasts that can be displayed at a time. + final int _showMaxToastNumber = 5; + + /// Gets the default dismiss direction based on the alignment. + ToastDismissDirection _getDefaultDismissDirection( + ToastAlignment alignment, + ToastDismissDirection? dismissDirection, + ) { + if (dismissDirection == null) { + switch (alignment) { + case ToastAlignment.top: + return ToastDismissDirection.up; + case ToastAlignment.bottom: + return ToastDismissDirection.down; + default: + return ToastDismissDirection.horizontal; + } + } else { + return dismissDirection; + } + } + + /// Displays a toast with text. + Future pushToast({ + Color? backgroundColor, + ToastDismissDirection? dismissDirection, + Widget? leading, + String? message, + TextStyle? messageStyle, + Color? shadowColor, + Curve? slideCurve, + ToastAlignment alignment = ToastAlignment.top, + double expandedHeight = 50, + bool isClosable = false, + ToastLength length = ToastLength.short, + Curve positionCurve = Curves.elasticOut, + bool shouldUseSafeArea = true, + }) async { + await _showToast( + backgroundColor: backgroundColor, + dismissDirection: dismissDirection, + leading: leading, + message: message, + messageStyle: messageStyle, + shadowColor: shadowColor, + slideCurve: slideCurve, + alignment: alignment, + expandedHeight: expandedHeight, + isClosable: isClosable, + length: length, + positionCurve: positionCurve, + shouldUseSafeArea: shouldUseSafeArea, + ); + } + + /// Displays a toast with a custom widget. + Future pushWidgetToast({ + Color? backgroundColor, + Widget? child, + ToastDismissDirection? dismissDirection, + Color? shadowColor, + Curve? slideCurve, + ToastAlignment alignment = ToastAlignment.top, + double expandedHeight = 50, + bool isClosable = false, + ToastLength length = ToastLength.short, + Curve positionCurve = Curves.elasticOut, + bool shouldUseSafeArea = true, + }) async { + await _showToast( + backgroundColor: backgroundColor, + child: child, + dismissDirection: dismissDirection, + shadowColor: shadowColor, + slideCurve: slideCurve, + alignment: alignment, + expandedHeight: expandedHeight, + isClosable: isClosable, + length: length, + positionCurve: positionCurve, + shouldUseSafeArea: shouldUseSafeArea, + ); + } + + /// Core method to show a toast with various configurable options. + Future _showToast({ + Color? backgroundColor, + Widget? child, + ToastDismissDirection? dismissDirection, + Widget? leading, + String? message, + TextStyle? messageStyle, + Color? shadowColor, + Curve? slideCurve, + ToastAlignment alignment = ToastAlignment.top, + double expandedHeight = 50, + bool isClosable = false, + ToastLength length = ToastLength.medium, + Curve positionCurve = Curves.elasticOut, + bool shouldUseSafeArea = true, + }) async { + // Assertions to ensure valid configurations. + assert( + expandedHeight >= 0.0, + 'Expanded height should not be a negative number!', + ); + assert( + (alignment == ToastAlignment.top && + dismissDirection != ToastDismissDirection.down) || + (alignment == ToastAlignment.bottom && + dismissDirection != ToastDismissDirection.up) || + (dismissDirection == ToastDismissDirection.horizontal), + 'If ToastAlignment is top then ToastDismissDirection must not be down. If ToastAlignment is bottom then ToastDismissDirection must not be up.', + ); + + final BuildContext context = await $.navigator.ensuredContext; + + if (context.mounted) { + _overlayState = Overlay.of(context); + final AnimationController controller = AnimationController( + duration: const Duration(milliseconds: 1000), + reverseDuration: const Duration(milliseconds: 1000), + vsync: _overlayState!, + ); + + final ToastModel toast = ToastModel(controller: controller); + + final OverlayEntry overlayEntry = OverlayEntry( + builder: (_) => AnimatedBuilder( + animation: controller, + builder: (_, __) { + return ToastWrapper( + alignment: alignment, + animatedOpacity: _calculateOpacity(toast), + child: Toast( + controller: controller, + onTap: () => _handleToggleExpand(toast), + backgroundColor: backgroundColor, + child: child, + curve: slideCurve, + isClosable: isClosable, + leading: leading, + message: message, + messageStyle: messageStyle, + onClose: () { + _removeOverlayEntry(toast); + _updateOverlayPositions(true, toast); + }, + shadowColor: shadowColor, + isInFront: _isToastInFront(toast), + ), + controller: controller, + dismissDirection: dismissDirection ?? + _getDefaultDismissDirection(alignment, dismissDirection), + expandedPaddingHorizontal: + (toast.isExpanded ? 10 : math.max(toast.position - 35, 0)), + expandedPositionedPadding: + toast.position + (toast.isExpanded ? expandedHeight : 0.0), + onDismissed: () { + _removeOverlayEntry(toast); + _updateOverlayPositions(true, toast); + }, + positionCurve: positionCurve, + shouldUseSafeArea: shouldUseSafeArea, + ); + }, + ), + ); + + toast.overlayEntry = overlayEntry; + _overlays.add(toast); + + _updateOverlayPositions(false, toast); + _forwardAnimation(toast); + await Future.delayed(_toastDuration(length)); + await _reverseAnimation(toast); + } + } + + /// Plays the reverse animation to hide the toast. + Future _reverseAnimation(ToastModel toast) async { + if (!_overlays.contains(toast)) { + return; + } + + await toast.controller.reverse(); + await Future.delayed(const Duration(milliseconds: 50)); + _removeOverlayEntry(toast); + } + + /// Removes the overlay entry for the toast and disposes its resources. + void _removeOverlayEntry(ToastModel toast) { + toast.dispose(); + _overlays.remove(toast); + } + + /// Plays the forward animation to show the toast. + void _forwardAnimation(ToastModel toast) { + _overlayState?.insert(toast.overlayEntry!); + toast.controller.forward(); + } + + /// Calculates the opacity of the toast based on the number of toasts being shown. + double _calculateOpacity(ToastModel toast) { + final int noOfShowToast = _showMaxToastNumber; + if (_overlays.length <= noOfShowToast) { + return 1; + } + + final List recentOverlays = + _overlays.sublist(_overlays.length - noOfShowToast); + + return recentOverlays.contains(toast) ? 1 : 0; + } + + /// Determines whether the toast is in front of the other toasts. + bool _isToastInFront(ToastModel toast) { + final int noOfShowToast = _showMaxToastNumber; + + return _overlays.indexOf(toast) >= _overlays.length - noOfShowToast; + } + + /// Updates the positions of all visible toasts when a new toast is added or removed. + void _updateOverlayPositions(bool isReverse, ToastModel toast) { + if (isReverse) { + _reverseUpdatePositions(toast); + } else { + _forwardUpdatePositions(); + } + } + + /// Forces all toast overlays to rebuild. + void _rebuildPositions() { + for (final ToastModel overlayInfo in _overlays) { + overlayInfo.overlayEntry?.markNeedsBuild(); + } + } + + /// Adjusts the positions of existing toasts when a toast is removed. + void _reverseUpdatePositions(ToastModel toast) { + final int currentIndex = _overlays.indexOf(toast); + for (int i = currentIndex - 1; i >= 0; i -= 1) { + _overlays.elementAtOrNull(i)?.position -= 10; + _overlays.elementAtOrNull(i)?.overlayEntry?.markNeedsBuild(); + } + } + + /// Adjusts the positions of existing toasts when a new toast is added. + void _forwardUpdatePositions() { + for (final ToastModel overlayInfo in _overlays) { + overlayInfo.position += 10; + overlayInfo.overlayEntry?.markNeedsBuild(); + } + } + + /// Toggles the expanded state of a toast, causing it to rebuild and adjust its size. + void _handleToggleExpand(ToastModel toast) { + toast.isExpanded = !toast.isExpanded; + _rebuildPositions(); + } + + /// Determines the duration for how long a toast should be shown based on the [ToastLength]. + Duration _toastDuration(ToastLength length) { + switch (length) { + case ToastLength.short: + return const Duration(milliseconds: 2000); + case ToastLength.medium: + return const Duration(milliseconds: 3500); + case ToastLength.long: + return const Duration(milliseconds: 5000); + case ToastLength.ages: + return const Duration(minutes: 2); + default: + return const Duration(hours: 24); + } + } +} diff --git a/infrastructure/lib/presentation/cubits/paginated_list.cubit.dart b/infrastructure/lib/presentation/cubits/paginated_list.cubit.dart new file mode 100644 index 0000000..6045b12 --- /dev/null +++ b/infrastructure/lib/presentation/cubits/paginated_list.cubit.dart @@ -0,0 +1,23 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/flutter_bloc.dart'; +import 'package:deps/packages/freezed_annotation.dart'; +import 'package:infrastructure/analytics/failure/failure.dart'; +import 'package:infrastructure/presentation/models/paginated.model.dart'; + +part 'paginated_list.cubit.freezed.dart'; +part 'states/paginated_list.state.dart'; + +/// An abstract class that defines the behavior of paginated list cubits. +/// It must be extended by a concrete class to implement data fetching. +abstract class PaginatedListCubit implements Cubit> { + /// Fetches a paginated list of items with a specified limit and page. + /// + /// [limit] - The number of items to fetch (default is 20). + /// [page] - The page from where to start fetching (default is 0). + Future fetch({int limit = 20, int page = 0}); +} diff --git a/infrastructure/lib/presentation/cubits/states/paginated_list.state.dart b/infrastructure/lib/presentation/cubits/states/paginated_list.state.dart new file mode 100644 index 0000000..cb27713 --- /dev/null +++ b/infrastructure/lib/presentation/cubits/states/paginated_list.state.dart @@ -0,0 +1,28 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +part of '../paginated_list.cubit.dart'; + +/// Defines the various states of the [PaginatedListCubit] using Freezed. +@freezed +class PaginatedListState with _$PaginatedListState { + /// Represents the state when data fetching fails with a [Failure]. + const factory PaginatedListState.failed(Failure failure) = + PaginatedListStateFailed; + + /// Represents the initial state before any data has been loaded. + const factory PaginatedListState.initial() = PaginatedListStateInitial; + + /// Represents the state when data has been successfully loaded. + const factory PaginatedListState.loaded(PaginatedModel data) = + PaginatedListStateLoaded; + + /// Represents the loading state while data is being fetched. + const factory PaginatedListState.loading() = PaginatedListStateLoading; + + /// Represents the state when the list is being refreshed. + const factory PaginatedListState.refresh() = PaginatedListStateRefresh; +} diff --git a/infrastructure/lib/presentation/extensions/styled_text.ext.dart b/infrastructure/lib/presentation/extensions/styled_text.ext.dart new file mode 100644 index 0000000..6adf001 --- /dev/null +++ b/infrastructure/lib/presentation/extensions/styled_text.ext.dart @@ -0,0 +1,87 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/styled_text.dart'; +import 'package:flutter/material.dart'; + +extension StyledTextExt on String { + /// A convenient extension on String to create a StyledText widget with predefined and custom styles. + /// + /// You can pass optional `style`, custom `tags` for specific text styles, and `widgets` for embedding widgets in text. + /// The `styled` method also supports `TextAlign`, `TextOverflow`, `opacity` for widgets, and `maxLines`. + StyledText styled({ + TextStyle? style, + Map? tags, + Map? widgets, + TextAlign textAlign = TextAlign.start, + TextOverflow overflow = TextOverflow.clip, + double opacity = 1, + int? maxLines, + }) { + /// Defines the default tags for styled text. + /// The predefined tags are: + /// - 'sb' for semi-bold text. + /// - 'b' for bold text. + /// - 'i' for italic text. + Map getTags() { + return style == null + ? {} + : { + 'sb': StyledTextTag( + style: style.copyWith( + fontWeight: FontWeight.w600, + ), + ), + 'b': StyledTextTag( + style: style.copyWith( + fontWeight: FontWeight.bold, + ), + ), + 'i': StyledTextTag( + style: style.copyWith( + fontStyle: FontStyle.italic, + ), + ), + }; + } + + /// Converts the provided `tags` map into a map of `StyledTextTag` objects. + Map convertTextStyleTags() { + return tags == null + ? {} + : tags.map((String key, TextStyle textStyle) { + return MapEntry( + key, StyledTextTag(style: textStyle)); + }); + } + + /// Converts the provided `widgets` map into a map of `StyledTextTagBase` objects with opacity applied. + Map convertWidgetTags() { + return widgets == null + ? {} + : widgets.map((String key, Widget widget) { + return MapEntry( + key, + StyledTextWidgetTag(Opacity(opacity: opacity, child: widget)), + ); + }); + } + + /// Returns a StyledText widget with the combined styles and tags. + return StyledText( + text: this, + style: style, + tags: { + ...getTags(), + ...convertTextStyleTags(), + ...convertWidgetTags(), + }, + textAlign: textAlign, + overflow: overflow, + maxLines: maxLines, + ); + } +} diff --git a/infrastructure/lib/presentation/helpers/helpers.dart b/infrastructure/lib/presentation/helpers/helpers.dart new file mode 100644 index 0000000..7fdf943 --- /dev/null +++ b/infrastructure/lib/presentation/helpers/helpers.dart @@ -0,0 +1,53 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// The `Helpers` class provides utility functions. +final class Helpers { + const Helpers(); + + /// Returns a [TextInputFormatter] that enforces a prefix at the beginning of the input text. + /// If the user tries to remove or alter the prefix, the formatter automatically restores it. + /// + /// - [prefix]: The string that will always appear at the start of the input text. + /// + /// Example: + /// ```dart + /// TextFormField( + /// inputFormatters: [ + /// Helpers().enforcePrefixFormatter('+1'), + /// ], + /// ) + /// ``` + TextInputFormatter enforcePrefixFormatter(String prefix) { + return TextInputFormatter.withFunction( + (TextEditingValue oldValue, TextEditingValue newValue) { + // Check if the new value starts with the provided prefix. + if (newValue.text.startsWith(prefix)) { + return newValue; + } + + // Find the index of the prefix in the old value. + final int cutIndex = oldValue.text.lastIndexOf(prefix); + + // If the prefix is not found, forcefully set the text to the prefix only. + return cutIndex == -1 + ? TextEditingValue( + text: prefix, + selection: TextSelection.collapsed(offset: prefix.length), + ) + : TextEditingValue( + text: oldValue.text.characters + .getRange(0, cutIndex + prefix.length) + .toString(), + selection: + TextSelection.collapsed(offset: cutIndex + prefix.length), + ); + }); + } +} diff --git a/infrastructure/lib/presentation/main_binding.dart b/infrastructure/lib/presentation/main_binding.dart new file mode 100644 index 0000000..aca171f --- /dev/null +++ b/infrastructure/lib/presentation/main_binding.dart @@ -0,0 +1,182 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'dart:async'; +import 'dart:ui'; + +import 'package:deps/infrastructure/infrastructure.dart'; +import 'package:deps/locator/locator.dart'; +import 'package:deps/packages/adaptive_theme.dart'; +import 'package:deps/packages/flutter_native_splash.dart'; +import 'package:deps/packages/hydrated_bloc.dart'; +import 'package:deps/packages/path_provider.dart'; +import 'package:deps/packages/talker_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:infrastructure/analytics/observers/i_failure_observer.dart'; +import 'package:infrastructure/presentation/widgets/_core/error.page.dart'; +import 'package:infrastructure/presentation/wrappers/_core/app_settings.dart'; +import 'package:infrastructure/presentation/wrappers/_core/observer_settings.dart'; + +typedef AppWrapperCallback = Widget Function(Widget widget); +typedef MainCallback = Future Function(WidgetsBinding binding)?; + +/// A class that handles the initialization of the application environment, +/// error handling, theming, and other core configurations for a Flutter app. +class MainBinding extends WidgetsFlutterBinding { + /// Constructor to initialize the [MainBinding] with optional settings. + /// + /// If [appSettings], [appWrapper], [env], or [observerSettings] are not provided, + /// the class will initialize them with default values. + MainBinding({ + this.appSettings, + this.appWrapper, + this.env, + this.mainCallback, + this.observerSettings, + }) { + // Builds the app and runs it. + build().then(runApp); + } + + /// Optional settings to customize the app's configuration. + final AppSettings? appSettings; + + /// Optional wrapper around the app, useful for customization of the app widget. + final AppWrapperCallback? appWrapper; + + /// The current environment (development, production, etc.). + final EnvEnum? env; + + /// Optional callback for additional initialization logic during the main setup. + final MainCallback mainCallback; + + /// Settings for configuring observers like Bloc and Dio. + final ObserverSettings? observerSettings; + + /// Initializes and builds the app. + /// + /// This method handles the initialization of service locators, failure observers, + /// caching assets, setting themes, and configuring error handling. + Future build() async { + // Initialize optional settings if not provided. + final AppSettings theAppSettings = appSettings ?? const AppSettings(); + final ObserverSettings theObserverSettings = + observerSettings ?? const ObserverSettings(); + + // Ensure the Flutter widget binding is initialized. + final WidgetsBinding binding = WidgetsFlutterBinding.ensureInitialized(); + + // Initialize the service locator with the correct environment. + await initLocator(_env(env)); + + // Set up observers for Bloc and Dio if enabled. + if (theObserverSettings.isBlocObserverEnabled) { + Bloc.observer = BlocTalkerObserver( + settings: theObserverSettings.blocSettings, + talker: $.get(), + ); + } + + if (theObserverSettings.isDioObserverEnabled) { + $.get().setObserver( + DioTalkerObserver( + settings: theObserverSettings.dioSettings, + talker: $.get(), + ), + ); + } + + // Configure error handling for Flutter and platform-specific errors. + ErrorWidget.builder = (FlutterErrorDetails details) { + return ErrorPage(details: details); + }; + + FlutterError.onError = (FlutterErrorDetails details) { + FlutterError.presentError(details); + + final UnexpectedFlutterError error = UnexpectedFlutterError( + exception: details.exception, + stack: details.stack, + ); + + locator().onFailure(error); + }; + + PlatformDispatcher.instance.onError = (Object exception, StackTrace stack) { + final UnexpectedPlatformError error = + UnexpectedPlatformError(exception: exception, stack: stack); + + locator().onFailure(error); + + return true; + }; + + // Initialize HydratedBloc's storage for state persistence. + HydratedBloc.storage = await HydratedStorage.build( + storageDirectory: await getApplicationDocumentsDirectory(), + ); + + // Execute additional setup logic if provided via [mainCallback]. + await mainCallback?.call(binding); + + // Retrieve the saved theme mode for Adaptive Theme. + final AdaptiveThemeMode? savedThemeMode = + await AdaptiveTheme.getThemeMode(); + + // Cache assets like images and SVGs after the first frame is rendered. + binding.addPostFrameCallback((_) { + // TODO: Cache assets here. + // final BuildContext? context = binding.rootElement; + // + // for (final AssetGenImage image in $.images.values) { + // precacheImage(AssetImage(image.path, package: 'design'), context!); + // } + + // for (final SvgGenImage icon in $.icons.values) { + // final SvgAssetLoader logo = SvgAssetLoader(icon.path, packageName: 'design'); + // svg.cache.putIfAbsent(logo.cacheKey(null), () => logo.loadBytes(null)); + // } + + // Remove the splash screen after initialization. + FlutterNativeSplash.remove(); + }); + + // Return the app wrapped in the optional [appWrapper] if provided, otherwise return the default AppWrapper. + return appWrapper == null + ? AppWrapper( + appSettings: theAppSettings, + isRouterObserverEnabled: + theObserverSettings.isRouterObserverEnabled, + savedThemeMode: savedThemeMode, + ) + : appWrapper!( + AppWrapper( + appSettings: theAppSettings, + isRouterObserverEnabled: + theObserverSettings.isRouterObserverEnabled, + savedThemeMode: savedThemeMode, + ), + ); + } + + /// Determines the environment (development, production, etc.) from the [EnvEnum]. + /// + /// The method uses environment variables to determine the current environment. + /// If the `flavor` environment variable is set, it overrides the provided [env] parameter. + String _env(EnvEnum? env) { + // Determine the default environment value based on the [env] parameter. + final String defaultValue = switch (env) { + EnvEnum.dev => 'dev', + EnvEnum.prod => 'prod', + _ => 'dev', + }; + + // Use the determined default value or the environment variable 'flavor'. + return const bool.hasEnvironment('flavor') + ? const String.fromEnvironment('flavor') + : defaultValue; + } +} diff --git a/infrastructure/lib/presentation/models/paginated.model.dart b/infrastructure/lib/presentation/models/paginated.model.dart new file mode 100644 index 0000000..030e18a --- /dev/null +++ b/infrastructure/lib/presentation/models/paginated.model.dart @@ -0,0 +1,30 @@ +import 'package:deps/packages/freezed_annotation.dart'; + +part 'paginated.model.freezed.dart'; +part 'paginated.model.g.dart'; + +void pageConverter(Map json, dynamic _) => json['currentPage'] ?? json['page']; + +@freezed +@JsonSerializable(genericArgumentFactories: true) +class PaginatedModel with _$PaginatedModel { + const factory PaginatedModel({ + @JsonKey(readValue: pageConverter) required int currentPage, + required int size, + required int total, + required List items, + }) = _PaginatedModel; + + const PaginatedModel._(); + + factory PaginatedModel.fromJson( + Map json, + T Function(Object? json) fromJsonT, + ) { + return _$PaginatedModelFromJson(json, fromJsonT); + } + + factory PaginatedModel.empty() => PaginatedModel(currentPage: 1, size: 100, total: 0, items: []); + + bool get isNotEmpty => this != PaginatedModel.empty(); +} diff --git a/infrastructure/lib/presentation/super_class.dart b/infrastructure/lib/presentation/super_class.dart new file mode 100644 index 0000000..71f9be9 --- /dev/null +++ b/infrastructure/lib/presentation/super_class.dart @@ -0,0 +1,161 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: file_names, prefer-match-file-name, prefer-correct-type-name, prefer-named-parameters, avoid-dynamic, prefer-correct-identifier-length, avoid-late-keyword + +import 'package:deps/design/design.dart'; +import 'package:deps/infrastructure/infrastructure.dart'; +import 'package:deps/locator/locator.dart'; +import 'package:flutter/material.dart'; +import 'package:infrastructure/permissions/permissions.dart'; +import 'package:infrastructure/presentation/constants/paddings.dart'; +import 'package:infrastructure/presentation/constants/platform.dart'; +import 'package:infrastructure/presentation/constants/radiuses.dart'; +import 'package:infrastructure/presentation/constants/timings.dart'; +import 'package:infrastructure/presentation/contexts/bloc_context.dart'; +import 'package:infrastructure/presentation/contexts/navigator_context.dart'; +import 'package:infrastructure/presentation/helpers/helpers.dart'; + +/// Global utility class that provides access to various application-wide services, +/// constants, contexts, and helpers. +/// +/// This class follows the singleton pattern to provide a unified access point +/// for common utilities such as navigation, dialogs, toasts, permissions, and more. +/// +/// Example usage: +/// ```dart +/// $.navigator.push(...); +/// $.paddings.lg; +/// $.helpers.enforcePrefixFormatter(...); +/// ``` +@immutable +final class $ { + /// Factory method to return the singleton instance. + factory $() => i; + + /// Private internal constructor for the singleton instance. + /// Initializes various contexts, constants, and managers. + $._internal() { + _navigator = NavigatorContext(); + _overlay = OverlayContext(); + _dialog = DialogContext(); + _toast = ToastContext(); + _bloc = const BlocContext(); + _timings = Timings(); + _radiuses = Radiuses(); + _paddings = Paddings(); + _platform = Platform(); + _permissions = Permissions(); + } + + /// Global app context, throws an error if the navigator context is not initialized. + static final BuildContext context = i._navigator.context ?? + (throw StateError('Navigator context is not initialized.')); + + /// Navigator context for handling navigation. + static final NavigatorContext navigator = i._navigator; + + /// Dialog context for managing dialogs. + static final DialogContext dialog = i._dialog; + + /// Toast context for showing toast messages. + static final ToastContext toast = i._toast; + + /// Overlay context for handling custom overlays. + static final OverlayContext overlay = i._overlay; + + /// Bloc context for managing state using the Bloc pattern. + static final BlocContext bloc = i._bloc; + + /// Singleton instance of the [$] class. + static final $ i = $._internal(); + + // Internal references to various managers, constants, and contexts. + + /// Navigator context for managing navigation within the app. + late final NavigatorContext _navigator; + + /// Dialog context for managing and displaying dialogs. + late final DialogContext _dialog; + + /// Toast context for showing toast messages within the app. + late final ToastContext _toast; + + /// Overlay context for managing custom overlays. + late final OverlayContext _overlay; + + /// Bloc context for managing app state using Bloc. + late final BlocContext _bloc; + + /// Timings constants for defining standard durations used throughout the app. + late final Timings _timings; + + /// Radius constants for defining standard corner radii. + late final Radiuses _radiuses; + + /// Padding constants for defining standard padding sizes. + late final Paddings _paddings; + + /// Platform-specific utilities and constants. + late final Platform _platform; + + /// Permissions manager for handling permission requests and statuses. + late final Permissions _permissions; + + // Public getters for constants and managers. + + /// Accessor for predefined timing durations. + static Timings get timings => i._timings; + + /// Accessor for predefined corner radius sizes. + static Radiuses get radiuses => i._radiuses; + + /// Accessor for predefined padding sizes. + static Paddings get paddings => i._paddings; + + /// Accessor for platform-specific utilities and constants. + static Platform get platform => i._platform; + + /// Accessor for permissions manager. + static Permissions get permissions => i._permissions; + + // Utility and helper methods. + + /// Accessor for the app's current theme configuration. + static ThemeGen get theme => context.theme.themeGen; + + /// Accessor for app icon assets. + // static $AssetsIconsGen get icons => Assets.icons; + + /// Accessor for app image assets. + // static $AssetsImagesGen get images => Assets.images; + + /// Accessor for global helper methods. + static Helpers get helpers => const Helpers(); + + /// Accessor for translation cubit, used for internationalization. + static TranslationsCubit get tr => locator(); + + /// Generic method to retrieve a service or dependency from the service locator. + /// + /// Example: + /// ```dart + /// final MyService service = $.get(); + /// ``` + static T get() => locator(); + + /// Logs debug information through the global logger. + /// + /// [data] is the information to log, and [message] is an optional accompanying message. + static void debug(dynamic data, [String? message]) => + locator().debug(data, message); + + /// Executes a callback after the widget build is completed. + /// + /// [callback] is the function to be executed post-build. + static void afterBuildCallback(ValueChanged callback) => + WidgetsBinding.instance.addPostFrameCallback(callback); +} diff --git a/infrastructure/lib/presentation/validators/reactive_validators.dart b/infrastructure/lib/presentation/validators/reactive_validators.dart new file mode 100644 index 0000000..f1627b3 --- /dev/null +++ b/infrastructure/lib/presentation/validators/reactive_validators.dart @@ -0,0 +1,76 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/reactive_forms.dart'; +import 'package:infrastructure/_core/_i18n/infrastructure_i18n_cubit_locale.ext.dart'; +import 'package:infrastructure/infrastructure.dart'; + +typedef ValidationMessageCallback = String Function(Object? error); + +/// The `ReactiveValidators` class provides a set of common validators for Reactive Forms, +/// which are customizable based on the provided context. +abstract final class ReactiveValidators { + /// Returns a map of common validation messages for form fields. + /// + /// This function provides localized error messages for common validation rules like + /// email, required, max length, and min length. It also accepts additional custom validation messages. + /// + /// - [isPreview]: Determines if preview mode is enabled, in which case generic labels will be used instead of full messages. + /// - [labelText]: The field label, used to reference the field in the validation messages. + /// - [additionalMessages]: Optionally add extra validation messages. + /// + /// Example usage: + /// ```dart + /// final validators = ReactiveValidators.getCommonValidators( + /// isPreview: false, + /// labelText: 'Email', + /// ); + /// ``` + static Map getCommonValidators({ + required bool isPreview, + required String labelText, + Map? additionalMessages, + }) { + return { + // Validation for email format + ValidationMessage.email: (_) => isPreview + ? 'Email' + : $.tr.infrastructure.presentation.validations.email( + field: labelText.capitalizeFirst, + ), + + // Validation for max length + ValidationMessage.maxLength: (Object? error) => isPreview + ? 'Max length' + : $.tr.infrastructure.presentation.validations.maxLength( + field: labelText.capitalizeFirst, + count: (error as Map?)?['requiredLength'] + ?.toString() ?? + '', + ), + + // Validation for min length + ValidationMessage.minLength: (Object? error) => isPreview + ? 'Min length' + : $.tr.infrastructure.presentation.validations.minLength( + field: labelText.capitalizeFirst, + count: (error as Map?)?['requiredLength'] + ?.toString() ?? + '', + ), + + // Validation for required field + ValidationMessage.required: (_) => isPreview + ? 'Required' + : $.tr.infrastructure.presentation.validations.required( + field: labelText.capitalizeFirst, + ), + + // Allows merging of additional custom validation messages + ...?additionalMessages, + }; + } +} diff --git a/infrastructure/lib/presentation/widgets/_core/error.page.dart b/infrastructure/lib/presentation/widgets/_core/error.page.dart new file mode 100644 index 0000000..3ec80ea --- /dev/null +++ b/infrastructure/lib/presentation/widgets/_core/error.page.dart @@ -0,0 +1,92 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter/material.dart'; +import 'package:infrastructure/infrastructure.dart'; + +/// [ErrorPage] is a custom error page widget used to display detailed error information +/// when an unhandled exception occurs within the app. +/// +/// This page notifies the user that something went wrong and provides the option to copy +/// the error details to the clipboard, allowing for easier sharing or debugging. +class ErrorPage extends StatelessWidget { + /// Creates an instance of [ErrorPage]. + /// + /// * [details]: The error details captured during a crash or failure, including + /// the exception and stack trace. + const ErrorPage({required this.details, super.key}); + + /// The error details to be displayed on the page. + final FlutterErrorDetails details; + + @override + Widget build(BuildContext context) { + return Material( + // Background color set from the theme. + color: $.theme.background, + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Error icon with a red color to symbolize an error. + Icon( + Icons.error_outline, + size: 70, + color: $.theme.colors.red, + ), + // Medium gap between icon and text. + PaddingGap.md(), + // Main error message displayed to the user. + Text( + 'Oups! Something went wrong!', + style: + $.theme.fonts.h2.changeColor($.theme.colors.red).semiBold, + ), + PaddingGap.xxs(), + // Descriptive text explaining the error. + Text( + "We encountered an error and we've notified our engineering team about it. Sorry for the inconvenience caused.", + style: $.theme.fonts.body1.medium, + textAlign: TextAlign.center, + ), + PaddingGap.xl(), + // Container that holds the error details (exception string). + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + // Red background to indicate an error. + color: $.theme.colors.red.withOpacity(0.2), + borderRadius: $.radiuses.lg.borderRadius, + ), + constraints: const BoxConstraints( + maxHeight: 200, // Limits the height for better UX. + ), + child: Stack( + children: [ + // Scrollable view to display long error messages. + SingleChildScrollView( + child: Text( + details + .exceptionAsString(), // Displays the exception message. + style: $.theme.fonts.body2.medium, + textDirection: TextDirection + .ltr, // Left-to-right text direction. + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/infrastructure/lib/presentation/widgets/_core/gap/atoms/gap_rendering.dart b/infrastructure/lib/presentation/widgets/_core/gap/atoms/gap_rendering.dart new file mode 100644 index 0000000..89fd2d8 --- /dev/null +++ b/infrastructure/lib/presentation/widgets/_core/gap/atoms/gap_rendering.dart @@ -0,0 +1,161 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// +// https://github.com/letsar/gap + +part of '../gap.dart'; + +/// [GapRendering] is a custom render object that is responsible for rendering a gap +/// with a specified size in a layout. It works in conjunction with [Gap] or other +/// similar widgets that introduce gaps in the main or cross axis of a layout. +/// +/// This class determines how to layout the gap, computes intrinsic dimensions, +/// and paints the gap with an optional color. +class GapRendering extends RenderBox { + /// Creates a [GapRendering] instance with the required [mainAxisExtent], which is the size + /// along the main axis. Optionally, a [color], [crossAxisExtent], and [fallbackDirection] + /// can be provided. + /// + /// The [mainAxisExtent] should be non-negative and smaller than infinity. If + /// [crossAxisExtent] is provided, it specifies the size of the gap along the cross axis. + GapRendering({ + required double mainAxisExtent, + Color? color, + double? crossAxisExtent, + Axis? fallbackDirection, + }) : _mainAxisExtent = mainAxisExtent, + _crossAxisExtent = crossAxisExtent, + _color = color, + _fallbackDirection = fallbackDirection; + + /// The size of the gap along the main axis (e.g., height in a vertical flex, width in a horizontal flex). + double _mainAxisExtent; + + /// The color of the gap, if any. + Color? _color; + + /// The size of the gap along the cross axis, if any. + double? _crossAxisExtent; + + /// Fallback direction of the axis in case the gap is not placed inside a [Flex] widget. + Axis? _fallbackDirection; + + /// Determines the layout of the gap, constraining the size based on the available constraints + /// and the provided dimensions (main and cross axis extents). + @override + Size computeDryLayout(BoxConstraints constraints) { + final Axis? direction = _direction; + + if (direction == null) { + throw FlutterError( + 'A Gap widget must be placed directly inside a Flex widget ' + 'or its fallbackDirection must not be null', + ); + } + + return direction == Axis.horizontal + ? constraints.constrain(Size(mainAxisExtent, crossAxisExtent!)) + : constraints.constrain(Size(crossAxisExtent!, mainAxisExtent)); + } + + // Intrinsic dimension calculation methods for height and width, determining + // how the gap behaves when the widget is queried for its intrinsic size. + @override + double computeMaxIntrinsicHeight(double width) { + return _computeIntrinsicExtent( + Axis.vertical, () => super.computeMaxIntrinsicHeight(width))!; + } + + @override + double computeMaxIntrinsicWidth(double height) { + return _computeIntrinsicExtent( + Axis.horizontal, () => super.computeMaxIntrinsicWidth(height))!; + } + + @override + double computeMinIntrinsicHeight(double width) { + return _computeIntrinsicExtent( + Axis.vertical, () => super.computeMinIntrinsicHeight(width))!; + } + + @override + double computeMinIntrinsicWidth(double height) { + return _computeIntrinsicExtent( + Axis.horizontal, () => super.computeMinIntrinsicWidth(height))!; + } + + /// Paints the gap with the specified color, if any. + @override + void paint(PaintingContext context, Offset offset) { + if (color == null) { + return; + } + + final Paint paint = Paint()..color = color!; + context.canvas.drawRect(offset & size, paint); + } + + /// Lays out the gap based on the constraints provided by its parent widget. + @override + void performLayout() { + size = computeDryLayout(constraints); + } + + /// Determines the layout direction (main or cross axis) based on its parent widget. + /// If the parent is a [RenderFlex], it will return the parent's direction. + Axis? get _direction { + final RenderObject? parentNode = parent; + + return parentNode is RenderFlex ? parentNode.direction : fallbackDirection; + } + + // Getter and setter methods for various properties. + Color? get color => _color; + double? get crossAxisExtent => _crossAxisExtent; + Axis? get fallbackDirection => _fallbackDirection; + double get mainAxisExtent => _mainAxisExtent; + + set color(Color? value) { + if (_color == value) { + return; + } + _color = value; + markNeedsPaint(); + } + + set crossAxisExtent(double? value) { + if (_crossAxisExtent == value) { + return; + } + _crossAxisExtent = value; + markNeedsLayout(); + } + + set fallbackDirection(Axis? value) { + if (_fallbackDirection == value) { + return; + } + _fallbackDirection = value; + markNeedsLayout(); + } + + set mainAxisExtent(double value) { + if (_mainAxisExtent == value) { + return; + } + _mainAxisExtent = value; + markNeedsLayout(); + } + + /// Helper method to compute intrinsic extents for height or width. + double? _computeIntrinsicExtent(Axis axis, double Function() compute) { + final Axis? direction = _direction; + if (direction == axis) { + return _mainAxisExtent; + } + + return _crossAxisExtent!.isFinite ? _crossAxisExtent : compute(); + } +} diff --git a/infrastructure/lib/presentation/widgets/_core/gap/atoms/sliver_gap_rendering.dart b/infrastructure/lib/presentation/widgets/_core/gap/atoms/sliver_gap_rendering.dart new file mode 100644 index 0000000..ced7570 --- /dev/null +++ b/infrastructure/lib/presentation/widgets/_core/gap/atoms/sliver_gap_rendering.dart @@ -0,0 +1,114 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// +// https://github.com/letsar/gap + +part of '../sliver_gap.dart'; + +/// [SliverGapRendering] is a custom render object that renders a gap in a [CustomScrollView] or other +/// sliver-based widgets. It provides space in the scrollable area without displaying any content, +/// except when an optional color is provided to paint the gap. +/// +/// This class handles the layout and painting of the gap in the scrollable area and calculates +/// geometry such as paint extent and cache extent. +class SliverGapRendering extends RenderSliver { + /// Creates a [SliverGapRendering] instance with the required [mainAxisExtent], which determines the size + /// of the gap along the main axis (e.g., height in a vertical scroll or width in a horizontal scroll). + /// Optionally, a [color] can be provided to visually fill the gap with a color. + SliverGapRendering({required double mainAxisExtent, Color? color}) + : _mainAxisExtent = mainAxisExtent, + _color = color; + + /// The extent of the gap along the main axis (height for vertical scroll, width for horizontal scroll). + double _mainAxisExtent; + + /// Optional color to paint the gap. + Color? _color; + + /// Adds properties such as [mainAxisExtent] and [color] to the diagnostic tree for debugging purposes. + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DoubleProperty('mainAxisExtent', mainAxisExtent)) + ..add(ColorProperty('color', color)); + } + + /// Paints the gap with the specified [color], if any. If no color is provided, the gap remains empty + /// (transparent). + @override + void paint(PaintingContext context, Offset offset) { + if (color == null) { + return; + } + + final Paint paint = Paint()..color = color!; + final Size size = constraints + .asBoxConstraints( + minExtent: geometry!.paintExtent, + maxExtent: geometry!.paintExtent, + ) + .constrain(Size.zero); + context.canvas.drawRect(offset & size, paint); + } + + /// Performs layout calculations for the sliver gap. It determines the scrollable extent and how much + /// space the gap takes in the sliver layout. + /// + /// It calculates [paintExtent] and [cacheExtent] based on the provided constraints, and sets the + /// sliver geometry accordingly. + @override + void performLayout() { + final double paintExtent = calculatePaintOffset( + constraints, + from: 0, + to: mainAxisExtent, + ); + final double cacheExtent = calculateCacheOffset( + constraints, + from: 0, + to: mainAxisExtent, + ); + + assert(paintExtent.isFinite); + assert(paintExtent >= 0.0); + + geometry = SliverGeometry( + scrollExtent: mainAxisExtent, + paintExtent: paintExtent, + maxPaintExtent: mainAxisExtent, + hitTestExtent: paintExtent, + hasVisualOverflow: mainAxisExtent > constraints.remainingPaintExtent || + constraints.scrollOffset > 0.0, + cacheExtent: cacheExtent, + ); + } + + /// Getter and setter methods for the [color] and [mainAxisExtent] properties. + + /// The color used to paint the gap, if any. + Color? get color => _color; + + /// The size of the gap along the main axis. + double get mainAxisExtent => _mainAxisExtent; + + /// Updates the color of the gap and triggers a repaint if the color changes. + set color(Color? value) { + if (_color == value) { + return; + } + _color = value; + markNeedsPaint(); + } + + /// Updates the size of the gap along the main axis and triggers a layout update if the size changes. + set mainAxisExtent(double value) { + if (_mainAxisExtent == value) { + return; + } + _mainAxisExtent = value; + markNeedsLayout(); + } +} diff --git a/infrastructure/lib/presentation/widgets/_core/gap/gap.dart b/infrastructure/lib/presentation/widgets/_core/gap/gap.dart new file mode 100644 index 0000000..fd69cdb --- /dev/null +++ b/infrastructure/lib/presentation/widgets/_core/gap/gap.dart @@ -0,0 +1,195 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// +// https://github.com/letsar/gap + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +part 'atoms/gap_rendering.dart'; + +/// A widget that provides a customizable gap between UI elements. +/// The `Gap` widget allows you to insert space of a defined size in the main +/// axis of the layout, with an optional color and cross-axis extent. +/// +/// It supports both flexible and inflexible gaps with the `Gap.expand` constructor. +class Gap extends StatelessWidget { + /// Creates a `Gap` widget. + /// + /// The [mainAxisExtent] determines the size of the gap in the main axis direction + /// (e.g., vertically in a column or horizontally in a row). The gap can optionally + /// have a [color] and a [crossAxisExtent], which defines the extent in the + /// cross-axis direction. + /// + /// The [mainAxisExtent] must be a non-negative number and smaller than infinity. + const Gap( + this.mainAxisExtent, { + this.color, + this.crossAxisExtent, + super.key, + }) : assert(mainAxisExtent >= 0 && mainAxisExtent < double.infinity), + assert(crossAxisExtent == null || crossAxisExtent >= 0); + + /// A named constructor for creating an expandable `Gap` widget. + /// + /// The gap will occupy the full available space in the cross axis, as + /// defined by [crossAxisExtent]. + const Gap.expand(double mainAxisExtent, {Color? color, Key? key}) + : this( + mainAxisExtent, + color: color, + crossAxisExtent: double.infinity, + key: key, + ); + + @override + Widget build(BuildContext context) { + final ScrollableState? scrollableState = Scrollable.maybeOf(context); + final AxisDirection? axisDirection = scrollableState?.axisDirection; + final Axis? fallbackDirection = + axisDirection == null ? null : axisDirectionToAxis(axisDirection); + + return _RawGap( + mainAxisExtent, + color: color, + crossAxisExtent: crossAxisExtent, + fallbackDirection: fallbackDirection, + ); + } + + /// The background color of the gap. + /// + /// If null, no background color is applied. + final Color? color; + + /// The extent of the gap in the cross-axis direction. + /// + /// This is optional and will default to the intrinsic size of the cross axis if null. + final double? crossAxisExtent; + + /// The extent of the gap in the main-axis direction. + /// + /// Must be a non-negative number and smaller than infinity. + final double mainAxisExtent; +} + +/// A widget that provides a flexible gap between UI elements. +/// +/// `MaxGap` works similarly to `Gap`, but it is designed to be flexible and adjust +/// its size based on the available space, while still maintaining the defined main axis +/// size. It uses `Flexible` to wrap the gap. +class MaxGap extends StatelessWidget { + /// Creates a flexible `MaxGap` widget. + /// + /// The [mainAxisExtent] defines the size of the gap in the main axis direction. + const MaxGap( + this.mainAxisExtent, { + this.color, + this.crossAxisExtent, + super.key, + }); + + /// A named constructor for creating an expandable `MaxGap` widget. + /// + /// The gap will occupy the full available space in the cross axis. + const MaxGap.expand(double mainAxisExtent, {Color? color, Key? key}) + : this( + mainAxisExtent, + color: color, + crossAxisExtent: double.infinity, + key: key, + ); + + @override + Widget build(BuildContext context) { + return Flexible( + child: _RawGap( + mainAxisExtent, + color: color, + crossAxisExtent: crossAxisExtent, + ), + ); + } + + /// The background color of the gap. + /// + /// If null, no background color is applied. + final Color? color; + + /// The extent of the gap in the cross-axis direction. + /// + /// This is optional and will default to the intrinsic size of the cross axis if null. + final double? crossAxisExtent; + + /// The extent of the gap in the main-axis direction. + /// + /// Must be a non-negative number and smaller than infinity. + final double mainAxisExtent; +} + +/// The internal widget used by both [Gap] and [MaxGap] to render the gap. +/// +/// This widget is responsible for the rendering logic and is not intended to be +/// used directly. It renders a gap of a specified size and color, along with +/// optional cross-axis extent and direction fallback. +class _RawGap extends LeafRenderObjectWidget { + const _RawGap( + this.mainAxisExtent, { + this.color, + this.crossAxisExtent, + this.fallbackDirection, + }) : assert(mainAxisExtent >= 0 && mainAxisExtent < double.infinity), + assert(crossAxisExtent == null || crossAxisExtent >= 0); + + /// The background color of the gap. + /// + /// If null, no background color is applied. + final Color? color; + + /// The extent of the gap in the cross-axis direction. + /// + /// This is optional and will default to the intrinsic size of the cross axis if null. + final double? crossAxisExtent; + + /// The fallback direction to use if the context's scroll direction is not + /// available. + final Axis? fallbackDirection; + + /// The extent of the gap in the main-axis direction. + /// + /// Must be a non-negative number and smaller than infinity. + final double mainAxisExtent; + + @override + RenderObject createRenderObject(BuildContext context) { + return GapRendering( + mainAxisExtent: mainAxisExtent, + color: color, + crossAxisExtent: crossAxisExtent ?? 0, + fallbackDirection: fallbackDirection, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DoubleProperty('mainAxisExtent', mainAxisExtent)) + ..add( + DoubleProperty('crossAxisExtent', crossAxisExtent, defaultValue: 0), + ) + ..add(ColorProperty('color', color)) + ..add(EnumProperty('fallbackDirection', fallbackDirection)); + } + + @override + void updateRenderObject(BuildContext context, GapRendering renderObject) { + renderObject + ..mainAxisExtent = mainAxisExtent + ..crossAxisExtent = crossAxisExtent ?? 0 + ..color = color + ..fallbackDirection = fallbackDirection; + } +} diff --git a/infrastructure/lib/presentation/widgets/_core/gap/sliver_gap.dart b/infrastructure/lib/presentation/widgets/_core/gap/sliver_gap.dart new file mode 100644 index 0000000..031c080 --- /dev/null +++ b/infrastructure/lib/presentation/widgets/_core/gap/sliver_gap.dart @@ -0,0 +1,64 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// +// https://github.com/letsar/gap + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +part 'atoms/sliver_gap_rendering.dart'; + +/// A widget that creates a gap inside a sliver, which can be used within +/// a [CustomScrollView] or other sliver-based scrollable widgets. +/// +/// The [SliverGap] widget inserts a gap with a specific [mainAxisExtent] (height for vertical scroll, +/// width for horizontal scroll). An optional [color] can be applied to the gap. +class SliverGap extends LeafRenderObjectWidget { + /// Creates a sliver-based gap with a given [mainAxisExtent] (size in the main axis). + /// + /// The [mainAxisExtent] defines the size of the gap along the main axis (e.g., height in a vertical + /// scrolling view or width in a horizontal scrolling view). The gap can optionally have a [color]. + /// + /// The [mainAxisExtent] must be a non-negative number and smaller than infinity. + const SliverGap( + this.mainAxisExtent, { + this.color, + super.key, + }) : assert(mainAxisExtent >= 0 && mainAxisExtent < double.infinity); + + /// The background color of the gap. + /// + /// If null, no background color is applied to the gap. + final Color? color; + + /// The size of the gap in the main axis direction (e.g., height in a vertical scroll, width in a horizontal scroll). + /// + /// Must be a non-negative number and smaller than infinity. + final double mainAxisExtent; + + @override + RenderObject createRenderObject(BuildContext context) { + return SliverGapRendering( + mainAxisExtent: mainAxisExtent, + color: color, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DoubleProperty('mainAxisExtent', mainAxisExtent)) + ..add(ColorProperty('color', color)); + } + + @override + void updateRenderObject( + BuildContext context, SliverGapRendering renderObject) { + renderObject + ..mainAxisExtent = mainAxisExtent + ..color = color; + } +} diff --git a/infrastructure/lib/presentation/widgets/_core/paddings/padding_all.dart b/infrastructure/lib/presentation/widgets/_core/paddings/padding_all.dart new file mode 100644 index 0000000..96e3f30 --- /dev/null +++ b/infrastructure/lib/presentation/widgets/_core/paddings/padding_all.dart @@ -0,0 +1,66 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter/material.dart'; +import 'package:infrastructure/presentation/super_class.dart'; + +/// [PaddingAll] is a convenience widget that applies padding of different +/// sizes around its child. The padding sizes are predefined based on a +/// consistent design system that is accessible through the global `$.paddings` +/// object. This class offers a variety of factory constructors for common +/// padding sizes. +/// +/// This helps maintain consistency in spacing throughout the UI. +class PaddingAll extends StatelessWidget { + /// The internal constructor that takes in a [child] and a [padding] value. + /// This is used by the factory constructors to build a [PaddingAll] instance. + const PaddingAll._({required this.child, required this.padding, super.key}); + + /// Factory constructor that creates a [PaddingAll] widget with large (lg) padding. + factory PaddingAll.lg({required Widget child, Key? key}) => + PaddingAll._(child: child, padding: $.paddings.lg, key: key); + + /// Factory constructor that creates a [PaddingAll] widget with medium (md) padding. + factory PaddingAll.md({required Widget child, Key? key}) => + PaddingAll._(child: child, padding: $.paddings.md, key: key); + + /// Factory constructor that creates a [PaddingAll] widget with small (sm) padding. + factory PaddingAll.sm({required Widget child, Key? key}) => + PaddingAll._(child: child, padding: $.paddings.sm, key: key); + + /// Factory constructor that creates a [PaddingAll] widget with extra-large (xl) padding. + factory PaddingAll.xl({required Widget child, Key? key}) => + PaddingAll._(child: child, padding: $.paddings.xl, key: key); + + /// Factory constructor that creates a [PaddingAll] widget with extra-small (xs) padding. + factory PaddingAll.xs({required Widget child, Key? key}) => + PaddingAll._(child: child, padding: $.paddings.xs, key: key); + + /// Factory constructor that creates a [PaddingAll] widget with extra-extra-large (xxl) padding. + factory PaddingAll.xxl({required Widget child, Key? key}) => + PaddingAll._(child: child, padding: $.paddings.xxl, key: key); + + /// Factory constructor that creates a [PaddingAll] widget with extra-extra-small (xxs) padding. + factory PaddingAll.xxs({required Widget child, Key? key}) => + PaddingAll._(child: child, padding: $.paddings.xxs, key: key); + + /// Factory constructor that creates a [PaddingAll] widget with extra-extra-extra-small (xxxs) padding. + factory PaddingAll.xxxs({required Widget child, Key? key}) => + PaddingAll._(child: child, padding: $.paddings.xxxs, key: key); + + /// Builds the [Padding] widget with the specified [padding] applied + /// equally to all sides of the [child] widget. + @override + Widget build(BuildContext context) { + return Padding(padding: EdgeInsets.all(padding), child: child); + } + + /// The child widget to which the padding is applied. + final Widget child; + + /// The amount of padding to apply on all sides. + final double padding; +} diff --git a/infrastructure/lib/presentation/widgets/_core/paddings/padding_bottom.dart b/infrastructure/lib/presentation/widgets/_core/paddings/padding_bottom.dart new file mode 100644 index 0000000..057417b --- /dev/null +++ b/infrastructure/lib/presentation/widgets/_core/paddings/padding_bottom.dart @@ -0,0 +1,71 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter/material.dart'; +import 'package:infrastructure/presentation/super_class.dart'; + +/// [PaddingBottom] is a convenience widget that applies padding only to the +/// bottom of its child. The padding sizes are predefined based on a +/// consistent design system that is accessible through the global `$.paddings` +/// object. This class offers a variety of factory constructors for common +/// padding sizes. +/// +/// This ensures consistency in vertical spacing across the UI where padding +/// is applied only to the bottom of widgets. +class PaddingBottom extends StatelessWidget { + /// Internal constructor for [PaddingBottom] that takes in a [child] and + /// a specific [padding] value for the bottom. + const PaddingBottom._( + {required this.child, required this.padding, super.key}); + + /// Factory constructor for applying large (lg) padding to the bottom. + factory PaddingBottom.lg({required Widget child, Key? key}) => + PaddingBottom._(child: child, padding: $.paddings.lg, key: key); + + /// Factory constructor for applying medium (md) padding to the bottom. + factory PaddingBottom.md({required Widget child, Key? key}) => + PaddingBottom._(child: child, padding: $.paddings.md, key: key); + + /// Factory constructor for applying small (sm) padding to the bottom. + factory PaddingBottom.sm({required Widget child, Key? key}) => + PaddingBottom._(child: child, padding: $.paddings.sm, key: key); + + /// Factory constructor for applying extra-large (xl) padding to the bottom. + factory PaddingBottom.xl({required Widget child, Key? key}) => + PaddingBottom._(child: child, padding: $.paddings.xl, key: key); + + /// Factory constructor for applying extra-small (xs) padding to the bottom. + factory PaddingBottom.xs({required Widget child, Key? key}) => + PaddingBottom._(child: child, padding: $.paddings.xs, key: key); + + /// Factory constructor for applying extra-extra-large (xxl) padding to the bottom. + factory PaddingBottom.xxl({required Widget child, Key? key}) => + PaddingBottom._(child: child, padding: $.paddings.xxl, key: key); + + /// Factory constructor for applying extra-extra-small (xxs) padding to the bottom. + factory PaddingBottom.xxs({required Widget child, Key? key}) => + PaddingBottom._(child: child, padding: $.paddings.xxs, key: key); + + /// Factory constructor for applying extra-extra-extra-small (xxxs) padding to the bottom. + factory PaddingBottom.xxxs({required Widget child, Key? key}) => + PaddingBottom._(child: child, padding: $.paddings.xxxs, key: key); + + /// Builds the [Padding] widget with the specified [padding] applied + /// to the bottom of the [child] widget. + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(bottom: padding), + child: child, + ); + } + + /// The child widget to which the bottom padding is applied. + final Widget child; + + /// The amount of padding to apply to the bottom side of the widget. + final double padding; +} diff --git a/infrastructure/lib/presentation/widgets/_core/paddings/padding_gap.dart b/infrastructure/lib/presentation/widgets/_core/paddings/padding_gap.dart new file mode 100644 index 0000000..fd5a814 --- /dev/null +++ b/infrastructure/lib/presentation/widgets/_core/paddings/padding_gap.dart @@ -0,0 +1,164 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: prefer-single-widget-per-file, prefer-single-declaration-per-file + +import 'package:flutter/material.dart'; +import 'package:infrastructure/presentation/super_class.dart'; +import 'package:infrastructure/presentation/widgets/_core/gap/gap.dart'; +import 'package:infrastructure/presentation/widgets/_core/gap/sliver_gap.dart'; + +/// [PaddingGap] is a specialized version of [Gap] that adds a flexible +/// gap with predefined padding values from the app's design system. +/// This widget helps maintain consistent spacing throughout the UI. +/// +/// It supports various padding sizes (e.g., xs, sm, md) and allows +/// customization of the gap's color and cross-axis extent. +class PaddingGap extends Gap { + /// Creates a [PaddingGap] with a specified main axis extent, optional + /// color, and cross-axis extent. + const PaddingGap(super.mainAxisExtent, + {super.color, super.crossAxisExtent, super.key}); + + /// Factory constructor for expanding the gap fully in the cross axis, with an optional color. + factory PaddingGap.expand({Color? color, Key? key}) => PaddingGap( + $.paddings.xxs, + color: color, + crossAxisExtent: double.infinity, + key: key, + ); + + /// Factory constructor for a large gap. + factory PaddingGap.lg({Color? color, double? crossAxisExtent, Key? key}) => + PaddingGap($.paddings.lg, + color: color, crossAxisExtent: crossAxisExtent, key: key); + + /// Factory constructor for a medium gap. + factory PaddingGap.md({Color? color, double? crossAxisExtent, Key? key}) => + PaddingGap($.paddings.md, + color: color, crossAxisExtent: crossAxisExtent, key: key); + + /// Factory constructor for a small gap. + factory PaddingGap.sm({Color? color, double? crossAxisExtent, Key? key}) => + PaddingGap($.paddings.sm, + color: color, crossAxisExtent: crossAxisExtent, key: key); + + /// Factory constructor for an extra-large gap. + factory PaddingGap.xl({Color? color, double? crossAxisExtent, Key? key}) => + PaddingGap($.paddings.xl, + color: color, crossAxisExtent: crossAxisExtent, key: key); + + /// Factory constructor for an extra-small gap. + factory PaddingGap.xs({Color? color, double? crossAxisExtent, Key? key}) => + PaddingGap($.paddings.xs, + color: color, crossAxisExtent: crossAxisExtent, key: key); + + /// Factory constructor for an extra-extra-large gap. + factory PaddingGap.xxl({Color? color, double? crossAxisExtent, Key? key}) => + PaddingGap($.paddings.xxl, + color: color, crossAxisExtent: crossAxisExtent, key: key); + + /// Factory constructor for an extra-extra-small gap. + factory PaddingGap.xxs({Color? color, double? crossAxisExtent, Key? key}) => + PaddingGap($.paddings.xxs, + color: color, crossAxisExtent: crossAxisExtent, key: key); + + /// Factory constructor for an extra-extra-extra-small gap. + factory PaddingGap.xxxs({Color? color, double? crossAxisExtent, Key? key}) => + PaddingGap($.paddings.xxxs, + color: color, crossAxisExtent: crossAxisExtent, key: key); +} + +/// [PaddingMaxGap] is a version of [MaxGap] that adds flexible gaps with +/// predefined padding values from the app's design system. It ensures +/// consistent spacing across the UI and supports various padding sizes. +class PaddingMaxGap extends MaxGap { + /// Creates a [PaddingMaxGap] with a specified main axis extent, optional color, + /// and cross-axis extent. + const PaddingMaxGap(super.mainAxisExtent, + {super.color, super.crossAxisExtent, super.key}); + + /// Factory constructor for expanding the max gap fully in the cross axis, with an optional color. + factory PaddingMaxGap.expand({Color? color, Key? key}) => PaddingMaxGap( + $.paddings.xxs, + color: color, + crossAxisExtent: double.infinity, + key: key, + ); + + /// Factory constructor for a large max gap. + factory PaddingMaxGap.lg({Color? color, double? crossAxisExtent, Key? key}) => + PaddingMaxGap($.paddings.lg, + color: color, crossAxisExtent: crossAxisExtent, key: key); + + /// Factory constructor for a medium max gap. + factory PaddingMaxGap.md({Color? color, double? crossAxisExtent, Key? key}) => + PaddingMaxGap($.paddings.md, + color: color, crossAxisExtent: crossAxisExtent, key: key); + + /// Factory constructor for a small max gap. + factory PaddingMaxGap.sm({Color? color, double? crossAxisExtent, Key? key}) => + PaddingMaxGap($.paddings.sm, + color: color, crossAxisExtent: crossAxisExtent, key: key); + + /// Factory constructor for an extra-large max gap. + factory PaddingMaxGap.xl({Color? color, double? crossAxisExtent, Key? key}) => + PaddingMaxGap($.paddings.xl, + color: color, crossAxisExtent: crossAxisExtent, key: key); + + /// Factory constructor for an extra-small max gap. + factory PaddingMaxGap.xs({Color? color, double? crossAxisExtent, Key? key}) => + PaddingMaxGap($.paddings.xs, + color: color, crossAxisExtent: crossAxisExtent, key: key); + + /// Factory constructor for an extra-extra-large max gap. + factory PaddingMaxGap.xxl( + {Color? color, double? crossAxisExtent, Key? key}) => + PaddingMaxGap($.paddings.xxl, + color: color, crossAxisExtent: crossAxisExtent, key: key); + + /// Factory constructor for an extra-extra-small max gap. + factory PaddingMaxGap.xxs( + {Color? color, double? crossAxisExtent, Key? key}) => + PaddingMaxGap($.paddings.xxs, + color: color, crossAxisExtent: crossAxisExtent, key: key); +} + +/// [PaddingSliverGap] extends [SliverGap] to support predefined padding sizes, +/// ensuring consistent spacing within slivers, such as in scrolling contexts. +/// It also supports custom colors for the gap. +class PaddingSliverGap extends SliverGap { + /// Creates a [PaddingSliverGap] with a specified main axis extent and an optional color. + const PaddingSliverGap(super.mainAxisExtent, {super.color, super.key}); + + /// Factory constructor for a large sliver gap. + factory PaddingSliverGap.lg({Color? color, Key? key}) => + PaddingSliverGap($.paddings.lg, color: color, key: key); + + /// Factory constructor for a medium sliver gap. + factory PaddingSliverGap.md({Color? color, Key? key}) => + PaddingSliverGap($.paddings.md, color: color, key: key); + + /// Factory constructor for a small sliver gap. + factory PaddingSliverGap.sm({Color? color, Key? key}) => + PaddingSliverGap($.paddings.sm, color: color, key: key); + + /// Factory constructor for an extra-large sliver gap. + factory PaddingSliverGap.xl({Color? color, Key? key}) => + PaddingSliverGap($.paddings.xl, color: color, key: key); + + /// Factory constructor for an extra-small sliver gap. + factory PaddingSliverGap.xs({Color? color, Key? key}) => + PaddingSliverGap($.paddings.xs, color: color, key: key); + + /// Factory constructor for an extra-extra-large sliver gap. + factory PaddingSliverGap.xxl({Color? color, Key? key}) => + PaddingSliverGap($.paddings.xxl, color: color, key: key); + + /// Factory constructor for an extra-extra-small sliver gap. + factory PaddingSliverGap.xxs({Color? color, Key? key}) => + PaddingSliverGap($.paddings.xxs, color: color, key: key); +} diff --git a/infrastructure/lib/presentation/widgets/_core/paddings/padding_left.dart b/infrastructure/lib/presentation/widgets/_core/paddings/padding_left.dart new file mode 100644 index 0000000..4c425e3 --- /dev/null +++ b/infrastructure/lib/presentation/widgets/_core/paddings/padding_left.dart @@ -0,0 +1,62 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter/material.dart'; +import 'package:infrastructure/presentation/super_class.dart'; + +/// [PaddingLeft] is a widget that applies padding to the left side +/// of its child based on predefined padding values from the design system. +/// +/// This widget helps maintain consistent spacing on the left side of +/// widgets, allowing for quick usage of standard padding values such as `xs`, `md`, `lg`, etc. +class PaddingLeft extends StatelessWidget { + /// Constructs a [PaddingLeft] widget with a specified padding value. + const PaddingLeft._({required this.child, required this.padding, super.key}); + + /// Factory constructor for creating a left padding of large size. + factory PaddingLeft.lg({required Widget child, Key? key}) => + PaddingLeft._(child: child, padding: $.paddings.lg, key: key); + + /// Factory constructor for creating a left padding of medium size. + factory PaddingLeft.md({required Widget child, Key? key}) => + PaddingLeft._(child: child, padding: $.paddings.md, key: key); + + /// Factory constructor for creating a left padding of small size. + factory PaddingLeft.sm({required Widget child, Key? key}) => + PaddingLeft._(child: child, padding: $.paddings.sm, key: key); + + /// Factory constructor for creating a left padding of extra-large size. + factory PaddingLeft.xl({required Widget child, Key? key}) => + PaddingLeft._(child: child, padding: $.paddings.xl, key: key); + + /// Factory constructor for creating a left padding of extra-small size. + factory PaddingLeft.xs({required Widget child, Key? key}) => + PaddingLeft._(child: child, padding: $.paddings.xs, key: key); + + /// Factory constructor for creating a left padding of extra-extra-large size. + factory PaddingLeft.xxl({required Widget child, Key? key}) => + PaddingLeft._(child: child, padding: $.paddings.xxl, key: key); + + /// Factory constructor for creating a left padding of extra-extra-small size. + factory PaddingLeft.xxs({required Widget child, Key? key}) => + PaddingLeft._(child: child, padding: $.paddings.xxs, key: key); + + /// Factory constructor for creating a left padding of extra-extra-extra-small size. + factory PaddingLeft.xxxs({required Widget child, Key? key}) => + PaddingLeft._(child: child, padding: $.paddings.xxxs, key: key); + + /// Builds the [PaddingLeft] widget with the specified left padding applied. + @override + Widget build(BuildContext context) { + return Padding(padding: EdgeInsets.only(left: padding), child: child); + } + + /// The widget that will have the left padding applied to it. + final Widget child; + + /// The amount of padding to be applied on the left side, coming from the design system. + final double padding; +} diff --git a/infrastructure/lib/presentation/widgets/_core/paddings/padding_only.dart b/infrastructure/lib/presentation/widgets/_core/paddings/padding_only.dart new file mode 100644 index 0000000..1ae24b4 --- /dev/null +++ b/infrastructure/lib/presentation/widgets/_core/paddings/padding_only.dart @@ -0,0 +1,210 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: prefer-boolean-prefixes + +import 'package:flutter/material.dart'; +import 'package:infrastructure/presentation/super_class.dart'; + +/// [PaddingOnly] is a flexible widget that applies padding to specific edges +/// of its child widget based on provided boolean flags for each edge (left, right, top, bottom). +/// The padding value is derived from predefined padding sizes from the design system. +class PaddingOnly extends StatelessWidget { + /// Constructs a [PaddingOnly] widget with custom padding for each side (left, right, top, bottom). + const PaddingOnly._({ + required this.bottom, + required this.child, + required this.left, + required this.padding, + required this.right, + required this.top, + super.key, + }); + + /// Factory constructor for creating a large padding. + factory PaddingOnly.lg({ + required Widget child, + Key? key, + bool bottom = false, + bool left = false, + bool right = false, + bool top = false, + }) => + PaddingOnly._( + bottom: bottom, + child: child, + left: left, + padding: $.paddings.lg, + right: right, + top: top, + key: key, + ); + + /// Factory constructor for creating a medium padding. + factory PaddingOnly.md({ + required Widget child, + Key? key, + bool bottom = false, + bool left = false, + bool right = false, + bool top = false, + }) => + PaddingOnly._( + bottom: bottom, + child: child, + left: left, + padding: $.paddings.md, + right: right, + top: top, + key: key, + ); + + /// Factory constructor for creating a small padding. + factory PaddingOnly.sm({ + required Widget child, + Key? key, + bool bottom = false, + bool left = false, + bool right = false, + bool top = false, + }) => + PaddingOnly._( + bottom: bottom, + child: child, + left: left, + padding: $.paddings.sm, + right: right, + top: top, + key: key, + ); + + /// Factory constructor for creating an extra-large padding. + factory PaddingOnly.xl({ + required Widget child, + Key? key, + bool bottom = false, + bool left = false, + bool right = false, + bool top = false, + }) => + PaddingOnly._( + bottom: bottom, + child: child, + left: left, + padding: $.paddings.xl, + right: right, + top: top, + key: key, + ); + + /// Factory constructor for creating an extra-small padding. + factory PaddingOnly.xs({ + required Widget child, + Key? key, + bool bottom = false, + bool left = false, + bool right = false, + bool top = false, + }) => + PaddingOnly._( + bottom: bottom, + child: child, + left: left, + padding: $.paddings.xs, + right: right, + top: top, + key: key, + ); + + /// Factory constructor for creating an extra-extra-small padding. + factory PaddingOnly.xxxs({ + required Widget child, + Key? key, + bool bottom = false, + bool left = false, + bool right = false, + bool top = false, + }) => + PaddingOnly._( + bottom: bottom, + child: child, + left: left, + padding: $.paddings.xxxs, + right: right, + top: top, + key: key, + ); + + /// Factory constructor for creating an extra-extra-large padding. + factory PaddingOnly.xxl({ + required Widget child, + Key? key, + bool bottom = false, + bool left = false, + bool right = false, + bool top = false, + }) => + PaddingOnly._( + bottom: bottom, + child: child, + left: left, + padding: $.paddings.xxl, + right: right, + top: top, + key: key, + ); + + /// Factory constructor for creating an extra-extra-small padding. + factory PaddingOnly.xxs({ + required Widget child, + Key? key, + bool bottom = false, + bool left = false, + bool right = false, + bool top = false, + }) => + PaddingOnly._( + bottom: bottom, + child: child, + left: left, + padding: $.paddings.xxs, + right: right, + top: top, + key: key, + ); + + /// Builds the [PaddingOnly] widget by applying padding to the specified edges of its child. + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.fromLTRB( + left ? padding : 0, + top ? padding : 0, + right ? padding : 0, + bottom ? padding : 0, + ), + child: child, + ); + } + + /// A boolean that determines whether padding is applied to the bottom edge. + final bool bottom; + + /// The widget to which padding is applied. + final Widget child; + + /// A boolean that determines whether padding is applied to the left edge. + final bool left; + + /// The amount of padding to be applied, which is fetched from the predefined design system padding values. + final double padding; + + /// A boolean that determines whether padding is applied to the right edge. + final bool right; + + /// A boolean that determines whether padding is applied to the top edge. + final bool top; +} diff --git a/infrastructure/lib/presentation/widgets/_core/paddings/padding_right.dart b/infrastructure/lib/presentation/widgets/_core/paddings/padding_right.dart new file mode 100644 index 0000000..4699ea7 --- /dev/null +++ b/infrastructure/lib/presentation/widgets/_core/paddings/padding_right.dart @@ -0,0 +1,59 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter/material.dart'; +import 'package:infrastructure/presentation/super_class.dart'; + +/// [PaddingRight] is a widget that adds padding to the right side of its child. +/// This widget provides predefined padding sizes from the design system, making it easier to ensure consistent spacing. +class PaddingRight extends StatelessWidget { + /// Constructs a [PaddingRight] widget with custom padding for the right side. + const PaddingRight._({required this.child, required this.padding, super.key}); + + /// Factory constructor for creating a large right padding. + factory PaddingRight.lg({required Widget child, Key? key}) => + PaddingRight._(child: child, padding: $.paddings.lg, key: key); + + /// Factory constructor for creating a medium right padding. + factory PaddingRight.md({required Widget child, Key? key}) => + PaddingRight._(child: child, padding: $.paddings.md, key: key); + + /// Factory constructor for creating a small right padding. + factory PaddingRight.sm({required Widget child, Key? key}) => + PaddingRight._(child: child, padding: $.paddings.sm, key: key); + + /// Factory constructor for creating an extra-large right padding. + factory PaddingRight.xl({required Widget child, Key? key}) => + PaddingRight._(child: child, padding: $.paddings.xl, key: key); + + /// Factory constructor for creating an extra-small right padding. + factory PaddingRight.xs({required Widget child, Key? key}) => + PaddingRight._(child: child, padding: $.paddings.xs, key: key); + + /// Factory constructor for creating an extra-extra-large right padding. + factory PaddingRight.xxl({required Widget child, Key? key}) => + PaddingRight._(child: child, padding: $.paddings.xxl, key: key); + + /// Factory constructor for creating an extra-extra-small right padding. + factory PaddingRight.xxs({required Widget child, Key? key}) => + PaddingRight._(child: child, padding: $.paddings.xxs, key: key); + + /// Factory constructor for creating an extra-extra-extra-small right padding. + factory PaddingRight.xxxs({required Widget child, Key? key}) => + PaddingRight._(child: child, padding: $.paddings.xxxs, key: key); + + /// Builds the [PaddingRight] widget by applying right padding to its child. + @override + Widget build(BuildContext context) { + return Padding(padding: EdgeInsets.only(right: padding), child: child); + } + + /// The widget to which the padding is applied. + final Widget child; + + /// The amount of padding to be applied to the right side, fetched from predefined values. + final double padding; +} diff --git a/infrastructure/lib/presentation/widgets/_core/paddings/padding_symmetric.dart b/infrastructure/lib/presentation/widgets/_core/paddings/padding_symmetric.dart new file mode 100644 index 0000000..323a3b6 --- /dev/null +++ b/infrastructure/lib/presentation/widgets/_core/paddings/padding_symmetric.dart @@ -0,0 +1,167 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: prefer-boolean-prefixes + +import 'package:flutter/material.dart'; +import 'package:infrastructure/presentation/super_class.dart'; + +/// [PaddingSymmetric] applies symmetric padding (both vertical and/or horizontal) to its child. +/// This widget simplifies adding uniform spacing to the child widget, based on pre-configured design system paddings. +class PaddingSymmetric extends StatelessWidget { + /// Constructs a [PaddingSymmetric] widget with custom symmetric padding. + const PaddingSymmetric._({ + required this.child, + required this.horizontal, + required this.padding, + required this.vertical, + super.key, + }); + + /// Factory constructor for adding large symmetric padding (lg) around the child. + factory PaddingSymmetric.lg({ + required Widget child, + Key? key, + bool horizontal = false, + bool vertical = false, + }) => + PaddingSymmetric._( + child: child, + horizontal: horizontal, + padding: $.paddings.lg, + vertical: vertical, + key: key, + ); + + /// Factory constructor for adding medium symmetric padding (md) around the child. + factory PaddingSymmetric.md({ + required Widget child, + Key? key, + bool horizontal = false, + bool vertical = false, + }) => + PaddingSymmetric._( + child: child, + horizontal: horizontal, + padding: $.paddings.md, + vertical: vertical, + key: key, + ); + + /// Factory constructor for adding small symmetric padding (sm) around the child. + factory PaddingSymmetric.sm({ + required Widget child, + Key? key, + bool horizontal = false, + bool vertical = false, + }) => + PaddingSymmetric._( + child: child, + horizontal: horizontal, + padding: $.paddings.sm, + vertical: vertical, + key: key, + ); + + /// Factory constructor for adding extra-large symmetric padding (xl) around the child. + factory PaddingSymmetric.xl({ + required Widget child, + Key? key, + bool horizontal = false, + bool vertical = false, + }) => + PaddingSymmetric._( + child: child, + horizontal: horizontal, + padding: $.paddings.xl, + vertical: vertical, + key: key, + ); + + /// Factory constructor for adding extra-small symmetric padding (xs) around the child. + factory PaddingSymmetric.xs({ + required Widget child, + Key? key, + bool horizontal = false, + bool vertical = false, + }) => + PaddingSymmetric._( + child: child, + horizontal: horizontal, + padding: $.paddings.xs, + vertical: vertical, + key: key, + ); + + /// Factory constructor for adding extra-extra-large symmetric padding (xxl) around the child. + factory PaddingSymmetric.xxl({ + required Widget child, + Key? key, + bool horizontal = false, + bool vertical = false, + }) => + PaddingSymmetric._( + child: child, + horizontal: horizontal, + padding: $.paddings.xxl, + vertical: vertical, + key: key, + ); + + /// Factory constructor for adding extra-extra-small symmetric padding (xxs) around the child. + factory PaddingSymmetric.xxs({ + required Widget child, + Key? key, + bool horizontal = false, + bool vertical = false, + }) => + PaddingSymmetric._( + child: child, + horizontal: horizontal, + padding: $.paddings.xxs, + vertical: vertical, + key: key, + ); + + /// Factory constructor for adding extra-extra-extra-small symmetric padding (xxxs) around the child. + factory PaddingSymmetric.xxxs({ + required Widget child, + Key? key, + bool horizontal = false, + bool vertical = false, + }) => + PaddingSymmetric._( + child: child, + horizontal: horizontal, + padding: $.paddings.xxxs, + vertical: vertical, + key: key, + ); + + /// Builds the [PaddingSymmetric] widget by applying symmetric padding to the child widget. + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric( + vertical: vertical ? padding : 0, + horizontal: horizontal ? padding : 0, + ), + child: child, + ); + } + + /// The widget to which symmetric padding is applied. + final Widget child; + + /// Flag to indicate if padding should be applied horizontally. + final bool horizontal; + + /// The padding value fetched from the design system's predefined padding sizes. + final double padding; + + /// Flag to indicate if padding should be applied vertically. + final bool vertical; +} diff --git a/infrastructure/lib/presentation/widgets/_core/paddings/padding_top.dart b/infrastructure/lib/presentation/widgets/_core/paddings/padding_top.dart new file mode 100644 index 0000000..0357b09 --- /dev/null +++ b/infrastructure/lib/presentation/widgets/_core/paddings/padding_top.dart @@ -0,0 +1,59 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter/material.dart'; +import 'package:infrastructure/presentation/super_class.dart'; + +/// [PaddingTop] applies padding to the top of its child widget, using predefined padding sizes. +/// This widget simplifies applying consistent top spacing across various parts of the app. +class PaddingTop extends StatelessWidget { + /// Constructs a [PaddingTop] widget with custom top padding. + const PaddingTop._({required this.child, required this.padding, super.key}); + + /// Factory constructor for applying large padding (lg) to the top of the child. + factory PaddingTop.lg({required Widget child, Key? key}) => + PaddingTop._(child: child, padding: $.paddings.lg, key: key); + + /// Factory constructor for applying medium padding (md) to the top of the child. + factory PaddingTop.md({required Widget child, Key? key}) => + PaddingTop._(child: child, padding: $.paddings.md, key: key); + + /// Factory constructor for applying small padding (sm) to the top of the child. + factory PaddingTop.sm({required Widget child, Key? key}) => + PaddingTop._(child: child, padding: $.paddings.sm, key: key); + + /// Factory constructor for applying extra-large padding (xl) to the top of the child. + factory PaddingTop.xl({required Widget child, Key? key}) => + PaddingTop._(child: child, padding: $.paddings.xl, key: key); + + /// Factory constructor for applying extra-small padding (xs) to the top of the child. + factory PaddingTop.xs({required Widget child, Key? key}) => + PaddingTop._(child: child, padding: $.paddings.xs, key: key); + + /// Factory constructor for applying extra-extra-large padding (xxl) to the top of the child. + factory PaddingTop.xxl({required Widget child, Key? key}) => + PaddingTop._(child: child, padding: $.paddings.xxl, key: key); + + /// Factory constructor for applying extra-extra-small padding (xxs) to the top of the child. + factory PaddingTop.xxs({required Widget child, Key? key}) => + PaddingTop._(child: child, padding: $.paddings.xxs, key: key); + + /// Factory constructor for applying extra-extra-extra-small padding (xxxs) to the top of the child. + factory PaddingTop.xxxs({required Widget child, Key? key}) => + PaddingTop._(child: child, padding: $.paddings.xxxs, key: key); + + /// Builds the [PaddingTop] widget by applying top padding to its child. + @override + Widget build(BuildContext context) { + return Padding(padding: EdgeInsets.only(top: padding), child: child); + } + + /// The widget to which top padding is applied. + final Widget child; + + /// The amount of padding to be applied to the top, fetched from predefined design system values. + final double padding; +} diff --git a/infrastructure/lib/presentation/widgets/_core/platform/platform_io.dart b/infrastructure/lib/presentation/widgets/_core/platform/platform_io.dart new file mode 100644 index 0000000..2eedac9 --- /dev/null +++ b/infrastructure/lib/presentation/widgets/_core/platform/platform_io.dart @@ -0,0 +1,37 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'dart:io'; + +/// Platform detection for native environments using `dart:io`. +/// This class provides static methods to determine the platform +/// the app is running on (macOS, Windows, Linux, Android, iOS, or Fuchsia). +abstract final class PlatformIo { + /// Always returns `false` as it's not a web platform. + static bool get isWeb => false; + + /// Returns `true` if running on macOS. + static bool get isMacOS => Platform.isMacOS; + + /// Returns `true` if running on Windows. + static bool get isWindows => Platform.isWindows; + + /// Returns `true` if running on Linux. + static bool get isLinux => Platform.isLinux; + + /// Returns `true` if running on Android. + static bool get isAndroid => Platform.isAndroid; + + /// Returns `true` if running on iOS. + static bool get isIOS => Platform.isIOS; + + /// Returns `true` if running on Fuchsia. + static bool get isFuchsia => Platform.isFuchsia; + + /// Returns `true` if running on a desktop platform (macOS, Windows, or Linux). + static bool get isDesktop => + Platform.isMacOS || Platform.isWindows || Platform.isLinux; +} diff --git a/infrastructure/lib/presentation/widgets/_core/platform/platform_io_web.dart b/infrastructure/lib/presentation/widgets/_core/platform/platform_io_web.dart new file mode 100644 index 0000000..72914a5 --- /dev/null +++ b/infrastructure/lib/presentation/widgets/_core/platform/platform_io_web.dart @@ -0,0 +1,52 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: avoid_web_libraries_in_flutter + +import 'package:deps/packages/universal_html.dart' as html; + +/// Provides platform detection for web environments using `dart:html`. +/// This class checks the browser's user agent to infer the platform (macOS, Windows, Linux, Android, or iOS). +abstract final class PlatformIo { + /// Always returns `true` as it's a web platform. + static bool get isWeb => true; + + /// Returns `true` if running on macOS. + static bool get isMacOS => + _navigator.appVersion.contains('Mac OS') && !PlatformIo.isIOS; + + /// Returns `true` if running on Windows. + static bool get isWindows => _navigator.appVersion.contains('Win'); + + /// Returns `true` if running on Linux. + static bool get isLinux => + (_navigator.appVersion.contains('Linux') || + _navigator.appVersion.contains('x11')) && + !isAndroid; + + /// Returns `true` if running on Android. + static bool get isAndroid => _navigator.appVersion.contains('Android '); + + /// Returns `true` if running on iOS. + static bool get isIOS { + return hasMatch('/iPad|iPhone|iPod/', _navigator.platform) || + (_navigator.platform == 'MacIntel' && _navigator.maxTouchPoints! > 1); + } + + /// Always returns `false` as Fuchsia isn't supported on the web. + static bool get isFuchsia => false; + + /// Returns `true` if running on a desktop platform (macOS, Windows, or Linux). + static bool get isDesktop => isMacOS || isWindows || isLinux; + + /// Helper method to check if a string contains a match for a given pattern. + static bool hasMatch(String pattern, String? value) { + return value != null && RegExp(pattern).hasMatch(value); + } + + /// Stores the web navigator object to access platform and app version details. + static final html.Navigator _navigator = html.window.navigator; +} diff --git a/infrastructure/lib/presentation/widgets/_core/radiuses/radius_all.dart b/infrastructure/lib/presentation/widgets/_core/radiuses/radius_all.dart new file mode 100644 index 0000000..f2e0b3f --- /dev/null +++ b/infrastructure/lib/presentation/widgets/_core/radiuses/radius_all.dart @@ -0,0 +1,53 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter/material.dart'; +import 'package:infrastructure/presentation/super_class.dart'; + +/// A widget that applies uniform border-radius to all corners of a child widget. +class RadiusAll extends StatelessWidget { + const RadiusAll._({required this.child, required this.radius, super.key}); + + /// Creates a widget with large uniform border-radius. + factory RadiusAll.lg({required Widget child, Key? key}) => + RadiusAll._(child: child, radius: $.radiuses.lg, key: key); + + /// Creates a widget with medium uniform border-radius. + factory RadiusAll.md({required Widget child, Key? key}) => + RadiusAll._(child: child, radius: $.radiuses.md, key: key); + + /// Creates a widget with no border-radius (radius set to 0). + factory RadiusAll.none({required Widget child, Key? key}) => + RadiusAll._(child: child, radius: $.radiuses.none, key: key); + + /// Creates a widget with small uniform border-radius. + factory RadiusAll.sm({required Widget child, Key? key}) => + RadiusAll._(child: child, radius: $.radiuses.sm, key: key); + + /// Creates a widget with extra-large uniform border-radius. + factory RadiusAll.xl({required Widget child, Key? key}) => + RadiusAll._(child: child, radius: $.radiuses.xl, key: key); + + /// Creates a widget with extra-small uniform border-radius. + factory RadiusAll.xs({required Widget child, Key? key}) => + RadiusAll._(child: child, radius: $.radiuses.xs, key: key); + + /// Creates a widget with extra-extra-large uniform border-radius. + factory RadiusAll.xxl({required Widget child, Key? key}) => + RadiusAll._(child: child, radius: $.radiuses.xxl, key: key); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(radius))), + child: child, + ); + } + + final Widget child; + final double radius; +} diff --git a/infrastructure/lib/presentation/widgets/_core/radiuses/radius_horizontal.dart b/infrastructure/lib/presentation/widgets/_core/radiuses/radius_horizontal.dart new file mode 100644 index 0000000..f0b9376 --- /dev/null +++ b/infrastructure/lib/presentation/widgets/_core/radiuses/radius_horizontal.dart @@ -0,0 +1,63 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: no-equal-arguments + +import 'package:flutter/material.dart'; +import 'package:infrastructure/presentation/super_class.dart'; + +/// A widget that applies horizontal (left and right) border-radius to a child widget. +class RadiusHorizontal extends StatelessWidget { + const RadiusHorizontal._({ + required this.child, + required this.radius, + super.key, + }); + + /// Creates a widget with large horizontal border-radius. + factory RadiusHorizontal.lg({required Widget child, Key? key}) => + RadiusHorizontal._(child: child, radius: $.radiuses.lg, key: key); + + /// Creates a widget with medium horizontal border-radius. + factory RadiusHorizontal.md({required Widget child, Key? key}) => + RadiusHorizontal._(child: child, radius: $.radiuses.md, key: key); + + /// Creates a widget with no horizontal border-radius (radius set to 0). + factory RadiusHorizontal.none({required Widget child, Key? key}) => + RadiusHorizontal._(child: child, radius: $.radiuses.none, key: key); + + /// Creates a widget with small horizontal border-radius. + factory RadiusHorizontal.sm({required Widget child, Key? key}) => + RadiusHorizontal._(child: child, radius: $.radiuses.sm, key: key); + + /// Creates a widget with extra-large horizontal border-radius. + factory RadiusHorizontal.xl({required Widget child, Key? key}) => + RadiusHorizontal._(child: child, radius: $.radiuses.xl, key: key); + + /// Creates a widget with extra-small horizontal border-radius. + factory RadiusHorizontal.xs({required Widget child, Key? key}) => + RadiusHorizontal._(child: child, radius: $.radiuses.xs, key: key); + + /// Creates a widget with extra-extra-large horizontal border-radius. + factory RadiusHorizontal.xxl({required Widget child, Key? key}) => + RadiusHorizontal._(child: child, radius: $.radiuses.xxl, key: key); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.horizontal( + left: Radius.circular(radius), + right: Radius.circular(radius), + ), + ), + child: child, + ); + } + + final Widget child; + final double radius; +} diff --git a/infrastructure/lib/presentation/widgets/_core/radiuses/radius_only.dart b/infrastructure/lib/presentation/widgets/_core/radiuses/radius_only.dart new file mode 100644 index 0000000..dd8ba17 --- /dev/null +++ b/infrastructure/lib/presentation/widgets/_core/radiuses/radius_only.dart @@ -0,0 +1,178 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: prefer-boolean-prefixes + +import 'package:flutter/material.dart'; +import 'package:infrastructure/presentation/super_class.dart'; + +/// A widget that applies custom radius to specific corners of a child widget. +class RadiusOnly extends StatelessWidget { + const RadiusOnly._({ + required this.bottomLeft, + required this.bottomRight, + required this.child, + required this.radius, + required this.topLeft, + required this.topRight, + super.key, + }); + + /// Creates a widget with large radius for specified corners. + factory RadiusOnly.lg({ + required Widget child, + Key? key, + bool bottomLeft = false, + bool bottomRight = false, + bool topLeft = false, + bool topRight = false, + }) => + RadiusOnly._( + bottomLeft: bottomLeft, + bottomRight: bottomRight, + child: child, + radius: $.radiuses.lg, + topLeft: topLeft, + topRight: topRight, + key: key, + ); + + /// Creates a widget with medium radius for specified corners. + factory RadiusOnly.md({ + required Widget child, + Key? key, + bool bottomLeft = false, + bool bottomRight = false, + bool topLeft = false, + bool topRight = false, + }) => + RadiusOnly._( + bottomLeft: bottomLeft, + bottomRight: bottomRight, + child: child, + radius: $.radiuses.md, + topLeft: topLeft, + topRight: topRight, + key: key, + ); + + /// Creates a widget with no radius for specified corners. + factory RadiusOnly.none({ + required Widget child, + Key? key, + bool bottomLeft = false, + bool bottomRight = false, + bool topLeft = false, + bool topRight = false, + }) => + RadiusOnly._( + bottomLeft: bottomLeft, + bottomRight: bottomRight, + child: child, + radius: $.radiuses.none, + topLeft: topLeft, + topRight: topRight, + key: key, + ); + + /// Creates a widget with small radius for specified corners. + factory RadiusOnly.sm({ + required Widget child, + Key? key, + bool bottomLeft = false, + bool bottomRight = false, + bool topLeft = false, + bool topRight = false, + }) => + RadiusOnly._( + bottomLeft: bottomLeft, + bottomRight: bottomRight, + child: child, + radius: $.radiuses.sm, + topLeft: topLeft, + topRight: topRight, + key: key, + ); + + /// Creates a widget with extra-large radius for specified corners. + factory RadiusOnly.xl({ + required Widget child, + Key? key, + bool bottomLeft = false, + bool bottomRight = false, + bool topLeft = false, + bool topRight = false, + }) => + RadiusOnly._( + bottomLeft: bottomLeft, + bottomRight: bottomRight, + child: child, + radius: $.radiuses.xl, + topLeft: topLeft, + topRight: topRight, + key: key, + ); + + /// Creates a widget with extra-small radius for specified corners. + factory RadiusOnly.xs({ + required Widget child, + Key? key, + bool bottomLeft = false, + bool bottomRight = false, + bool topLeft = false, + bool topRight = false, + }) => + RadiusOnly._( + bottomLeft: bottomLeft, + bottomRight: bottomRight, + child: child, + radius: $.radiuses.xs, + topLeft: topLeft, + topRight: topRight, + key: key, + ); + + /// Creates a widget with extra-extra-large radius for specified corners. + factory RadiusOnly.xxl({ + required Widget child, + Key? key, + bool bottomLeft = false, + bool bottomRight = false, + bool topLeft = false, + bool topRight = false, + }) => + RadiusOnly._( + bottomLeft: bottomLeft, + bottomRight: bottomRight, + child: child, + radius: $.radiuses.xxl, + topLeft: topLeft, + topRight: topRight, + key: key, + ); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(topLeft ? radius : 0), + topRight: Radius.circular(topRight ? radius : 0), + bottomLeft: Radius.circular(bottomLeft ? radius : 0), + bottomRight: Radius.circular(bottomRight ? radius : 0), + ), + ), + child: child, + ); + } + + final bool bottomLeft; + final bool bottomRight; + final Widget child; + final double radius; + final bool topLeft; + final bool topRight; +} diff --git a/infrastructure/lib/presentation/widgets/_core/radiuses/radius_vertical.dart b/infrastructure/lib/presentation/widgets/_core/radiuses/radius_vertical.dart new file mode 100644 index 0000000..da095c1 --- /dev/null +++ b/infrastructure/lib/presentation/widgets/_core/radiuses/radius_vertical.dart @@ -0,0 +1,63 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: no-equal-arguments + +import 'package:flutter/material.dart'; +import 'package:infrastructure/presentation/super_class.dart'; + +/// A widget that applies a vertical radius (top and bottom) to a child widget. +class RadiusVertical extends StatelessWidget { + const RadiusVertical._({ + required this.child, + required this.radius, + super.key, + }); + + /// Creates a widget with a large vertical radius. + factory RadiusVertical.lg({required Widget child, Key? key}) => + RadiusVertical._(child: child, radius: $.radiuses.lg, key: key); + + /// Creates a widget with a medium vertical radius. + factory RadiusVertical.md({required Widget child, Key? key}) => + RadiusVertical._(child: child, radius: $.radiuses.md, key: key); + + /// Creates a widget with no vertical radius. + factory RadiusVertical.none({required Widget child, Key? key}) => + RadiusVertical._(child: child, radius: $.radiuses.none, key: key); + + /// Creates a widget with a small vertical radius. + factory RadiusVertical.sm({required Widget child, Key? key}) => + RadiusVertical._(child: child, radius: $.radiuses.sm, key: key); + + /// Creates a widget with an extra-large vertical radius. + factory RadiusVertical.xl({required Widget child, Key? key}) => + RadiusVertical._(child: child, radius: $.radiuses.xl, key: key); + + /// Creates a widget with an extra-small vertical radius. + factory RadiusVertical.xs({required Widget child, Key? key}) => + RadiusVertical._(child: child, radius: $.radiuses.xs, key: key); + + /// Creates a widget with an extra-extra-large vertical radius. + factory RadiusVertical.xxl({required Widget child, Key? key}) => + RadiusVertical._(child: child, radius: $.radiuses.xxl, key: key); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.vertical( + top: Radius.circular(radius), + bottom: Radius.circular(radius), + ), + ), + child: child, + ); + } + + final Widget child; + final double radius; +} diff --git a/infrastructure/lib/presentation/widgets/_core/url_strategy/url_strategy.dart b/infrastructure/lib/presentation/widgets/_core/url_strategy/url_strategy.dart new file mode 100644 index 0000000..621f583 --- /dev/null +++ b/infrastructure/lib/presentation/widgets/_core/url_strategy/url_strategy.dart @@ -0,0 +1,29 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: avoid_empty_blocks, no-empty-block + +abstract final class UrlStrategy { + /// Sets the URL strategy of your web app to using paths instead of a leading + /// hash (`#`). + /// + /// You can safely call this on all platforms, i.e. also when running on mobile + /// or desktop. In that case, it will simply be a noop. + /// + /// See also: + /// * [setHashUrlStrategy], which will use a hash URL strategy instead. + static void setPathUrlStrategy() {} + + /// Sets the URL strategy of your web app to using a leading has (`#`) instead + /// of paths. + /// + /// You can safely call this on all platforms, i.e. also when running on mobile + /// or desktop. In that case, it will simply be a noop. + /// + /// See also: + /// * [setPathUrlStrategy], which will use a path URL strategy instead. + static void setHashUrlStrategy() {} +} diff --git a/infrastructure/lib/presentation/widgets/_core/url_strategy/url_strategy_web.dart b/infrastructure/lib/presentation/widgets/_core/url_strategy/url_strategy_web.dart new file mode 100644 index 0000000..ee46ffb --- /dev/null +++ b/infrastructure/lib/presentation/widgets/_core/url_strategy/url_strategy_web.dart @@ -0,0 +1,33 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter_web_plugins/flutter_web_plugins.dart'; + +abstract final class UrlStrategyWeb { + /// Sets the URL strategy of your web app to using paths instead of a leading + /// hash (`#`). + /// + /// You can safely call this on all platforms, i.e. also when running on mobile + /// or desktop. In that case, it will simply be a noop. + /// + /// See also: + /// * [setHashUrlStrategy], which will use a hash URL strategy instead. + static void setPathUrlStrategy() { + setUrlStrategy(PathUrlStrategy()); + } + + /// Sets the URL strategy of your web app to using a leading has (`#`) instead + /// of paths. + /// + /// You can safely call this on all platforms, i.e. also when running on mobile + /// or desktop. In that case, it will simply be a noop. + /// + /// See also: + /// * [setPathUrlStrategy], which will use a path URL strategy instead. + static void setHashUrlStrategy() { + setUrlStrategy(const HashUrlStrategy()); + } +} diff --git a/infrastructure/lib/presentation/widgets/animated_box_decoration/blend_decoration_image.dart b/infrastructure/lib/presentation/widgets/animated_box_decoration/blend_decoration_image.dart new file mode 100644 index 0000000..b6b1439 --- /dev/null +++ b/infrastructure/lib/presentation/widgets/animated_box_decoration/blend_decoration_image.dart @@ -0,0 +1,527 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'dart:developer' as developer; +import 'dart:ui' as ui show Image; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +/// The painter for a [BlendDecorationImage]. +/// +/// To obtain a painter, call [BlendDecorationImage.createPainter]. +/// +/// To paint, call [paint]. The `onChanged` callback passed to +/// [BlendDecorationImage.createPainter] will be called if the image needs to paint +/// again (e.g. because it is animated or because it had not yet loaded the +/// first time the [paint] method was called). +/// +/// This object should be disposed using the [dispose] method when it is no +/// longer needed. +class BlendDecorationImagePainter { + BlendDecorationImagePainter(this.details, this.onChanged); + + final DecorationImage details; + final VoidCallback onChanged; + + ImageStream? _imageStream; + ImageInfo? _image; + + /// Draw the image onto the given canvas. + /// + /// The image is drawn at the position and size given by the `rect` argument. + /// + /// The image is clipped to the given `clipPath`, if any. + /// + /// The `configuration` object is used to resolve the image (e.g. to pick + /// resolution-specific assets), and to implement the + /// [BlendDecorationImage.matchTextDirection] feature. + /// + /// If the image needs to be painted again, e.g. because it is animated or + /// because it had not yet been loaded the first time this method was called, + /// then the `onChanged` callback passed to [BlendDecorationImage.createPainter] + /// will be called. + void paint( + Canvas canvas, + Rect rect, + Path? clipPath, + ImageConfiguration configuration, + BlendMode blendMode, + ) { + bool flipHorizontally = false; + if (details.matchTextDirection) { + assert(() { + // We check this first so that the assert will fire immediately, not just + // when the image is ready. + if (configuration.textDirection == null) { + throw FlutterError.fromParts([ + ErrorSummary( + 'DecorationImage.matchTextDirection can only be used when a TextDirection is available.', + ), + ErrorDescription( + 'When DecorationImagePainter.paint() was called, there was no text direction provided ' + 'in the ImageConfiguration object to match.', + ), + DiagnosticsProperty( + 'The DecorationImage was', + details, + style: DiagnosticsTreeStyle.errorProperty, + ), + DiagnosticsProperty( + 'The ImageConfiguration was', + configuration, + style: DiagnosticsTreeStyle.errorProperty, + ), + ]); + } + + return true; + }()); + if (configuration.textDirection == TextDirection.rtl) { + flipHorizontally = true; + } + } + + final ImageStream newImageStream = details.image.resolve(configuration); + if (newImageStream.key != _imageStream?.key) { + final ImageStreamListener listener = ImageStreamListener( + _handleImage, + onError: details.onError, + ); + _imageStream?.removeListener(listener); + _imageStream = newImageStream; + _imageStream!.addListener(listener); + } + if (_image == null) { + return; + } + + if (clipPath != null) { + canvas + ..save() + ..clipPath(clipPath); + } + + paintImage( + canvas: canvas, + rect: rect, + image: _image!.image, + blendMode: blendMode, + debugImageLabel: _image!.debugLabel, + scale: details.scale * _image!.scale, + opacity: details.opacity, + colorFilter: details.colorFilter, + fit: details.fit, + alignment: details.alignment.resolve(configuration.textDirection), + centerSlice: details.centerSlice, + repeat: details.repeat, + flipHorizontally: flipHorizontally, + invertColors: details.invertColors, + filterQuality: details.filterQuality, + isAntiAlias: details.isAntiAlias, + ); + + if (clipPath != null) { + canvas.restore(); + } + } + + void _handleImage(ImageInfo value, bool synchronousCall) { + if (_image == value) { + return; + } + if (_image != null && _image!.isCloneOf(value)) { + value.dispose(); + + return; + } + _image?.dispose(); + _image = value; + if (!synchronousCall) { + onChanged(); + } + } + + /// Releases the resources used by this painter. + /// + /// This should be called whenever the painter is no longer needed. + /// + /// After this method has been called, the object is no longer usable. + @mustCallSuper + void dispose() { + _imageStream?.removeListener( + ImageStreamListener( + _handleImage, + onError: details.onError, + ), + ); + _image?.dispose(); + _image = null; + } + + @override + String toString() { + return '${objectRuntimeType(this, 'DecorationImagePainter')}(stream: $_imageStream, image: $_image) for $details'; + } +} + +/// Used by [paintImage] to report image sizes drawn at the end of the frame. +Map _pendingImageSizeInfo = {}; + +/// [ImageSizeInfo]s that were reported on the last frame. +/// +/// Used to prevent duplicative reports from frame to frame. +Set _lastFrameImageSizeInfo = {}; + +/// Flushes inter-frame tracking of image size information from [paintImage]. +/// +/// Has no effect if asserts are disabled. +@visibleForTesting +void debugFlushLastFrameImageSizeInfo() { + assert(() { + _lastFrameImageSizeInfo = {}; + + return true; + }()); +} + +/// Paints an image into the given rectangle on the canvas. +/// +/// The arguments have the following meanings: +/// +/// * `canvas`: The canvas onto which the image will be painted. +/// +/// * `rect`: The region of the canvas into which the image will be painted. +/// The image might not fill the entire rectangle (e.g., depending on the +/// `fit`). If `rect` is empty, nothing is painted. +/// +/// * `image`: The image to paint onto the canvas. +/// +/// * `scale`: The number of image pixels for each logical pixel. +/// +/// * `opacity`: The opacity to paint the image onto the canvas with. +/// +/// * `colorFilter`: If non-null, the color filter to apply when painting the +/// image. +/// +/// * `fit`: How the image should be inscribed into `rect`. If null, the +/// default behavior depends on `centerSlice`. If `centerSlice` is also null, +/// the default behavior is [BoxFit.scaleDown]. If `centerSlice` is +/// non-null, the default behavior is [BoxFit.fill]. See [BoxFit] for +/// details. +/// +/// * `alignment`: How the destination rectangle defined by applying `fit` is +/// aligned within `rect`. For example, if `fit` is [BoxFit.contain] and +/// `alignment` is [Alignment.bottomRight], the image will be as large +/// as possible within `rect` and placed with its bottom right corner at the +/// bottom right corner of `rect`. Defaults to [Alignment.center]. +/// +/// * `centerSlice`: The image is drawn in nine portions described by splitting +/// the image by drawing two horizontal lines and two vertical lines, where +/// `centerSlice` describes the rectangle formed by the four points where +/// these four lines intersect each other. (This forms a 3-by-3 grid +/// of regions, the center region being described by `centerSlice`.) +/// The four regions in the corners are drawn, without scaling, in the four +/// corners of the destination rectangle defined by applying `fit`. The +/// remaining five regions are drawn by stretching them to fit such that they +/// exactly cover the destination rectangle while maintaining their relative +/// positions. +/// +/// * `repeat`: If the image does not fill `rect`, whether and how the image +/// should be repeated to fill `rect`. By default, the image is not repeated. +/// See [ImageRepeat] for details. +/// +/// * `flipHorizontally`: Whether to flip the image horizontally. This is +/// occasionally used with images in right-to-left environments, for images +/// that were designed for left-to-right locales (or vice versa). Be careful, +/// when using this, to not flip images with integral shadows, text, or other +/// effects that will look incorrect when flipped. +/// +/// * `invertColors`: Inverting the colors of an image applies a new color +/// filter to the paint. If there is another specified color filter, the +/// invert will be applied after it. This is primarily used for implementing +/// smart invert on iOS. +/// +/// * `filterQuality`: Use this to change the quality when scaling an image. +/// Use the [FilterQuality.low] quality setting to scale the image, which corresponds to +/// bilinear interpolation, rather than the default [FilterQuality.none] which corresponds +/// to nearest-neighbor. +/// +/// The `canvas`, `rect`, `image`, `scale`, `alignment`, `repeat`, `flipHorizontally` and `filterQuality` +/// arguments must not be null. +/// +/// See also: +/// +/// * [paintBorder], which paints a border around a rectangle on a canvas. +/// * [BlendDecorationImage], which holds a configuration for calling this function. +/// * [BoxDecoration], which uses this function to paint a [BlendDecorationImage]. +void paintImage({ + required Canvas canvas, + required Rect rect, + required ui.Image image, + required BlendMode blendMode, + String? debugImageLabel, + double scale = 1.0, + double opacity = 1.0, + ColorFilter? colorFilter, + BoxFit? fit, + Alignment alignment = Alignment.center, + Rect? centerSlice, + ImageRepeat repeat = ImageRepeat.noRepeat, + bool flipHorizontally = false, + bool invertColors = false, + FilterQuality filterQuality = FilterQuality.low, + bool isAntiAlias = false, +}) { + assert( + image.debugGetOpenHandleStackTraces()?.isNotEmpty ?? true, + 'Cannot paint an image that is disposed.\n' + 'The caller of paintImage is expected to wait to dispose the image until ' + 'after painting has completed.', + ); + if (rect.isEmpty) { + return; + } + Size outputSize = rect.size; + Size inputSize = Size(image.width.toDouble(), image.height.toDouble()); + Offset? sliceBorder; + if (centerSlice != null) { + sliceBorder = inputSize / scale - centerSlice.size as Offset; + outputSize = outputSize - sliceBorder as Size; + inputSize = inputSize - sliceBorder * scale as Size; + } + fit ??= centerSlice == null ? BoxFit.scaleDown : BoxFit.fill; + assert(centerSlice == null || (fit != BoxFit.none && fit != BoxFit.cover)); + final FittedSizes fittedSizes = + applyBoxFit(fit, inputSize / scale, outputSize); + final Size sourceSize = fittedSizes.source * scale; + Size destinationSize = fittedSizes.destination; + if (centerSlice != null) { + outputSize += sliceBorder!; + destinationSize += sliceBorder; + // We don't have the ability to draw a subset of the image at the same time + // as we apply a nine-patch stretch. + assert( + sourceSize == inputSize, + 'centerSlice was used with a BoxFit that does not guarantee that the image is fully visible.', + ); + } + + if (repeat != ImageRepeat.noRepeat && destinationSize == outputSize) { + // There's no need to repeat the image because we're exactly filling the + // output rect with the image. + repeat = ImageRepeat.noRepeat; + } + final Paint paint = Paint()..isAntiAlias = isAntiAlias; + if (colorFilter != null) { + paint.colorFilter = colorFilter; + } + paint.color = Color.fromRGBO(0, 0, 0, opacity); + paint.filterQuality = filterQuality; + paint.invertColors = invertColors; + paint.blendMode = blendMode; + final double halfWidthDelta = + (outputSize.width - destinationSize.width) / 2.0; + final double halfHeightDelta = + (outputSize.height - destinationSize.height) / 2.0; + final double dx = halfWidthDelta + + (flipHorizontally ? -alignment.x : alignment.x) * halfWidthDelta; + final double dy = halfHeightDelta + alignment.y * halfHeightDelta; + final Offset destinationPosition = rect.topLeft.translate(dx, dy); + final Rect destinationRect = destinationPosition & destinationSize; + + // Set to true if we added a saveLayer to the canvas to invert/flip the image. + bool invertedCanvas = false; + // Output size and destination rect are fully calculated. + if (!kReleaseMode) { + final ImageSizeInfo sizeInfo = ImageSizeInfo( + // Some ImageProvider implementations may not have given this. + source: + debugImageLabel ?? '', + imageSize: Size(image.width.toDouble(), image.height.toDouble()), + // It's ok to use this instead of a MediaQuery because if this changes, + // whatever is aware of the MediaQuery will be repainting the image anyway. + displaySize: + outputSize * PaintingBinding.instance.window.devicePixelRatio, + ); + assert(() { + if (debugInvertOversizedImages && + sizeInfo.decodedSizeInBytes > + sizeInfo.displaySizeInBytes + debugImageOverheadAllowance) { + final int overheadInKilobytes = + (sizeInfo.decodedSizeInBytes - sizeInfo.displaySizeInBytes) ~/ 1024; + final int outputWidth = sizeInfo.displaySize.width.toInt(); + final int outputHeight = sizeInfo.displaySize.height.toInt(); + FlutterError.reportError( + FlutterErrorDetails( + exception: 'Image $debugImageLabel has a display size of ' + '$outputWidth×$outputHeight but a decode size of ' + '${image.width}×${image.height}, which uses an additional ' + '${overheadInKilobytes}KB.\n\n' + 'Consider resizing the asset ahead of time, supplying a cacheWidth ' + 'parameter of $outputWidth, a cacheHeight parameter of ' + '$outputHeight, or using a ResizeImage.', + library: 'painting library', + context: ErrorDescription('while painting an image'), + ), + ); + // Invert the colors of the canvas. + canvas.saveLayer( + destinationRect, + Paint() + ..colorFilter = const ColorFilter.matrix([ + -1, + 0, + 0, + 0, + 255, + 0, + -1, + 0, + 0, + 255, + 0, + 0, + -1, + 0, + 255, + 0, + 0, + 0, + 1, + 0, + ]), + ); + // Flip the canvas vertically. + final double dy = -(rect.top + rect.height / 2.0); + canvas.translate(0, -dy); + canvas.scale(1, -1); + canvas.translate(0, dy); + invertedCanvas = true; + } + + return true; + }()); + // Avoid emitting events that are the same as those emitted in the last frame. + if (!_lastFrameImageSizeInfo.contains(sizeInfo)) { + final ImageSizeInfo? existingSizeInfo = + _pendingImageSizeInfo[sizeInfo.source]; + if (existingSizeInfo == null || + existingSizeInfo.displaySizeInBytes < sizeInfo.displaySizeInBytes) { + _pendingImageSizeInfo[sizeInfo.source!] = sizeInfo; + } + debugOnPaintImage?.call(sizeInfo); + SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) { + _lastFrameImageSizeInfo = _pendingImageSizeInfo.values.toSet(); + if (_pendingImageSizeInfo.isEmpty) { + return; + } + developer.postEvent( + 'Flutter.ImageSizesForFrame', + { + for (final ImageSizeInfo imageSizeInfo + in _pendingImageSizeInfo.values) + imageSizeInfo.source!: imageSizeInfo.toJson(), + }, + ); + _pendingImageSizeInfo = {}; + }); + } + } + + final bool needSave = + centerSlice != null || repeat != ImageRepeat.noRepeat || flipHorizontally; + if (needSave) { + canvas.save(); + } + if (repeat != ImageRepeat.noRepeat) { + canvas.clipRect(rect); + } + if (flipHorizontally) { + final double dx = -(rect.left + rect.width / 2.0); + canvas.translate(-dx, 0); + canvas.scale(-1, 1); + canvas.translate(dx, 0); + } + if (centerSlice == null) { + final Rect sourceRect = alignment.inscribe( + sourceSize, + Offset.zero & inputSize, + ); + if (repeat == ImageRepeat.noRepeat) { + canvas.drawImageRect(image, sourceRect, destinationRect, paint); + } else { + for (final Rect tileRect + in _generateImageTileRects(rect, destinationRect, repeat)) { + canvas.drawImageRect(image, sourceRect, tileRect, paint); + } + } + } else { + canvas.scale(1 / scale); + if (repeat == ImageRepeat.noRepeat) { + canvas.drawImageNine( + image, + _scaleRect(centerSlice, scale), + _scaleRect(destinationRect, scale), + paint, + ); + } else { + for (final Rect tileRect + in _generateImageTileRects(rect, destinationRect, repeat)) { + canvas.drawImageNine( + image, + _scaleRect(centerSlice, scale), + _scaleRect(tileRect, scale), + paint, + ); + } + } + } + if (needSave) { + canvas.restore(); + } + + if (invertedCanvas) { + canvas.restore(); + } +} + +Iterable _generateImageTileRects( + Rect outputRect, + Rect fundamentalRect, + ImageRepeat repeat, +) { + int startX = 0; + int startY = 0; + int stopX = 0; + int stopY = 0; + final double strideX = fundamentalRect.width; + final double strideY = fundamentalRect.height; + + if (repeat == ImageRepeat.repeat || repeat == ImageRepeat.repeatX) { + startX = ((outputRect.left - fundamentalRect.left) / strideX).floor(); + stopX = ((outputRect.right - fundamentalRect.right) / strideX).ceil(); + } + + if (repeat == ImageRepeat.repeat || repeat == ImageRepeat.repeatY) { + startY = ((outputRect.top - fundamentalRect.top) / strideY).floor(); + stopY = ((outputRect.bottom - fundamentalRect.bottom) / strideY).ceil(); + } + + return [ + for (int i = startX; i <= stopX; ++i) + for (int j = startY; j <= stopY; ++j) + fundamentalRect.shift(Offset(i * strideX, j * strideY)), + ]; +} + +Rect _scaleRect(Rect rect, double scale) => Rect.fromLTRB( + rect.left * scale, + rect.top * scale, + rect.right * scale, + rect.bottom * scale, + ); diff --git a/infrastructure/lib/presentation/widgets/animated_box_decoration/box_decoration_mix.dart b/infrastructure/lib/presentation/widgets/animated_box_decoration/box_decoration_mix.dart new file mode 100644 index 0000000..c8d45c9 --- /dev/null +++ b/infrastructure/lib/presentation/widgets/animated_box_decoration/box_decoration_mix.dart @@ -0,0 +1,483 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'dart:math' as math; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; + +import 'blend_decoration_image.dart'; + +/// An immutable description of how to paint a box. +/// +/// The [BoxDecorationMix] class provides a variety of ways to draw a box. +/// +/// The box has a [border], a body, and may cast a [boxShadow]. +/// +/// The [shape] of the box can be a circle or a rectangle. If it is a rectangle, +/// then the [borderRadius] property controls the roundness of the corners. +/// +/// The body of the box is painted in layers. The bottom-most layer is the +/// [color], which fills the box. Above that is the [gradient], which also fills +/// the box. Finally there is the [image], the precise alignment of which is +/// controlled by the [DecorationImage] class. +/// +/// The [border] paints over the body; the [boxShadow], naturally, paints below it. +/// +/// {@tool snippet} +/// +/// The following applies a [BoxDecorationMix] to a [Container] widget to draw an +/// [image] of an owl with a thick black [border] and rounded corners. +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/painting/box_decoration.png) +/// +/// ```dart +/// Container( +/// decoration: BoxDecoration( +/// color: const Color(0xff7c94b6), +/// image: const DecorationImage( +/// image: NetworkImage('https://flutter.github.io/assets-for-api-docs/assets/widgets/owl-2.jpg'), +/// fit: BoxFit.cover, +/// ), +/// border: Border.all( +/// color: Colors.black, +/// width: 8, +/// ), +/// borderRadius: BorderRadius.circular(12), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@template flutter.painting.BoxDecoration.clip} +/// The [shape] or the [borderRadius] won't clip the children of the +/// decorated [Container]. If the clip is required, insert a clip widget +/// (e.g., [ClipRect], [ClipRRect], [ClipPath]) as the child of the [Container]. +/// Be aware that clipping may be costly in terms of performance. +/// {@endtemplate} +/// +/// See also: +/// +/// * [DecoratedBox] and [Container], widgets that can be configured with +/// [BoxDecoration] objects. +/// * [CustomPaint], a widget that lets you draw arbitrary graphics. +/// * [Decoration], the base class which lets you define other decorations. +class BoxDecorationMix extends Decoration { + /// Creates a box decoration. + /// + /// * If [color] is null, this decoration does not paint a background color. + /// * If [image] is null, this decoration does not paint a background image. + /// * If [border] is null, this decoration does not paint a border. + /// * If [borderRadius] is null, this decoration uses more efficient background + /// painting commands. The [borderRadius] argument must be null if [shape] is + /// [BoxShape.circle]. + /// * If [boxShadow] is null, this decoration does not paint a shadow. + /// * If [gradient] is null, this decoration does not paint gradients. + /// * If [backgroundBlendMode] is null, this decoration paints with [BlendMode.srcOver] + /// + /// The [shape] argument must not be null. + const BoxDecorationMix({ + this.color, + this.image1, + this.image2, + this.border, + this.borderRadius, + this.boxShadow, + this.gradient1, + this.gradient2, + this.backgroundBlendMode, + this.shape = BoxShape.rectangle, + }) : assert( + backgroundBlendMode == null || + color != null || + gradient1 != null || + gradient2 != null, + "backgroundBlendMode applies to BoxDecoration's background color or " + 'gradient, but no color or gradient was provided.', + ); + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + BoxDecorationMix copyWith({ + Color? color, + DecorationImage? image1, + DecorationImage? image2, + BoxBorder? border, + BorderRadiusGeometry? borderRadius, + List? boxShadow, + Gradient? gradient1, + Gradient? gradient2, + BlendMode? backgroundBlendMode, + BoxShape? shape, + }) { + return BoxDecorationMix( + color: color ?? this.color, + image1: image1 ?? this.image1, + image2: image2 ?? this.image2, + border: border ?? this.border, + borderRadius: borderRadius ?? this.borderRadius, + boxShadow: boxShadow ?? this.boxShadow, + gradient1: gradient1 ?? this.gradient1, + gradient2: gradient2 ?? this.gradient2, + backgroundBlendMode: backgroundBlendMode ?? this.backgroundBlendMode, + shape: shape ?? this.shape, + ); + } + + @override + bool debugAssertIsValid() { + assert(shape != BoxShape.circle || + borderRadius == null); // Can't have a border radius if you're a circle. + return super.debugAssertIsValid(); + } + + /// The color to fill in the background of the box. + /// + /// The color is filled into the [shape] of the box (e.g., either a rectangle, + /// potentially with a [borderRadius], or a circle). + /// + /// This is ignored if [gradient] is non-null. + /// + /// The [color] is drawn under the [image]. + final Color? color; + + /// An image to paint above the background [color] or [gradient]. + /// + /// If [shape] is [BoxShape.circle] then the image is clipped to the circle's + /// boundary; if [borderRadius] is non-null then the image is clipped to the + /// given radii. + final DecorationImage? image1; + + final DecorationImage? image2; + + /// A border to draw above the background [color], [gradient], or [image]. + /// + /// Follows the [shape] and [borderRadius]. + /// + /// Use [Border] objects to describe borders that do not depend on the reading + /// direction. + /// + /// Use [BoxBorder] objects to describe borders that should flip their left + /// and right edges based on whether the text is being read left-to-right or + /// right-to-left. + final BoxBorder? border; + + /// If non-null, the corners of this box are rounded by this [BorderRadius]. + /// + /// Applies only to boxes with rectangular shapes; ignored if [shape] is not + /// [BoxShape.rectangle]. + /// + /// {@macro flutter.painting.BoxDecoration.clip} + final BorderRadiusGeometry? borderRadius; + + /// A list of shadows cast by this box behind the box. + /// + /// The shadow follows the [shape] of the box. + /// + /// See also: + /// + /// * [kElevationToShadow], for some predefined shadows used in Material + /// Design. + /// * [PhysicalModel], a widget for showing shadows. + final List? boxShadow; + + /// A gradient to use when filling the box. + /// + /// If this is specified, [color] has no effect. + /// + /// The [gradient] is drawn under the [image]. + final Gradient? gradient1; + final Gradient? gradient2; + + /// The blend mode applied to the [color] or [gradient] background of the box. + /// + /// If no [backgroundBlendMode] is provided then the default painting blend + /// mode is used. + /// + /// If no [color] or [gradient] is provided then the blend mode has no impact. + final BlendMode? backgroundBlendMode; + + /// The shape to fill the background [color], [gradient], and [image] into and + /// to cast as the [boxShadow]. + /// + /// If this is [BoxShape.circle] then [borderRadius] is ignored. + /// + /// The [shape] cannot be interpolated; animating between two [BoxDecorationMix]s + /// with different [shape]s will result in a discontinuity in the rendering. + /// To interpolate between two shapes, consider using [ShapeDecoration] and + /// different [ShapeBorder]s; in particular, [CircleBorder] instead of + /// [BoxShape.circle] and [RoundedRectangleBorder] instead of + /// [BoxShape.rectangle]. + /// + /// {@macro flutter.painting.BoxDecoration.clip} + final BoxShape shape; + + @override + EdgeInsetsGeometry get padding => border?.dimensions ?? EdgeInsets.zero; + + @override + Path getClipPath(Rect rect, TextDirection textDirection) { + switch (shape) { + case BoxShape.circle: + final Offset center = rect.center; + final double radius = rect.shortestSide / 2.0; + final Rect square = Rect.fromCircle(center: center, radius: radius); + return Path()..addOval(square); + case BoxShape.rectangle: + if (borderRadius != null) { + return Path() + ..addRRect(borderRadius!.resolve(textDirection).toRRect(rect)); + } + return Path()..addRect(rect); + } + } + + @override + bool get isComplex => boxShadow != null; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + return other is BoxDecorationMix && + other.color == color && + other.image1 == image1 && + other.image2 == image2 && + other.border == border && + other.borderRadius == borderRadius && + listEquals(other.boxShadow, boxShadow) && + other.gradient1 == gradient1 && + other.gradient2 == gradient2 && + other.shape == shape; + } + + @override + int get hashCode { + return Object.hash( + color, + image1, + image2, + border, + borderRadius, + Object.hashAll(boxShadow ?? []), + gradient1, + gradient2, + shape, + ); + } + + @override + bool hitTest(Size size, Offset position, {TextDirection? textDirection}) { + assert((Offset.zero & size).contains(position)); + switch (shape) { + case BoxShape.rectangle: + if (borderRadius != null) { + final RRect bounds = + borderRadius!.resolve(textDirection).toRRect(Offset.zero & size); + return bounds.contains(position); + } + return true; + case BoxShape.circle: + // Circles are inscribed into our smallest dimension. + final Offset center = size.center(Offset.zero); + final double distance = (position - center).distance; + return distance <= math.min(size.width, size.height) / 2.0; + } + } + + @override + BoxPainter createBoxPainter([VoidCallback? onChanged]) { + assert(onChanged != null || image1 == null || image2 == null); + return _BoxDecorationMixPainter(this, onChanged); + } +} + +/// An object that paints a [BoxDecorationMix] into a canvas. +class _BoxDecorationMixPainter extends BoxPainter { + _BoxDecorationMixPainter(this._decoration, VoidCallback? onChanged) + : super(onChanged); + + final BoxDecorationMix _decoration; + + Paint? _cachedBackgroundPaint1; + Rect? _rectForCachedBackgroundPaint1; + Paint _getBackgroundPaint1(Rect rect, TextDirection? textDirection) { + assert(_decoration.gradient1 != null || + _rectForCachedBackgroundPaint1 == null); + + if (_cachedBackgroundPaint1 == null || + (_decoration.gradient1 != null && + _rectForCachedBackgroundPaint1 != rect)) { + final Paint paint = Paint(); + if (_decoration.backgroundBlendMode != null) { + paint.blendMode = _decoration.backgroundBlendMode!; + } + if (_decoration.color != null) paint.color = _decoration.color!; + if (_decoration.gradient1 != null) { + paint.shader = _decoration.gradient1! + .createShader(rect, textDirection: textDirection); + _rectForCachedBackgroundPaint1 = rect; + } + _cachedBackgroundPaint1 = paint; + } + + return _cachedBackgroundPaint1!; + } + + Paint? _cachedBackgroundPaint2; + Rect? _rectForCachedBackgroundPaint2; + Paint _getBackgroundPaint2(Rect rect, TextDirection? textDirection) { + assert(_decoration.gradient2 != null || + _rectForCachedBackgroundPaint2 == null); + + if (_cachedBackgroundPaint2 == null || + (_decoration.gradient2 != null && + _rectForCachedBackgroundPaint2 != rect)) { + final Paint paint = Paint(); + if (_decoration.backgroundBlendMode != null) { + paint.blendMode = _decoration.backgroundBlendMode!; + } + if (_decoration.color != null) paint.color = _decoration.color!; + if (_decoration.gradient2 != null) { + paint.shader = _decoration.gradient2! + .createShader(rect, textDirection: textDirection); + _rectForCachedBackgroundPaint2 = rect; + } + _cachedBackgroundPaint2 = paint; + } + + return _cachedBackgroundPaint2!; + } + + void _paintBox( + Canvas canvas, Rect rect, Paint paint, TextDirection? textDirection) { + switch (_decoration.shape) { + case BoxShape.circle: + assert(_decoration.borderRadius == null); + final Offset center = rect.center; + final double radius = rect.shortestSide / 2.0; + canvas.drawCircle(center, radius, paint); + break; + case BoxShape.rectangle: + if (_decoration.borderRadius == null) { + canvas.drawRect(rect, paint); + } else { + canvas.drawRRect( + _decoration.borderRadius!.resolve(textDirection).toRRect(rect), + paint); + } + break; + } + } + + void _paintShadows(Canvas canvas, Rect rect, TextDirection? textDirection) { + if (_decoration.boxShadow == null) return; + for (final BoxShadow boxShadow in _decoration.boxShadow!) { + final Paint paint = boxShadow.toPaint(); + final Rect bounds = + rect.shift(boxShadow.offset).inflate(boxShadow.spreadRadius); + _paintBox(canvas, bounds, paint, textDirection); + } + } + + void _paintBackgroundColor( + Canvas canvas, Rect rect, TextDirection? textDirection) { + if (_decoration.color != null || _decoration.gradient1 != null) { + _paintBox(canvas, rect, _getBackgroundPaint1(rect, textDirection), + textDirection); + } + + if (_decoration.color != null || _decoration.gradient2 != null) { + _paintBox(canvas, rect, _getBackgroundPaint2(rect, textDirection), + textDirection); + } + } + + DecorationImagePainter? _imagePainter1; + void _paintBackgroundImage1( + Canvas canvas, Rect rect, ImageConfiguration configuration) { + if (_decoration.image1 == null) return; + _imagePainter1 ??= _decoration.image1!.createPainter(onChanged!); + Path? clipPath; + switch (_decoration.shape) { + case BoxShape.circle: + assert(_decoration.borderRadius == null); + final Offset center = rect.center; + final double radius = rect.shortestSide / 2.0; + final Rect square = Rect.fromCircle(center: center, radius: radius); + clipPath = Path()..addOval(square); + break; + case BoxShape.rectangle: + if (_decoration.borderRadius != null) { + clipPath = Path() + ..addRRect(_decoration.borderRadius! + .resolve(configuration.textDirection) + .toRRect(rect)); + } + break; + } + _imagePainter1!.paint(canvas, rect, clipPath, configuration); + } + + BlendDecorationImagePainter? _imagePainter2; + void _paintBackgroundImage2( + Canvas canvas, Rect rect, ImageConfiguration configuration) { + if (_decoration.image2 == null) return; + _imagePainter2 ??= + BlendDecorationImagePainter(_decoration.image2!, onChanged!); + Path? clipPath; + switch (_decoration.shape) { + case BoxShape.circle: + assert(_decoration.borderRadius == null); + final Offset center = rect.center; + final double radius = rect.shortestSide / 2.0; + final Rect square = Rect.fromCircle(center: center, radius: radius); + clipPath = Path()..addOval(square); + break; + case BoxShape.rectangle: + if (_decoration.borderRadius != null) { + clipPath = Path() + ..addRRect(_decoration.borderRadius! + .resolve(configuration.textDirection) + .toRRect(rect)); + } + break; + } + _imagePainter2! + .paint(canvas, rect, clipPath, configuration, BlendMode.srcOver); + } + + @override + void dispose() { + _imagePainter1?.dispose(); + _imagePainter2?.dispose(); + super.dispose(); + } + + /// Paint the box decoration into the given location on the given canvas. + @override + void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { + assert(configuration.size != null); + final Rect rect = offset & configuration.size!; + final TextDirection? textDirection = configuration.textDirection; + _paintShadows(canvas, rect, textDirection); + _paintBackgroundColor(canvas, rect, textDirection); + _paintBackgroundImage1(canvas, rect, configuration); + _paintBackgroundImage2(canvas, rect, configuration); + _decoration.border?.paint( + canvas, + rect, + shape: _decoration.shape, + borderRadius: _decoration.borderRadius?.resolve(textDirection), + textDirection: configuration.textDirection, + ); + } + + @override + String toString() { + return 'BoxPainter for $_decoration'; + } +} diff --git a/infrastructure/lib/presentation/widgets/animated_box_decoration/smooth_animated_container.dart b/infrastructure/lib/presentation/widgets/animated_box_decoration/smooth_animated_container.dart new file mode 100644 index 0000000..d63e5cd --- /dev/null +++ b/infrastructure/lib/presentation/widgets/animated_box_decoration/smooth_animated_container.dart @@ -0,0 +1,260 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; + +import 'smooth_decoration_tween.dart'; + +class SmoothAnimatedContainer extends ImplicitlyAnimatedWidget { + /// Creates a container that animates its parameters implicitly. + /// + /// The [curve] and [duration] arguments must not be null. + SmoothAnimatedContainer({ + Key? key, + this.alignment, + this.padding, + Color? color, + Decoration? decoration, + this.foregroundDecoration, + double? width, + double? height, + BoxConstraints? constraints, + this.margin, + this.transform, + this.transformAlignment, + this.child, + this.clipBehavior = Clip.none, + Curve curve = Curves.linear, + required Duration duration, + VoidCallback? onEnd, + }) : assert(margin == null || margin.isNonNegative), + assert(padding == null || padding.isNonNegative), + assert(decoration == null || decoration.debugAssertIsValid()), + assert(constraints == null || constraints.debugAssertIsValid()), + assert( + color == null || decoration == null, + 'Cannot provide both a color and a decoration\n' + 'The color argument is just a shorthand for "decoration: BoxDecoration(color: color)".', + ), + decoration = + decoration ?? (color != null ? BoxDecoration(color: color) : null), + constraints = (width != null || height != null) + ? constraints?.tighten(width: width, height: height) ?? + BoxConstraints.tightFor(width: width, height: height) + : constraints, + super(key: key, curve: curve, duration: duration, onEnd: onEnd); + + /// The [child] contained by the container. + /// + /// If null, and if the [constraints] are unbounded or also null, the + /// container will expand to fill all available space in its parent, unless + /// the parent provides unbounded constraints, in which case the container + /// will attempt to be as small as possible. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget? child; + + /// Align the [child] within the container. + /// + /// If non-null, the container will expand to fill its parent and position its + /// child within itself according to the given value. If the incoming + /// constraints are unbounded, then the child will be shrink-wrapped instead. + /// + /// Ignored if [child] is null. + /// + /// See also: + /// + /// * [Alignment], a class with convenient constants typically used to + /// specify an [AlignmentGeometry]. + /// * [AlignmentDirectional], like [Alignment] for specifying alignments + /// relative to text direction. + final AlignmentGeometry? alignment; + + /// Empty space to inscribe inside the [decoration]. The [child], if any, is + /// placed inside this padding. + final EdgeInsetsGeometry? padding; + + /// The decoration to paint behind the [child]. + /// + /// A shorthand for specifying just a solid color is available in the + /// constructor: set the `color` argument instead of the `decoration` + /// argument. + final Decoration? decoration; + + /// The decoration to paint in front of the child. + final Decoration? foregroundDecoration; + + /// Additional constraints to apply to the child. + /// + /// The constructor `width` and `height` arguments are combined with the + /// `constraints` argument to set this property. + /// + /// The [padding] goes inside the constraints. + final BoxConstraints? constraints; + + /// Empty space to surround the [decoration] and [child]. + final EdgeInsetsGeometry? margin; + + /// The transformation matrix to apply before painting the container. + final Matrix4? transform; + + /// The alignment of the origin, relative to the size of the container, if [transform] is specified. + /// + /// When [transform] is null, the value of this property is ignored. + /// + /// See also: + /// + /// * [Transform.alignment], which is set by this property. + final AlignmentGeometry? transformAlignment; + + /// The clip behavior when [SmoothAnimatedContainer.decoration] is not null. + /// + /// Defaults to [Clip.none]. Must be [Clip.none] if [decoration] is null. + /// + /// Unlike other properties of [SmoothAnimatedContainer], changes to this property + /// apply immediately and have no animation. + /// + /// If a clip is to be applied, the [Decoration.getClipPath] method + /// for the provided decoration must return a clip path. (This is not + /// supported by all decorations; the default implementation of that + /// method throws an [UnsupportedError].) + final Clip clipBehavior; + + @override + AnimatedWidgetBaseState createState() => + _AnimatedContainerState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty( + 'alignment', alignment, + showName: false, defaultValue: null)); + properties.add(DiagnosticsProperty('padding', padding, + defaultValue: null)); + properties.add( + DiagnosticsProperty('bg', decoration, defaultValue: null)); + properties.add(DiagnosticsProperty('fg', foregroundDecoration, + defaultValue: null)); + properties.add(DiagnosticsProperty( + 'constraints', constraints, + defaultValue: null, showName: false)); + properties.add(DiagnosticsProperty('margin', margin, + defaultValue: null)); + properties.add(ObjectFlagProperty.has('transform', transform)); + properties.add(DiagnosticsProperty( + 'transformAlignment', transformAlignment, + defaultValue: null)); + properties.add(DiagnosticsProperty('clipBehavior', clipBehavior)); + } +} + +class _AnimatedContainerState + extends AnimatedWidgetBaseState { + AlignmentGeometryTween? _alignment; + EdgeInsetsGeometryTween? _padding; + DecorationTween? _decoration; + DecorationTween? _foregroundDecoration; + BoxConstraintsTween? _constraints; + EdgeInsetsGeometryTween? _margin; + Matrix4Tween? _transform; + AlignmentGeometryTween? _transformAlignment; + + @override + void forEachTween(TweenVisitor visitor) { + _alignment = visitor( + _alignment, + widget.alignment, + (dynamic value) => + AlignmentGeometryTween(begin: value as AlignmentGeometry)) + as AlignmentGeometryTween?; + _padding = visitor( + _padding, + widget.padding, + (dynamic value) => + EdgeInsetsGeometryTween(begin: value as EdgeInsetsGeometry)) + as EdgeInsetsGeometryTween?; + _decoration = visitor( + _decoration, + widget.decoration, + (dynamic value) => + SmoothDecorationTween(begin: value as Decoration)) + as DecorationTween?; + _foregroundDecoration = visitor( + _foregroundDecoration, + widget.foregroundDecoration, + (dynamic value) => + SmoothDecorationTween(begin: value as Decoration)) + as DecorationTween?; + _constraints = visitor( + _constraints, + widget.constraints, + (dynamic value) => + BoxConstraintsTween(begin: value as BoxConstraints)) + as BoxConstraintsTween?; + _margin = visitor( + _margin, + widget.margin, + (dynamic value) => + EdgeInsetsGeometryTween(begin: value as EdgeInsetsGeometry)) + as EdgeInsetsGeometryTween?; + _transform = visitor(_transform, widget.transform, + (dynamic value) => Matrix4Tween(begin: value as Matrix4)) + as Matrix4Tween?; + _transformAlignment = visitor( + _transformAlignment, + widget.transformAlignment, + (dynamic value) => + AlignmentGeometryTween(begin: value as AlignmentGeometry)) + as AlignmentGeometryTween?; + } + + @override + Widget build(BuildContext context) { + final Animation animation = this.animation; + return Container( + alignment: _alignment?.evaluate(animation), + padding: _padding?.evaluate(animation), + decoration: _decoration?.evaluate(animation), + foregroundDecoration: _foregroundDecoration?.evaluate(animation), + constraints: _constraints?.evaluate(animation), + margin: _margin?.evaluate(animation), + transform: _transform?.evaluate(animation), + transformAlignment: _transformAlignment?.evaluate(animation), + clipBehavior: widget.clipBehavior, + child: widget.child, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder description) { + super.debugFillProperties(description); + description.add(DiagnosticsProperty( + 'alignment', _alignment, + showName: false, defaultValue: null)); + description.add(DiagnosticsProperty( + 'padding', _padding, + defaultValue: null)); + description.add(DiagnosticsProperty('bg', _decoration, + defaultValue: null)); + description.add(DiagnosticsProperty( + 'fg', _foregroundDecoration, + defaultValue: null)); + description.add(DiagnosticsProperty( + 'constraints', _constraints, + showName: false, defaultValue: null)); + description.add(DiagnosticsProperty( + 'margin', _margin, + defaultValue: null)); + description + .add(ObjectFlagProperty.has('transform', _transform)); + description.add(DiagnosticsProperty( + 'transformAlignment', _transformAlignment, + defaultValue: null)); + } +} diff --git a/infrastructure/lib/presentation/widgets/animated_box_decoration/smooth_decoration_tween.dart b/infrastructure/lib/presentation/widgets/animated_box_decoration/smooth_decoration_tween.dart new file mode 100644 index 0000000..e16d6ab --- /dev/null +++ b/infrastructure/lib/presentation/widgets/animated_box_decoration/smooth_decoration_tween.dart @@ -0,0 +1,231 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'box_decoration_mix.dart'; + +class SmoothDecorationTween extends DecorationTween { + SmoothDecorationTween({ + Decoration? begin, + Decoration? end, + }) : super(begin: begin, end: end); + + @override + Decoration lerp(double t) { + if (begin is BoxDecoration && end is BoxDecoration) { + return lerpBoxDecoration( + begin as BoxDecoration, end as BoxDecoration, t) ?? + const BoxDecoration(); + } + return Decoration.lerp(begin, end, t) ?? const BoxDecoration(); + } + + bool _isSameGradient(Gradient a, Gradient b) { + return (a is LinearGradient && b is LinearGradient) || + (a is RadialGradient && b is RadialGradient) || + (a is SweepGradient && b is SweepGradient); + } + + Decoration? lerpBoxDecoration(BoxDecoration? a, BoxDecoration? b, double t) { + if (a == null && b == null) return null; + if (a == null) return b!.scale(t); + if (b == null) return a.scale(1.0 - t); + if (t == 0.0) return a; + if (t == 1.0) return b; + if (a == b) return a; + + ///If there is image in either a or b or + ///if the gradient in a and b are not the same type, + ///use crossfade + if (a.image != null || + b.image != null || + (a.gradient != null && + b.gradient != null && + !_isSameGradient(a.gradient!, b.gradient!))) { + DecorationImage? aImageAtT, bImageAtT; + + if (a.image != null) { + DecorationImage aImage = a.image!; + aImageAtT = DecorationImage( + image: aImage.image, + onError: aImage.onError, + colorFilter: aImage.colorFilter, + fit: aImage.fit, + alignment: aImage.alignment, + centerSlice: aImage.centerSlice, + repeat: aImage.repeat, + matchTextDirection: aImage.matchTextDirection, + scale: aImage.scale, + opacity: b.image != null + ? aImage.opacity + : (lerpDouble(aImage.opacity, 0, t) ?? 0), + filterQuality: aImage.filterQuality, + invertColors: aImage.invertColors, + isAntiAlias: aImage.isAntiAlias, + ); + } + + if (b.image != null) { + DecorationImage bImage = b.image!; + bImageAtT = DecorationImage( + image: bImage.image, + onError: bImage.onError, + colorFilter: bImage.colorFilter, + fit: bImage.fit, + alignment: bImage.alignment, + centerSlice: bImage.centerSlice, + repeat: bImage.repeat, + matchTextDirection: bImage.matchTextDirection, + scale: bImage.scale, + opacity: lerpDouble(0, bImage.opacity, t) ?? 1, + filterQuality: bImage.filterQuality, + invertColors: bImage.invertColors, + isAntiAlias: bImage.isAntiAlias, + ); + } + + return BoxDecorationMix( + color: Color.lerp(a.color, b.color, t), + image1: aImageAtT, + image2: bImageAtT, + border: BoxBorder.lerp(a.border, b.border, t), + borderRadius: + BorderRadiusGeometry.lerp(a.borderRadius, b.borderRadius, t), + boxShadow: BoxShadow.lerpList(a.boxShadow, b.boxShadow, t), + gradient1: a.gradient?.scale(1 - t), + gradient2: b.gradient?.scale(t), + shape: t < 0.5 ? a.shape : b.shape, + ); + } + + if (a.gradient != null || b.gradient != null) { + return BoxDecoration( + border: BoxBorder.lerp(a.border, b.border, t), + borderRadius: + BorderRadiusGeometry.lerp(a.borderRadius, b.borderRadius, t), + boxShadow: BoxShadow.lerpList(a.boxShadow, b.boxShadow, t), + gradient: lerpGradient( + t, + a.gradient, + b.gradient, + a.color ?? const Color(0x00FFFFFF), + b.color ?? const Color(0x00FFFFFF)), + shape: t < 0.5 ? a.shape : b.shape, + ); + } + + return BoxDecoration( + color: Color.lerp(a.color, b.color, t), + border: BoxBorder.lerp(a.border, b.border, t), + borderRadius: + BorderRadiusGeometry.lerp(a.borderRadius, b.borderRadius, t), + boxShadow: BoxShadow.lerpList(a.boxShadow, b.boxShadow, t), + shape: t < 0.5 ? a.shape : b.shape, + ); + } + + Gradient? lerpGradient(double t, Gradient? beginGradient, + Gradient? endGradient, Color beginColor, Color endColor) { + if (beginGradient == null) { + if (endGradient == null) { + return null; + } else { + if (endGradient is LinearGradient) { + return LinearGradient.lerp( + LinearGradient( + begin: endGradient.begin, + end: endGradient.end, + transform: endGradient.transform, + tileMode: endGradient.tileMode, + stops: endGradient.stops, + colors: List.generate( + endGradient.colors.length, (index) => beginColor)), + endGradient, + t); + } + if (endGradient is RadialGradient) { + return RadialGradient.lerp( + RadialGradient( + center: endGradient.center, + radius: endGradient.radius, + focal: endGradient.focal, + focalRadius: endGradient.focalRadius, + transform: endGradient.transform, + tileMode: endGradient.tileMode, + stops: endGradient.stops, + colors: List.generate( + endGradient.colors.length, (index) => beginColor)), + endGradient, + t); + } + if (endGradient is SweepGradient) { + return SweepGradient.lerp( + SweepGradient( + center: endGradient.center, + startAngle: endGradient.startAngle, + endAngle: endGradient.endAngle, + transform: endGradient.transform, + tileMode: endGradient.tileMode, + stops: endGradient.stops, + colors: List.generate( + endGradient.colors.length, (index) => beginColor)), + endGradient, + t); + } + } + } else { + if (endGradient == null) { + if (beginGradient is LinearGradient) { + return LinearGradient.lerp( + beginGradient, + LinearGradient( + begin: beginGradient.begin, + end: beginGradient.end, + transform: beginGradient.transform, + tileMode: beginGradient.tileMode, + stops: beginGradient.stops, + colors: List.generate( + beginGradient.colors.length, (index) => endColor)), + t); + } + if (beginGradient is RadialGradient) { + return RadialGradient.lerp( + beginGradient, + RadialGradient( + center: beginGradient.center, + radius: beginGradient.radius, + focal: beginGradient.focal, + focalRadius: beginGradient.focalRadius, + transform: beginGradient.transform, + tileMode: beginGradient.tileMode, + stops: beginGradient.stops, + colors: List.generate( + beginGradient.colors.length, (index) => endColor)), + t); + } + if (beginGradient is SweepGradient) { + return SweepGradient.lerp( + beginGradient, + SweepGradient( + center: beginGradient.center, + startAngle: beginGradient.startAngle, + endAngle: beginGradient.endAngle, + transform: beginGradient.transform, + tileMode: beginGradient.tileMode, + stops: beginGradient.stops, + colors: List.generate( + beginGradient.colors.length, (index) => endColor)), + t); + } + } else { + return Gradient.lerp(beginGradient, endGradient, t); + } + } + return null; + } +} diff --git a/infrastructure/lib/presentation/widgets/blend_mask.dart b/infrastructure/lib/presentation/widgets/blend_mask.dart new file mode 100644 index 0000000..86e2f3a --- /dev/null +++ b/infrastructure/lib/presentation/widgets/blend_mask.dart @@ -0,0 +1,103 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// A widget that applies a blend mode and opacity to its child. +/// +/// The [BlendMask] allows you to blend the child widget with its background +/// using a specified [BlendMode] and an optional [opacity] to adjust the effect. +/// +/// The blend mode defines how the child content is composited with the +/// content behind it, while the [opacity] controls the transparency. +class BlendMask extends SingleChildRenderObjectWidget { + const BlendMask({ + required BlendMode blendMode, + required Widget super.child, + super.key, + double opacity = 1.0, + }) : _blendMode = blendMode, + _opacity = opacity; + + /// The blend mode to apply to the child widget. + final BlendMode _blendMode; + + /// The opacity to apply to the child widget. + /// + /// The opacity should be a value between 0.0 and 1.0, where 1.0 means fully opaque. + final double _opacity; + + @override + RenderObject createRenderObject(BuildContext context) { + return RenderCoreBlendMask(_blendMode, _opacity); + } + + @override + void updateRenderObject( + BuildContext context, RenderCoreBlendMask renderObject) { + renderObject + ..blendMode = _blendMode + ..opacity = _opacity; + } +} + +/// A [RenderProxyBox] that applies a blend mode and opacity to its child. +/// +/// This render object is used by the [BlendMask] widget to manage the +/// blending of the child content with its background using a given [BlendMode] +/// and [opacity]. +class RenderCoreBlendMask extends RenderProxyBox { + RenderCoreBlendMask(BlendMode blendMode, double opacity) + : _blendMode = blendMode, + _opacity = opacity; + + /// The blend mode used to composite the child with the background. + BlendMode _blendMode; + + /// The opacity of the child widget. + double _opacity; + + @override + void paint(PaintingContext context, Offset offset) { + context.canvas.saveLayer( + offset & size, + Paint() + ..blendMode = _blendMode + ..color = Color.fromARGB((_opacity * 255).round(), 255, 255, 255), + ); + + super.paint(context, offset); + + context.canvas.restore(); + } + + /// Gets the current blend mode. + BlendMode get blendMode => _blendMode; + + /// Gets the current opacity. + double get opacity => _opacity; + + /// Sets a new blend mode and repaints if necessary. + set blendMode(BlendMode value) { + if (_blendMode == value) { + return; + } + + _blendMode = value; + markNeedsPaint(); + } + + /// Sets a new opacity value and repaints if necessary. + set opacity(double value) { + if (_opacity == value) { + return; + } + + _opacity = value; + markNeedsPaint(); + } +} diff --git a/infrastructure/lib/presentation/widgets/nil.dart b/infrastructure/lib/presentation/widgets/nil.dart new file mode 100644 index 0000000..aec2495 --- /dev/null +++ b/infrastructure/lib/presentation/widgets/nil.dart @@ -0,0 +1,45 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: boolean_prefix, avoid-dynamic + +import 'package:flutter/material.dart'; + +/// A widget which is not in the layout and does nothing. +/// It is useful when you have to return a widget and can't return null. +/// Use this widget to represent a non-existent widget, especially in conditional rendering. +class CoreNil extends Widget { + /// Creates a [CoreNil] widget. + const CoreNil({super.key}); + + @override + Element createElement() => _CoreNilElement(this); +} + +class _CoreNilElement extends Element { + _CoreNilElement(CoreNil super.widget); + + @override + bool get debugDoingBuild => false; + + /// Mounts the element to the widget tree. + /// Asserts that it is not used under a MultiChildRenderObjectElement to prevent improper use. + @override + void mount(Element? parent, dynamic newSlot) { + super.mount(parent, newSlot); + assert(parent is! MultiChildRenderObjectElement, """ + You are using CoreNil under a MultiChildRenderObjectElement. + This suggests a possibility that the CoreNil is not needed or is being used improperly. + Make sure it can't be replaced with an inline conditional or + omission of the target widget from a list. + """); + } + + @override + void performRebuild() { + super.performRebuild(); + } +} diff --git a/infrastructure/lib/presentation/widgets/paginated_list/paginated_list.dart b/infrastructure/lib/presentation/widgets/paginated_list/paginated_list.dart new file mode 100644 index 0000000..a1f87ac --- /dev/null +++ b/infrastructure/lib/presentation/widgets/paginated_list/paginated_list.dart @@ -0,0 +1,135 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: no-equal-arguments, avoid-non-null-assertion + +import 'dart:async'; + +import 'package:deps/packages/flutter_bloc.dart'; +import 'package:deps/packages/infinite_scroll_pagination.dart'; +import 'package:flutter/material.dart'; +import 'package:infrastructure/_core/commons/extensions/context.ext.dart'; +import 'package:infrastructure/_core/commons/extensions/double.ext.dart'; +import 'package:infrastructure/analytics/failure/failure.dart'; +import 'package:infrastructure/presentation/cubits/paginated_list.cubit.dart'; +import 'package:infrastructure/presentation/models/paginated.model.dart'; +import 'package:infrastructure/presentation/super_class.dart'; + +typedef OnLocalFilter = bool Function(T item)?; +typedef OnNextPage = void Function(PaginatedListCubit bloc, int page); +typedef ItemBuilder = Widget Function( + BuildContext context, int index, T? item); + +class PaginatedList> extends StatefulWidget { + const PaginatedList({ + required this.itemBuilder, + this.onNextPage, + this.bloc, + super.key, + this.page = 1, + this.limit = 20, + this.onLocalFilter, + }); + + final C? bloc; + final ItemBuilder itemBuilder; + final int limit; + final int page; + final OnLocalFilter onLocalFilter; + final OnNextPage? onNextPage; + + @override + State> createState() => _PaginatedListState(); +} + +class _PaginatedListState> + extends State> { + @override + Widget build(BuildContext context) { + return BlocListener>( + listener: _handleListener, + bloc: _bloc, + child: PagedListView( + padding: ($.context.mqPadding.bottom + $.paddings.md).bottom, + pagingController: _pagingController, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (BuildContext ctx, T item, int index) { + return widget.itemBuilder(ctx, index, item); + }, + firstPageProgressIndicatorBuilder: (_) { + return SizedBox.shrink( + child: ListView.builder( + padding: EdgeInsets.zero, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (BuildContext ctx, int index) { + return widget.itemBuilder(ctx, -1, null); + }, + itemCount: 20, + ), + ); + }, + newPageProgressIndicatorBuilder: (BuildContext ctx) { + return widget.itemBuilder(ctx, -1, null); + }, + noItemsFoundIndicatorBuilder: (_) => const Placeholder(), + animateTransitions: true, + ), + ), + ); + } + + late final C _bloc = widget.bloc ?? $.get(); + late final PagingController _pagingController = + PagingController( + firstPageKey: widget.page, + ); + + @override + void initState() { + super.initState(); + + _pagingController.addPageRequestListener(_onPageRequest); + } + + void _onPageRequest(int page) { + if (widget.onNextPage != null) { + widget.onNextPage?.call(_bloc, page); + } + + unawaited(_bloc.fetch(limit: widget.limit, page: page)); + } + + @override + void dispose() { + _pagingController.dispose(); + super.dispose(); + } + + void _handleListener(BuildContext _, PaginatedListState state) { + switch (state) { + case PaginatedListStateRefresh(): + _pagingController.refresh(); + + case PaginatedListStateFailed(:final Failure failure): + _pagingController.error = failure.message; + + case PaginatedListStateLoaded(:final PaginatedModel data): + final List newItems = widget.onLocalFilter == null + ? data.items + : data.items + .where((T item) => !widget.onLocalFilter!(item)) + .toList(); + final bool isLastPage = newItems.length < widget.limit; + + if (isLastPage) { + _pagingController.appendLastPage(newItems); + } else { + final int nextPageKey = (_pagingController.nextPageKey ?? 0) + 1; + _pagingController.appendPage(newItems, nextPageKey); + } + } + } +} diff --git a/infrastructure/lib/presentation/widgets/transparent_pointer.dart b/infrastructure/lib/presentation/widgets/transparent_pointer.dart new file mode 100644 index 0000000..27f8e28 --- /dev/null +++ b/infrastructure/lib/presentation/widgets/transparent_pointer.dart @@ -0,0 +1,113 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: prefer-boolean-prefixes, prefer-single-declaration-per-file, avoid-mutating-parameters + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// This widget is invisible for its parent to hit testing, but still +/// allows its subtree to receive pointer events. +/// +/// {@tool snippet} +/// +/// In this example, a drag can be started anywhere in the widget, including on +/// top of the text button, even though the button is visually in front of the +/// background gesture detector. At the same time, the button is tappable. +/// +/// ```dart +/// class MyWidget extends StatelessWidget { +/// @override +/// Widget build(BuildContext context) { +/// return Stack( +/// children: [ +/// GestureDetector( +/// behavior: HitTestBehavior.opaque, +/// onVerticalDragStart: (_) => print("Background drag started"), +/// ), +/// Positioned( +/// top: 60, +/// left: 60, +/// height: 60, +/// width: 60, +/// child: TransparentPointer( +/// child: TextButton( +/// child: Text("Tap me"), +/// onPressed: () => print("You tapped me"), +/// ), +/// ), +/// ), +/// ], +/// ); +/// } +/// } +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [IgnorePointer], which is also invisible for its parent during hit testing, but +/// does not allow its subtree to receive pointer events. +/// * [AbsorbPointer], which is visible during hit testing, but prevents its subtree +/// from receiving pointer event. The opposite of this widget. +class TransparentPointer extends SingleChildRenderObjectWidget { + /// Creates a widget that is invisible for its parent to hit testing, but still + /// allows its subtree to receive pointer events. + const TransparentPointer({ + required super.child, + this.transparent = true, + super.key, + }); + + /// Whether this widget is invisible to its parent during hit testing. + final bool transparent; + + @override + RenderCoreTransparentPointer createRenderObject(BuildContext context) { + return RenderCoreTransparentPointer(transparent: transparent); + } + + @override + void updateRenderObject( + BuildContext context, RenderCoreTransparentPointer renderObject) { + renderObject.transparent = transparent; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('transparent', transparent)); + } +} + +class RenderCoreTransparentPointer extends RenderProxyBox { + RenderCoreTransparentPointer({RenderBox? child, bool transparent = true}) + : _transparent = transparent, + super(child); + + bool get transparent => _transparent; + bool _transparent; + + set transparent(bool value) { + if (value == _transparent) { + return; + } + _transparent = value; + } + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + final bool hit = super.hitTest(result, position: position); + + return !transparent && hit; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('transparent', transparent)); + } +} diff --git a/infrastructure/lib/presentation/widgets/widget_fader.dart b/infrastructure/lib/presentation/widgets/widget_fader.dart new file mode 100644 index 0000000..25b22f6 --- /dev/null +++ b/infrastructure/lib/presentation/widgets/widget_fader.dart @@ -0,0 +1,180 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +/// A widget that provides fade-in and fade-out animations for its child widget. +/// +/// The [WidgetFader] can be controlled manually using the [FadeInController]. +/// You can trigger fade-in or fade-out animations, specify the animation duration, +/// and customize the curve of the animation. +class WidgetFader extends StatefulWidget { + /// A controller to manually control the fade-in and fade-out actions. + final FadeInController? controller; + + /// The widget to apply the fade effect to. + final Widget? child; + + /// The duration of the fade animation. Defaults to 250 milliseconds. + final Duration duration; + + /// The animation curve for the fade transition. Defaults to [Curves.easeIn]. + final Curve curve; + + const WidgetFader({ + super.key, + this.controller, + this.child, + this.duration = const Duration(milliseconds: 250), + this.curve = Curves.easeIn, + }); + + @override + State createState() => _FadeInState(); +} + +/// The available actions for the [FadeInController]. +/// +/// You can either trigger a fade-in or fade-out animation. +enum FadeInAction { + fadeIn, + fadeOut, +} + +/// A controller to manage the fade-in and fade-out actions of the [WidgetFader]. +/// +/// Use the [fadeIn] and [fadeOut] methods to control the fade animations. You can +/// also set [autoStart] to `true` to trigger the fade-in automatically on initialization. +class FadeInController { + /// Internal stream controller to handle fade-in and fade-out events. + final StreamController _streamController = + StreamController(); + + /// If set to true, the widget will automatically start the fade-in animation upon initialization. + final bool autoStart; + + FadeInController({this.autoStart = false}); + + /// Disposes the controller and closes the internal stream. + void dispose() => _streamController.close(); + + /// Triggers the fade-in animation. + void fadeIn() => run(FadeInAction.fadeIn); + + /// Triggers the fade-out animation. + void fadeOut() => run(FadeInAction.fadeOut); + + /// Sends the provided action (fade-in or fade-out) to the stream. + void run(FadeInAction action) => _streamController.add(action); + + /// Provides a stream of [FadeInAction]s to listen for fade-in and fade-out events. + Stream get stream => _streamController.stream; +} + +class _FadeInState extends State with TickerProviderStateMixin { + late AnimationController _controller; + StreamSubscription? _listener; + + @override + void initState() { + super.initState(); + + // Initialize the animation controller with the provided duration. + _controller = AnimationController( + vsync: this, + duration: widget.duration, + ); + + // Set up the curve for the animation. + _setupCurve(); + + // Automatically start fade-in if autoStart is enabled. + if (widget.controller?.autoStart ?? true) { + fadeIn(); + } + + // Listen for actions from the controller. + _listen(); + } + + /// Configures the curve for the fade animation. + void _setupCurve() { + final CurvedAnimation curve = + CurvedAnimation(parent: _controller, curve: widget.curve); + + Tween( + begin: 0, + end: 1, + ).animate(curve); + } + + /// Subscribes to the [FadeInController] to listen for fade-in and fade-out actions. + void _listen() { + // Cancel any previous listener. + _listener?.cancel(); + _listener = null; + + // Listen to the stream if a controller is provided. + if (widget.controller != null) { + _listener = widget.controller!.stream.listen(_onAction); + } + } + + /// Handles the actions triggered by the [FadeInController]. + /// + /// Depending on the action, it will either start a fade-in or a fade-out. + void _onAction(FadeInAction action) { + switch (action) { + case FadeInAction.fadeIn: + fadeIn(); + case FadeInAction.fadeOut: + fadeOut(); + } + } + + @override + void didUpdateWidget(WidgetFader oldWidget) { + super.didUpdateWidget(oldWidget); + + // Re-subscribe to the controller if it has changed. + if (oldWidget.controller != widget.controller) { + _listen(); + } + + // Update the duration of the animation if it has changed. + if (oldWidget.duration != widget.duration) { + _controller.duration = widget.duration; + } + + // Update the curve if it has changed. + if (oldWidget.curve != widget.curve) { + _setupCurve(); + } + } + + @override + void dispose() { + _controller.dispose(); + _listener?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _controller, + child: widget.child, + ); + } + + /// Starts the fade-in animation. + void fadeIn() => _controller.forward(); + + /// Starts the fade-out animation. + void fadeOut() => _controller.reverse(); +} diff --git a/infrastructure/lib/presentation/wrappers/_core/app_settings.dart b/infrastructure/lib/presentation/wrappers/_core/app_settings.dart new file mode 100644 index 0000000..a87235d --- /dev/null +++ b/infrastructure/lib/presentation/wrappers/_core/app_settings.dart @@ -0,0 +1,70 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +/// The [AppSettings] class holds various configuration options for the app's +/// runtime behavior, particularly useful for debugging and diagnostics. +/// +/// These settings are used to control aspects of the app's visual representation +/// and performance monitoring during development. +final class AppSettings { + /// Creates an instance of [AppSettings] with optional configurations. + /// + /// * [title]: The title of the application. + /// * [debugShowMaterialGrid]: Whether to display a baseline grid overlay for debugging material design. + /// * [showPerformanceOverlay]: Whether to display a performance overlay in the app. + /// * [checkerboardRasterCacheImages]: Whether to checkerboard images that are cached in the raster cache. + /// * [checkerboardOffscreenLayers]: Whether to checkerboard layers rendered offscreen. + /// * [showSemanticsDebugger]: Whether to display the semantics debugger overlay. + /// * [debugShowCheckedModeBanner]: Whether to display the "debug" banner in debug mode. + const AppSettings({ + this.title = '', + this.debugShowMaterialGrid = false, + this.showPerformanceOverlay = false, + this.checkerboardRasterCacheImages = false, + this.checkerboardOffscreenLayers = false, + this.showSemanticsDebugger = false, + this.debugShowCheckedModeBanner = true, + }); + + /// The title of the application, usually displayed in the system UI or app bar. + final String title; + + /// Whether to display a grid overlay to help debug the layout of material design components. + /// + /// This is useful for ensuring that your app follows material design guidelines + /// and the components are aligned correctly. + final bool debugShowMaterialGrid; + + /// Whether to show a performance overlay on the screen. + /// + /// The performance overlay displays graphical information about the app's rendering performance, + /// which helps in identifying performance bottlenecks during development. + final bool showPerformanceOverlay; + + /// Whether to checkerboard images that are cached in the raster cache. + /// + /// Checkerboarding is a visual debugging tool that shows areas where offscreen images are being cached. + /// When this is enabled, images stored in the raster cache will be overlaid with a checkerboard pattern. + final bool checkerboardRasterCacheImages; + + /// Whether to checkerboard layers rendered offscreen. + /// + /// Similar to [checkerboardRasterCacheImages], this setting overlays a checkerboard pattern + /// on layers that are rendered offscreen, providing a visual indication for debugging purposes. + final bool checkerboardOffscreenLayers; + + /// Whether to display the semantics debugger overlay. + /// + /// The semantics debugger visually shows the semantics information associated with widgets in the app, + /// which helps in testing and ensuring proper accessibility support for screen readers and other assistive technologies. + final bool showSemanticsDebugger; + + /// Whether to display the "debug" banner in debug mode. + /// + /// The debug banner appears in the top-right corner of the app when running in debug mode, + /// and this flag controls whether the banner is shown. + final bool debugShowCheckedModeBanner; +} diff --git a/infrastructure/lib/presentation/wrappers/_core/observer_settings.dart b/infrastructure/lib/presentation/wrappers/_core/observer_settings.dart new file mode 100644 index 0000000..fbdb310 --- /dev/null +++ b/infrastructure/lib/presentation/wrappers/_core/observer_settings.dart @@ -0,0 +1,48 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/talker_bloc_logger.dart'; +import 'package:deps/packages/talker_dio_logger.dart'; + +/// [ObserverSettings] configures various observers that monitor and log +/// events in the application, including Bloc, Dio, and Router events. +final class ObserverSettings { + /// Constructs an instance of [ObserverSettings]. + /// + /// * [isBlocObserverEnabled]: Enables the Bloc observer, which logs + /// state changes in Cubit and Bloc classes. + /// * [blocSettings]: Configuration settings for Bloc observer logging. + /// * [isDioObserverEnabled]: Enables the Dio observer, which logs HTTP + /// requests and responses. + /// * [dioSettings]: Configuration settings for Dio observer logging. + /// * [isRouterObserverEnabled]: Enables the Router observer to log navigation + /// events. + const ObserverSettings({ + this.isBlocObserverEnabled = true, + this.blocSettings = const TalkerBlocLoggerSettings(), + this.isDioObserverEnabled = true, + this.dioSettings = const TalkerDioLoggerSettings(), + this.isRouterObserverEnabled = true, + }); + + /// Settings used to configure logging behavior for Bloc state changes. + final TalkerBlocLoggerSettings blocSettings; + + /// Settings used to configure logging behavior for Dio HTTP requests and responses. + final TalkerDioLoggerSettings dioSettings; + + /// Whether the Bloc observer should be enabled for monitoring Bloc and Cubit + /// state changes. + final bool isBlocObserverEnabled; + + /// Whether the Dio observer should be enabled for monitoring HTTP requests + /// and responses. + final bool isDioObserverEnabled; + + /// Whether the Router observer should be enabled for monitoring navigation + /// events within the app. + final bool isRouterObserverEnabled; +} diff --git a/infrastructure/lib/presentation/wrappers/app_wrapper.dart b/infrastructure/lib/presentation/wrappers/app_wrapper.dart new file mode 100644 index 0000000..f533e07 --- /dev/null +++ b/infrastructure/lib/presentation/wrappers/app_wrapper.dart @@ -0,0 +1,139 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: max_lines_for_function, avoid-dynamic + +import 'package:deps/design/design.dart'; +import 'package:deps/features/features.dart'; +import 'package:deps/infrastructure/infrastructure.dart'; +import 'package:deps/packages/adaptive_theme.dart'; +import 'package:deps/packages/flutter_bloc.dart'; +import 'package:deps/packages/talker_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:infrastructure/_core/_i18n/translations.g.dart'; +import 'package:infrastructure/_core/commons/converters/bloc_to_listenable.converter.dart'; +import 'package:infrastructure/presentation/wrappers/_core/app_settings.dart'; + +/// The [AppWrapper] widget is responsible for setting up the entire +/// application structure including theme, routing, and connectivity +/// management. It integrates various Bloc and Cubit providers to manage +/// global state across the app. +class AppWrapper extends StatelessWidget { + /// Constructs the [AppWrapper] widget. + /// + /// [appSettings] contains the core settings for the application such as + /// performance overlays and debug flags. [isRouterObserverEnabled] determines + /// whether navigation observers should be enabled. [savedThemeMode] is used to + /// restore the user's theme preferences. + const AppWrapper({ + required this.appSettings, + required this.isRouterObserverEnabled, + this.savedThemeMode, + super.key, + }); + + /// The settings for the application including debug settings and title. + final AppSettings appSettings; + + /// Flag to enable or disable router observers, used to monitor navigation events. + final bool isRouterObserverEnabled; + + /// The saved theme mode that will be applied when the app starts. + final AdaptiveThemeMode? savedThemeMode; + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: >[ + // Provides the [ConnectivityCubit] for monitoring network connectivity. + BlocProvider( + create: (_) => $.get()), + + // Provides the [TranslationsCubit] for handling localization state. + BlocProvider( + create: (_) => $.get()), + ], + child: BlocListener( + // Handles changes in network connectivity status. + listener: _handleConnectivityStatusChange, + child: BlocBuilder( + // Rebuilds the app when the locale changes. + builder: (_, Locale locale) { + return AdaptiveTheme( + light: Themes.light, + dark: Themes.light, + initial: savedThemeMode ?? AdaptiveThemeMode.light, + builder: (ThemeData light, ThemeData dark) { + return MaterialApp.router( + routerConfig: $.get().config( + // Converts AuthCubit stream into a listenable for router reevaluation. + reevaluateListenable: + BlocToListenableConverter( + $.get().stream), + navigatorObservers: () => isRouterObserverEnabled + ? [ + RouterTalkerObserver(talker: $.get()), + HeroController(), + ] + : [], + ), + builder: (BuildContext ctx, Widget? child) { + return MediaQuery.withNoTextScaling( + child: child ?? const SizedBox(), + ); + }, + title: appSettings.title, + theme: light, + darkTheme: dark, + locale: locale, + localizationsDelegates: GlobalMaterialLocalizations.delegates, + supportedLocales: AppLocaleUtils.supportedLocales, + debugShowMaterialGrid: appSettings.debugShowMaterialGrid, + showPerformanceOverlay: appSettings.showPerformanceOverlay, + checkerboardRasterCacheImages: + appSettings.checkerboardRasterCacheImages, + checkerboardOffscreenLayers: + appSettings.checkerboardOffscreenLayers, + showSemanticsDebugger: appSettings.showSemanticsDebugger, + debugShowCheckedModeBanner: + appSettings.debugShowCheckedModeBanner, + ); + }, + ); + }, + ), + ), + ); + } + + /// Handles changes in the network connectivity status. + /// + /// Depending on the [ConnectivityStatusEnum], dialogs or other UI + /// can be updated to reflect connectivity state. + /// + /// Currently, the method is set to handle different states with comments + /// for adding more functionality in the future. + void _handleConnectivityStatusChange(_, ConnectivityStatusEnum state) { + switch (state) { +/* case ConnectivityStatusEnum.connected: + $.dialog.popDialog(); // Example of handling connected state. + + case ConnectivityStatusEnum.disconnected: + unawaited( + $.dialog.showSheet( + builder: (_) => const SizedBox( + height: 50, + child: Center(child: Text('Connected')), // Example of showing a sheet. + ), + ), + ); + */ + default: + break; + } + } +} diff --git a/infrastructure/lib/presentation/wrappers/super_wrapper.dart b/infrastructure/lib/presentation/wrappers/super_wrapper.dart new file mode 100644 index 0000000..f41ddf6 --- /dev/null +++ b/infrastructure/lib/presentation/wrappers/super_wrapper.dart @@ -0,0 +1,84 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: prefer-boolean-prefixes, prefer-extracting-function-callbacks + +import 'package:deps/infrastructure/infrastructure.dart'; +import 'package:deps/packages/auto_route.dart'; +import 'package:deps/packages/back_button_interceptor.dart'; +import 'package:flutter/material.dart'; + +/// A [SuperWrapper] is a base wrapper widget that uses the [AutoRouter] +/// for handling navigation within the app. It provides a base layout +/// with a scaffold and handles back button interception. +/// +/// The wrapper uses a global scaffold and navigation key to manage the +/// routing and dialogs, ensuring that focus is dismissed when interacting +/// outside of input fields. +@RoutePage() +class SuperWrapper extends StatefulWidget { + const SuperWrapper({super.key}); + + @override + State createState() => _SuperHandlerState(); +} + +/// The state handler for the [SuperWrapper] widget. +/// +/// It sets up back button interception to handle dialogs and +/// ensures proper navigation behavior. +class _SuperHandlerState extends State { + /// Builds the [AutoRouter] for navigation, wrapped in a [Scaffold] + /// to use keys for global context and scaffold access. + @override + Widget build(BuildContext context) { + return AutoRouter( + key: $.navigator.autoRouterKey, + builder: (_, Widget child) { + return Scaffold( + key: $.navigator.scaffoldKey, + backgroundColor: Colors.black, + body: child, + resizeToAvoidBottomInset: false, + ); + }, + navigatorKey: $.navigator.navigatorKey, + ); + } + + /// Adds a back button interceptor when the widget is initialized to + /// handle back button presses, specifically intercepting if a dialog + /// is visible and dismissing it instead of navigating back. + @override + void initState() { + super.initState(); + BackButtonInterceptor.add((_, __) => _shouldIntercept()); + } + + /// Removes the back button interceptor when the widget is disposed of, + /// ensuring no memory leaks or unnecessary back button handling. + @override + void dispose() { + BackButtonInterceptor.remove((_, __) => _shouldIntercept()); + super.dispose(); + } + + /// Determines whether the back button should be intercepted. + /// + /// If a dialog is visible, it will close the dialog and prevent the + /// default back navigation behavior. Otherwise, it allows normal back + /// navigation. + /// + /// Returns `true` if the dialog is intercepted, otherwise `false`. + bool _shouldIntercept() { + if ($.dialog.hasDialogVisible) { + $.dialog.popDialog(); + return true; + } + + return false; + } +} diff --git a/infrastructure/lib/storage/caches/i_cache.dart b/infrastructure/lib/storage/caches/i_cache.dart new file mode 100644 index 0000000..f3bb952 --- /dev/null +++ b/infrastructure/lib/storage/caches/i_cache.dart @@ -0,0 +1,34 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +/// An abstract interface for cache storage, allowing objects to be stored, +/// retrieved, and removed based on a unique key. Any class implementing this +/// interface should handle the storage of objects in a way that maintains type +/// safety. +/// +/// Generic types are used to allow for flexibility in the kind of objects +/// that can be cached. +abstract interface class ICache { + /// Writes an object of type [T] to the cache. + /// + /// - [key]: A unique string identifier used to store and retrieve the object. + /// - [value]: The object to store, which must extend [Object]. + void write({required String key, required T value}); + + /// Reads and returns an object of type [T] from the cache. + /// + /// - [key]: The unique string identifier of the object to retrieve. + /// - Returns the cached object if found, or `null` if no object is stored + /// with the specified [key] or if the stored object is not of type [T]. + T? read({required String key}); + + /// Removes and returns an object of type [T] from the cache. + /// + /// - [key]: The unique string identifier of the object to remove. + /// - Returns the removed object if found, or `null` if no object is stored + /// with the specified [key] or if the stored object is not of type [T]. + T? remove({required String key}); +} diff --git a/infrastructure/lib/storage/caches/in_memory_cache.dart b/infrastructure/lib/storage/caches/in_memory_cache.dart new file mode 100755 index 0000000..6c374f0 --- /dev/null +++ b/infrastructure/lib/storage/caches/in_memory_cache.dart @@ -0,0 +1,70 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/injectable.dart'; +import 'package:infrastructure/storage/caches/i_cache.dart'; + +/// An in-memory implementation of the [ICache] interface, storing cached objects +/// in a map. This class provides basic functionality for caching objects +/// temporarily within the app's memory during runtime. +/// +/// The cached data is stored in a [Map] using key-value pairs, and will be +/// discarded when the app is restarted or closed. +/// +/// This class is marked as [Injectable] so it can be easily managed by a +/// dependency injection system. +@Injectable(as: ICache) +class InMemoryCache implements ICache { + InMemoryCache(); + + /// The map that holds the cached data in memory. Keys are [String], and + /// values are any object that extends [Object]. + Map _map = {}; + + /// Retrieves a value from the cache, associated with the given [key]. If the + /// value exists and is of type [T], it is returned. Otherwise, `null` is returned. + /// + /// - [key]: The unique string identifier used to retrieve the object. + /// - Returns: The cached object if found and it matches type [T], otherwise `null`. + @override + T? read({required String key}) { + final Object? value = _map[key]; + return value is T ? value : null; + } + + /// Removes a value from the cache, associated with the given [key]. If the + /// value exists and is of type [T], it is removed and returned. Otherwise, `null` + /// is returned. + /// + /// - [key]: The unique string identifier used to remove the object. + /// - Returns: The removed object if found and it matches type [T], otherwise `null`. + @override + T? remove({required String key}) { + final Object? value = _map[key]; + + if (value is T) { + final Map newMap = {..._map}..remove(key); + _map = newMap; // Reassigning to ensure immutability. + + return value; + } + + return null; + } + + /// Writes a value to the cache, associating it with the given [key]. If a value + /// with the same [key] already exists, it is overwritten. + /// + /// - [key]: The unique string identifier used to store the object. + /// - [value]: The object to be cached. + @override + void write({required String key, required T value}) { + _map = { + ..._map, + key: value + }; // Ensures immutability by creating a new map. + } +} diff --git a/infrastructure/lib/storage/file_storage/i_file_storage.dart b/infrastructure/lib/storage/file_storage/i_file_storage.dart new file mode 100644 index 0000000..a11fad2 --- /dev/null +++ b/infrastructure/lib/storage/file_storage/i_file_storage.dart @@ -0,0 +1,25 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'dart:async'; + +/// The [IFileStorage] interface provides a contract for file storage operations +/// such as reading, writing, and deleting data. It is a generic interface +/// where [T] represents the type of the object being stored. +abstract class IFileStorage { + /// Deletes the stored data. + /// + /// This method removes the stored data associated with the storage. + Future delete(); + + /// Reads the stored data and returns an instance of [T]. + /// + /// Returns `null` if no data is found or if an error occurs. + Future read(); + + /// Writes the given [value] of type [T] to the storage. + Future write(T value); +} diff --git a/infrastructure/lib/storage/file_storage/token/secure_token_file_storage.dart b/infrastructure/lib/storage/file_storage/token/secure_token_file_storage.dart new file mode 100644 index 0000000..66968dc --- /dev/null +++ b/infrastructure/lib/storage/file_storage/token/secure_token_file_storage.dart @@ -0,0 +1,63 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'dart:convert'; + +import 'package:deps/packages/flutter_secure_storage.dart'; +import 'package:deps/packages/injectable.dart'; +import 'package:infrastructure/networking/models/token.model.dart'; +import 'package:infrastructure/storage/file_storage/i_file_storage.dart'; + +/// An implementation of [IFileStorage] for securely storing the [TokenModel] +/// in the device's secure storage. This class uses [FlutterSecureStorage] +/// to store, read, and delete token data in a secure manner. +/// +/// The data is stored as a JSON string, which can be encrypted and stored +/// using platform-specific secure storage mechanisms such as the iOS Keychain +/// and Android Keystore. +@LazySingleton(as: IFileStorage) +class SecureTokenFileStorage extends IFileStorage { + /// Constructs a [SecureTokenFileStorage] with a given [FlutterSecureStorage] + /// instance. + SecureTokenFileStorage(this._secureStorage); + + /// The key under which the token data is stored in secure storage. + final String _key = 'secure_token_storage'; + + /// The secure storage mechanism used to store the token. + final FlutterSecureStorage _secureStorage; + + /// Deletes the stored token from secure storage. + @override + Future delete() async { + await _secureStorage.delete(key: _key); + } + + /// Reads the stored token from secure storage. + /// + /// Returns a [TokenModel] if the token is successfully retrieved and + /// deserialized. Returns `null` if no token is found or if an error occurs. + @override + Future read() async { + try { + final String? token = await _secureStorage.read(key: _key); + + if (token != null) { + return TokenModel.fromJson(jsonDecode(token)); + } + + return null; + } catch (exception) { + return null; + } + } + + /// Writes the [TokenModel] to secure storage after converting it to a JSON string. + @override + Future write(TokenModel value) async { + await _secureStorage.write(key: _key, value: jsonEncode(value.toJson())); + } +} diff --git a/infrastructure/lib/storage/file_storage/token/token_file_storage_mixin.dart b/infrastructure/lib/storage/file_storage/token/token_file_storage_mixin.dart new file mode 100644 index 0000000..7f61a46 --- /dev/null +++ b/infrastructure/lib/storage/file_storage/token/token_file_storage_mixin.dart @@ -0,0 +1,97 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'dart:async'; + +import 'package:infrastructure/_core/commons/enums/auth_status.enum.dart'; +import 'package:infrastructure/storage/file_storage/i_file_storage.dart'; + +/// A mixin to handle token storage, retrieval, and authentication status +/// management. This mixin abstracts the storage of token-like objects and +/// allows you to work with an authentication state that is broadcasted via +/// a stream. +/// +/// [T] represents the token type (e.g., [TokenModel]). +mixin TokenFileStorageMixin { + /// A [StreamController] to broadcast changes in authentication status. + final StreamController _controller = + StreamController.broadcast()..add(AuthStatusEnum.initial); + + /// The current authentication status. + AuthStatusEnum _authStatus = AuthStatusEnum.initial; + + /// The current token stored in memory. + T? _token; + + /// The storage mechanism for storing tokens. + IFileStorage? _tokenStorage; + + /// Retrieves the token asynchronously. If the authentication status is + /// not [AuthStatusEnum.initial], the cached token is returned. + /// + /// Otherwise, waits for the auth status to update and returns the token. + Future get token async { + if (_authStatus != AuthStatusEnum.initial) { + return _token; + } + await authStatus.first; + return _token; + } + + /// A stream of the current authentication status. + Stream get authStatus async* { + yield _authStatus; + yield* _controller.stream; + } + + /// Clears the stored token and updates the authentication status to unauthenticated. + Future clearToken() async { + await _tokenStorage?.delete(); + _updateStatus(null); + } + + /// Closes the stream controller. + Future close() => _controller.close(); + + /// Revokes the stored token and updates the authentication status to unauthenticated. + Future revokeToken() async { + await _tokenStorage?.delete(); + if (_authStatus != AuthStatusEnum.unauthenticated) { + _authStatus = AuthStatusEnum.unauthenticated; + _controller.add(_authStatus); + } + } + + /// Sets the token and stores it. If the token is `null`, it clears the stored token. + Future setToken(T? value) async { + if (value == null) { + return clearToken(); + } + await _tokenStorage?.write(value); + _updateStatus(value); + } + + /// Sets the token storage mechanism and initializes the stored token. + set tokenStorage(IFileStorage value) { + _tokenStorage = value; + unawaited(_initTokenStorage()); + } + + /// Initializes the token storage by reading the stored token and updating the status. + Future _initTokenStorage() async { + final T? result = await _tokenStorage?.read(); + _updateStatus(result); + } + + /// Updates the authentication status based on the provided token. + void _updateStatus(T? newToken) { + _authStatus = newToken == null + ? AuthStatusEnum.unauthenticated + : AuthStatusEnum.authenticated; + _token = newToken; + _controller.add(_authStatus); + } +} diff --git a/infrastructure/lib/translations/translations.cubit.dart b/infrastructure/lib/translations/translations.cubit.dart new file mode 100644 index 0000000..e671080 --- /dev/null +++ b/infrastructure/lib/translations/translations.cubit.dart @@ -0,0 +1,54 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +import 'package:deps/packages/hydrated_bloc.dart'; +import 'package:deps/packages/injectable.dart'; +import 'package:flutter/material.dart'; +import 'package:infrastructure/_core/_i18n/translations.g.dart'; + +/// A `Cubit` class responsible for managing locale (language) state in the application. +/// +/// This class extends `HydratedCubit` to persist the locale across app restarts. +/// It uses `HydratedCubit` to automatically save and restore the selected locale from storage. +/// +/// The `@lazySingleton` annotation ensures that this class is registered as a singleton and +/// can be injected throughout the app, meaning that only one instance will be created. +@lazySingleton +class TranslationsCubit extends HydratedCubit { + /// Initializes the `TranslationsCubit` with the device's locale by default. + /// + /// The initial locale is determined using `AppLocaleUtils.findDeviceLocale()`, which + /// fetches the device's locale and converts it into a Flutter `Locale` object. + TranslationsCubit() : super(AppLocaleUtils.findDeviceLocale().flutterLocale); + + /// Deserializes the stored locale from JSON when the app is restarted. + /// + /// If the `locale` key is present in the JSON map, it is used to create a `Locale` object. + /// Otherwise, it returns `null`, meaning no locale is restored. + @override + Locale? fromJson(Map json) { + return json['locale'] == null ? null : Locale(json['locale']); + } + + /// Serializes the current locale to JSON for storage. + /// + /// The locale is stored as a string, which is later used for restoring the locale state + /// after app restarts. + @override + Map toJson(Locale state) { + return {'locale': state.toString()}; + } + + /// Sets the application's locale to English and emits the new locale state. + /// + /// This method updates the locale state to `AppLocale.en`, which corresponds to English. + void setEN() => emit(AppLocale.en.flutterLocale); + + /// Sets the application's locale to Turkish and emits the new locale state. + /// + /// This method updates the locale state to `AppLocale.tr`, which corresponds to Turkish. + void setTR() => emit(AppLocale.tr.flutterLocale); +} diff --git a/infrastructure/pubspec.yaml b/infrastructure/pubspec.yaml new file mode 100644 index 0000000..68e5223 --- /dev/null +++ b/infrastructure/pubspec.yaml @@ -0,0 +1,30 @@ +name: infrastructure +description: The infrastructure of the flutter advanced boilerplate. +publish_to: none +version: 0.0.1 + +environment: + sdk: ">=3.5.3 <4.0.0" + +dependencies: + deps: + path: ../deps + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + flutter_web_plugins: + sdk: flutter + json_annotation: ^4.9.0 + meta: ^1.15.0 + plugin_platform_interface: ^2.1.8 + +dev_dependencies: + auto_route_generator: ^9.0.0 + build_runner: ^2.4.13 + dart_code_metrics_presets: ^2.16.0 + envied_generator: ^0.5.4+1 + freezed: ^2.5.7 + injectable_generator: ^2.6.2 + json_serializable: ^6.8.0 + slang_build_runner: ^3.31.0 diff --git a/infrastructure/pubspec_overrides.yaml b/infrastructure/pubspec_overrides.yaml new file mode 100644 index 0000000..958eb99 --- /dev/null +++ b/infrastructure/pubspec_overrides.yaml @@ -0,0 +1,12 @@ +# melos_managed_dependency_overrides: deps,design,feature_auth,feature_core,feature_user +dependency_overrides: + deps: + path: ../deps + design: + path: ../design + feature_auth: + path: ../features/auth + feature_core: + path: ../features/_core + feature_user: + path: ../features/user diff --git a/melos.yaml b/melos.yaml new file mode 100755 index 0000000..b00ab6a --- /dev/null +++ b/melos.yaml @@ -0,0 +1,201 @@ +# Copyright 2024 Thomas. All rights reserved. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file. +# + +name: fmbb + +repository: + type: github + origin: https://github.com/thomashoangvn/fvm_monorepo_bloc_boilerplate.git + owner: thomashoangvn + name: Flutter Monorepo BLoC Boilerplate + +# Specifies to use fvm (Flutter Version Management) for the Flutter SDK. +sdkPath: .fvm/flutter_sdk + +# Defines the project structure and the packages used in the workspace. +packages: + - app + - deps + - design + - features/** + - infrastructure + - widgetbook + +command: + version: + # Restricts versioning to the main branch only. + branch: main + # Enables links to commits in the changelogs. + linkToCommits: true + # Creates a changelog for the entire workspace at the root level. + workspaceChangelog: true + + bootstrap: + environment: + sdk: ">=3.5.3 <4.0.0" + flutter: ">=3.24.3 <4.0.0" + dependencies: + # List of dependencies for the project. + adaptive_theme: ^3.6.0 + auto_route: ^9.2.2 + back_button_interceptor: ^7.0.3 + cupertino_icons: ^1.0.8 + dio: ^5.7.0 + dio_smart_retry: ^6.0.0 + envied: ^0.5.4+1 + flutter_bloc: ^8.1.6 + flutter_native_splash: ^2.4.1 + flutter_secure_storage: ^9.2.2 + flutter_svg: ^2.0.10+1 + fpdart: ^1.1.0 + freezed_annotation: ^2.4.4 + get_it: ^7.7.0 + hydrated_bloc: ^9.1.5 + infinite_scroll_pagination: ^4.0.0 + injectable: ^2.4.4 + internet_connection_checker_plus: ^2.5.1 + intl: ^0.19.0 + json_annotation: ^4.9.0 + lottie: ^3.1.2 + universal_html: ^2.2.4 + package_info_plus: ^8.0.2 + path_provider: ^2.1.4 + permission_handler: ^11.3.1 + reactive_forms: ^17.0.1 + slang: ^3.31.2 + slang_flutter: ^3.31.0 + styled_text: ^8.1.0 + talker_bloc_logger: ^4.4.1 + talker_dio_logger: ^4.4.1 + talker_flutter: ^4.4.1 + theme_tailor_annotation: ^3.0.1 + widgetbook: ^4.0.0-alpha.2 + + dev_dependencies: + # List of dev dependencies used for generating code, metrics, etc. + auto_route_generator: ^9.0.0 + build_runner: ^2.4.13 + dart_code_metrics_presets: ^2.16.0 + envied_generator: ^0.5.4+1 + flutter_gen_runner: ^5.7.0 + freezed: ^2.5.7 + injectable_generator: ^2.6.2 + json_serializable: ^6.8.0 + slang_build_runner: ^3.31.0 + theme_tailor: ^3.0.1 + widgetbook_generator: ^4.0.0-alpha.2 + + # Allows running `pub get` in parallel. + runPubGetInParallel: true + + hooks: + pre: melos clean + post: melos generate && melos reset:ios + + clean: + hooks: + pre: melos reset + +# Scripts for various tasks like cleaning, generating code, running tests, etc. +scripts: + reset: + description: Clean things very deeply by removing untracked files. + run: | + melos exec --flutter -c 1 -- "flutter clean" + + reset:ios: + description: Clean ios pods to fix any pods related issues. + run: | + cd app/ios && \ + rm -Rf Pods 2>/dev/null || true && \ + rm Podfile.lock 2>/dev/null || true && \ + flutter precache --ios && \ + pod install --repo-update + + dart:check: + description: Run `dart` checks for all packages. + run: | + melos format -c 6 --line-length 120 --set-exit-if-changed . + melos analyze -c 6 --fatal-infos --fatal-warnings . + + dart:fix: + description: Run `dart` fix for all packages. + run: | + melos exec -c 6 --ignore="*example*" -- \ + dart format -l 120 . + dart analyze -fatal-infos --fatal-warnings . + + dcm:check: + description: Run `dart code metrics` checks for all packages. + run: | + melos exec -c 6 --ignore="*example*" -- \ + dcm format -l 120 --dry-run . + dcm analyze --fatal-style --fatal-performance --fatal-warnings . + + dcm:fix: + description: Run `dart code metrics` fix for all packages. + run: dcm fix -l 120 . + + generate: + description: Generate codes for all packages. + run: | + melos exec -c 1 --depends-on="build_runner" -- \ + dart run build_runner build -d + + generate:assets: + description: Generate asset codes for all packages. + run: | + melos exec -c 1 --depends-on="flutter_gen_runner" -- \ + dart run build_runner build -d + + # Tests + test: + description: Run all Flutter tests in this project. + run: melos run test:select --no-select + + test:select: + description: Run `flutter test` for selected packages. + run: melos exec -c 1 -- flutter test + packageFilters: + dirExists: test + + test:coverage: + description: Generate coverage for the selected package. + run: | + melos exec -c 4 --fail-fast -- \ + flutter test --coverage + + test:update-goldens: + description: Re-generate all golden test files. + run: melos exec -- "flutter test --update-goldens" + packageFilters: + dirExists: test + + # CI Scripts + review: + description: Review the codebase. + run: | + ./tools/scripts/review.sh + review:fix: + description: Fix the codebase. + run: | + melos dart:fix && \ + melos dcm:fix + + build:android: + description: Build Android APK. + run: ./tools/scripts/build_android.sh + + release:android: + description: Release Android APK. + run: ./tools/scripts/release_android.sh + + build:ios: + description: Build iOS IPA. + run: ./tools/scripts/build_ios.sh + + release:ios: + description: Release iOS IPA. + run: ./tools/scripts/release_ios.sh \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..b14067d --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,20 @@ +name: fmbb +description: "Flutter Monorepo Bloc Boilerplate" +publish_to: 'none' +version: 0.0.1 + +environment: + sdk: '>=3.5.3 <4.0.0' + +dev_dependencies: + dart_code_metrics_presets: ^2.16.0 + husky: ^0.1.7 + lint_staged: ^0.5.1 + melos: ^6.2.0 + +lint_staged: + # Exclude generated Dart files globally + '!**/*.g.dart': ignore + + # Apply commands to all Dart files in specified directories + '**/*.dart': fvm dart format -l 120 --fix && dart analyze \ No newline at end of file diff --git a/widgetbook/build.yaml b/widgetbook/build.yaml new file mode 100644 index 0000000..4a27f71 --- /dev/null +++ b/widgetbook/build.yaml @@ -0,0 +1,20 @@ +# Copyright 2024 Thomas. All rights reserved. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file. +# / + +# This `build.yaml` file is used to configure various code generation tools such as widgetbook_generator. +# This YAML file sets up the target configurations for Widgetbook experimental builders. + +targets: + $default: + builders: + # This enables the experimental story builder for Widgetbook. + # The story builder generates stories for your components which are used for design reviews and previews. + widgetbook_generator:experimental_story_builder: + enabled: true + + # This enables the experimental components builder for Widgetbook. + # The components builder generates the component previews that are used to document and interact with various UI elements. + widgetbook_generator:experimental_components_builder: + enabled: true \ No newline at end of file diff --git a/widgetbook/devtools_options.yaml b/widgetbook/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/widgetbook/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/widgetbook/lib/components.book.dart b/widgetbook/lib/components.book.dart new file mode 100644 index 0000000..77dd5ea --- /dev/null +++ b/widgetbook/lib/components.book.dart @@ -0,0 +1,9 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_import, prefer_relative_imports, directives_ordering +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'package:widgetbook/widgetbook.dart' as _i1; + +final components = <_i1.Component>[]; diff --git a/widgetbook/lib/main.dart b/widgetbook/lib/main.dart new file mode 100644 index 0000000..a0f2e01 --- /dev/null +++ b/widgetbook/lib/main.dart @@ -0,0 +1,91 @@ +// Copyright 2024 Thomas Hoang +// +// This source code is licensed under the MIT License found in the +// LICENSE file. +// + +// ignore_for_file: prefer-correct-type-names, always_specify_types + +import 'package:deps/infrastructure/infrastructure.dart'; +import 'package:deps/locator/locator.dart'; +import 'package:deps/packages/flutter_bloc.dart'; +import 'package:deps/packages/hydrated_bloc.dart'; +import 'package:design/_core/_constants/themes.dart'; +import 'package:design_widgetbook/components.book.dart'; +import 'package:flutter/material.dart'; +import 'package:widgetbook/widgetbook.dart' hide ThemeMode; + +/// The `main` function initializes the Flutter application and sets up services and storage. +/// +/// This function: +/// - Ensures that the widget binding is initialized. +/// - Caches assets for optimization (currently commented out for future implementation). +/// - Initializes the service locator for dependency injection with the 'dev' environment. +/// - Sets up persistent storage using `HydratedBloc`. +/// - Runs the application wrapped with `BlocProvider` to manage the app's localization state. +void main() async { + final WidgetsBinding binding = WidgetsFlutterBinding.ensureInitialized() + ..addPostFrameCallback((_) { + // TODO: Cache assets here. + // final BuildContext? context = binding.rootElement; + // for (final AssetGenImage image in $.images.values) { + // precacheImage(AssetImage(image.path, package: 'design'), context!); + // } + // for (final SvgGenImage icon in $.icons.values) { + // final SvgAssetLoader logo = SvgAssetLoader(icon.path, packageName: 'design'); + // svg.cache.putIfAbsent(logo.cacheKey(null), () => logo.loadBytes(null)); + // } + }); + + await initLocator('prod'); + + // Set up persistent storage for HydratedBloc, using web storage in this case. + HydratedBloc.storage = await HydratedStorage.build( + storageDirectory: HydratedStorage.webStorageDirectory, + ); + + // Run the Flutter app with a `BlocProvider` to manage translations. + runApp( + BlocProvider( + create: (BuildContext context) => $.get(), + child: const WidgetbookApp(), + ), + ); +} + +/// The `WidgetbookApp` class is a stateless widget that configures the `Widgetbook` package for the app. +/// +/// It defines the app's theme, the list of components for design review, and the device frames for +/// previewing the UI across different devices. +class WidgetbookApp extends StatelessWidget { + const WidgetbookApp({super.key}); + + @override + Widget build(BuildContext context) { + return Widgetbook( + components: components, + appBuilder: (_, Widget child) { + return MaterialApp( + debugShowCheckedModeBanner: false, + navigatorKey: $.navigator.navigatorKey, + theme: Themes.light, + themeMode: ThemeMode.light, + home: child, + ); + }, + addons: >[ + DeviceFrameAddon( + [ + Devices.android.samsungGalaxyA50, + Devices.android.samsungGalaxyS20, + Devices.ios.iPhoneSE, + Devices.ios.iPhone13ProMax, + ], + ), + GridAddon(100), + InspectorAddon(), + AlignmentAddon(), + ], + ); + } +} diff --git a/widgetbook/pubspec.yaml b/widgetbook/pubspec.yaml new file mode 100644 index 0000000..224c5b7 --- /dev/null +++ b/widgetbook/pubspec.yaml @@ -0,0 +1,31 @@ +name: design_widgetbook +description: The design system documentation of the project. +publish_to: none +version: 0.0.1 + +environment: + sdk: ">=3.5.3 <4.0.0" + +dependencies: + deps: + path: ../deps + design: + path: ../design + flutter: + sdk: flutter + widgetbook: ^4.0.0-alpha.2 + widgetbook_annotation: ^3.2.0 + +dependency_overrides: + flutter_secure_storage_web: + git: + url: https://github.com/fikretsengul/flutter_secure_storage.git + path: flutter_secure_storage_web + ref: develop + +dev_dependencies: + build_runner: ^2.4.13 + widgetbook_generator: ^4.0.0-alpha.2 + +flutter: + uses-material-design: true diff --git a/widgetbook/pubspec_overrides.yaml b/widgetbook/pubspec_overrides.yaml new file mode 100644 index 0000000..92039db --- /dev/null +++ b/widgetbook/pubspec_overrides.yaml @@ -0,0 +1,19 @@ +# melos_managed_dependency_overrides: deps,design,feature_auth,feature_core,feature_user,infrastructure,flutter_secure_storage_web +dependency_overrides: + deps: + path: ../deps + design: + path: ../design + feature_auth: + path: ../features/auth + feature_core: + path: ../features/_core + feature_user: + path: ../features/user + infrastructure: + path: ../infrastructure + flutter_secure_storage_web: + git: + url: https://github.com/fikretsengul/flutter_secure_storage.git + ref: develop + path: flutter_secure_storage_web diff --git a/widgetbook/web/favicon.png b/widgetbook/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/widgetbook/web/favicon.png differ diff --git a/widgetbook/web/icons/Icon-192.png b/widgetbook/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/widgetbook/web/icons/Icon-192.png differ diff --git a/widgetbook/web/icons/Icon-512.png b/widgetbook/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/widgetbook/web/icons/Icon-512.png differ diff --git a/widgetbook/web/icons/Icon-maskable-192.png b/widgetbook/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/widgetbook/web/icons/Icon-maskable-192.png differ diff --git a/widgetbook/web/icons/Icon-maskable-512.png b/widgetbook/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/widgetbook/web/icons/Icon-maskable-512.png differ diff --git a/widgetbook/web/index.html b/widgetbook/web/index.html new file mode 100644 index 0000000..fdbc503 --- /dev/null +++ b/widgetbook/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + widgetbook + + + + + + diff --git a/widgetbook/web/manifest.json b/widgetbook/web/manifest.json new file mode 100644 index 0000000..6bd71b6 --- /dev/null +++ b/widgetbook/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "widgetbook", + "short_name": "widgetbook", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +}