├── .firebaserc ├── .github ├── images │ └── time-tracker-screenshots.png └── workflows │ └── tests.yaml ├── .gitignore ├── .metadata ├── .vscode └── launch.json ├── LICENSE.md ├── README.md ├── analysis_options.yaml ├── android ├── .gitignore ├── app │ ├── build.gradle │ └── src │ │ ├── debug │ │ └── AndroidManifest.xml │ │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── kotlin │ │ │ └── com │ │ │ │ └── example │ │ │ │ └── starter_architecture_flutter_firebase │ │ │ │ └── MainActivity.kt │ │ └── res │ │ │ ├── drawable-v21 │ │ │ └── launch_background.xml │ │ │ ├── drawable │ │ │ └── launch_background.xml │ │ │ ├── mipmap-hdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-mdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xhdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xxhdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xxxhdpi │ │ │ └── ic_launcher.png │ │ │ ├── values-night │ │ │ └── styles.xml │ │ │ └── values │ │ │ └── styles.xml │ │ └── profile │ │ └── AndroidManifest.xml ├── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties └── settings.gradle ├── assets └── time-tracking.svg ├── firebase.json ├── ios ├── .gitignore ├── Flutter │ ├── AppFrameworkInfo.plist │ ├── Debug.xcconfig │ └── Release.xcconfig ├── Podfile ├── Podfile.lock ├── Runner.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── WorkspaceSettings.xcsettings │ └── xcshareddata │ │ └── xcschemes │ │ └── Runner.xcscheme ├── Runner.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── WorkspaceSettings.xcsettings └── Runner │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Icon-App-1024x1024@1x.png │ │ ├── Icon-App-20x20@1x.png │ │ ├── Icon-App-20x20@2x.png │ │ ├── Icon-App-20x20@3x.png │ │ ├── Icon-App-29x29@1x.png │ │ ├── Icon-App-29x29@2x.png │ │ ├── Icon-App-29x29@3x.png │ │ ├── Icon-App-40x40@1x.png │ │ ├── Icon-App-40x40@2x.png │ │ ├── Icon-App-40x40@3x.png │ │ ├── Icon-App-60x60@2x.png │ │ ├── Icon-App-60x60@3x.png │ │ ├── Icon-App-76x76@1x.png │ │ ├── Icon-App-76x76@2x.png │ │ └── Icon-App-83.5x83.5@2x.png │ └── LaunchImage.imageset │ │ ├── Contents.json │ │ ├── LaunchImage.png │ │ ├── LaunchImage@2x.png │ │ ├── LaunchImage@3x.png │ │ └── README.md │ ├── Base.lproj │ ├── LaunchScreen.storyboard │ └── Main.storyboard │ ├── Info.plist │ └── Runner-Bridging-Header.h ├── lib ├── main.dart └── src │ ├── app.dart │ ├── common_widgets │ ├── action_text_button.dart │ ├── async_value_widget.dart │ ├── avatar.dart │ ├── custom_text_button.dart │ ├── date_time_picker.dart │ ├── empty_content.dart │ ├── empty_placeholder_widget.dart │ ├── error_message_widget.dart │ ├── input_dropdown.dart │ ├── list_items_builder.dart │ ├── primary_button.dart │ ├── responsive_center.dart │ ├── responsive_scrollable_card.dart │ └── segmented_control.dart │ ├── constants │ ├── app_sizes.dart │ ├── breakpoints.dart │ ├── keys.dart │ └── strings.dart │ ├── features │ ├── authentication │ │ ├── data │ │ │ ├── firebase_auth_repository.dart │ │ │ └── firebase_auth_repository.g.dart │ │ ├── domain │ │ │ └── app_user.dart │ │ └── presentation │ │ │ ├── auth_providers.dart │ │ │ ├── auth_providers.g.dart │ │ │ ├── custom_profile_screen.dart │ │ │ └── custom_sign_in_screen.dart │ ├── entries │ │ ├── application │ │ │ ├── entries_service.dart │ │ │ └── entries_service.g.dart │ │ ├── data │ │ │ ├── entries_repository.dart │ │ │ └── entries_repository.g.dart │ │ ├── domain │ │ │ ├── daily_jobs_details.dart │ │ │ ├── entries_list_tile_model.dart │ │ │ ├── entry.dart │ │ │ └── entry_job.dart │ │ └── presentation │ │ │ ├── entries_screen.dart │ │ │ └── entry_screen │ │ │ ├── entry_screen.dart │ │ │ ├── entry_screen_controller.dart │ │ │ └── entry_screen_controller.g.dart │ ├── jobs │ │ ├── data │ │ │ ├── jobs_repository.dart │ │ │ └── jobs_repository.g.dart │ │ ├── domain │ │ │ └── job.dart │ │ └── presentation │ │ │ ├── edit_job_screen │ │ │ ├── edit_job_screen.dart │ │ │ ├── edit_job_screen_controller.dart │ │ │ ├── edit_job_screen_controller.g.dart │ │ │ └── job_submit_exception.dart │ │ │ ├── job_entries_screen │ │ │ ├── entry_list_item.dart │ │ │ ├── job_entries_list.dart │ │ │ ├── job_entries_list_controller.dart │ │ │ ├── job_entries_list_controller.g.dart │ │ │ └── job_entries_screen.dart │ │ │ └── jobs_screen │ │ │ ├── jobs_screen.dart │ │ │ ├── jobs_screen_controller.dart │ │ │ └── jobs_screen_controller.g.dart │ └── onboarding │ │ ├── data │ │ ├── onboarding_repository.dart │ │ └── onboarding_repository.g.dart │ │ └── presentation │ │ ├── onboarding_controller.dart │ │ ├── onboarding_controller.g.dart │ │ └── onboarding_screen.dart │ ├── localization │ └── string_hardcoded.dart │ ├── routing │ ├── app_router.dart │ ├── app_router.g.dart │ ├── app_startup.dart │ ├── app_startup.g.dart │ ├── go_router_delegate_listener.dart │ ├── go_router_refresh_stream.dart │ ├── not_found_screen.dart │ └── scaffold_with_nested_navigation.dart │ └── utils │ ├── alert_dialogs.dart │ ├── async_value_ui.dart │ ├── format.dart │ ├── shared_preferences_provider.dart │ ├── shared_preferences_provider.g.dart │ ├── show_alert_dialog.dart │ └── show_exception_alert_dialog.dart ├── macos ├── .gitignore ├── Flutter │ ├── Flutter-Debug.xcconfig │ ├── Flutter-Release.xcconfig │ └── GeneratedPluginRegistrant.swift ├── Podfile ├── Podfile.lock ├── Runner.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ └── Runner.xcscheme ├── Runner.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── Runner │ ├── AppDelegate.swift │ ├── Assets.xcassets │ │ └── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ ├── app_icon_1024.png │ │ │ ├── app_icon_128.png │ │ │ ├── app_icon_16.png │ │ │ ├── app_icon_256.png │ │ │ ├── app_icon_32.png │ │ │ ├── app_icon_512.png │ │ │ └── app_icon_64.png │ ├── Base.lproj │ │ └── MainMenu.xib │ ├── Configs │ │ ├── AppInfo.xcconfig │ │ ├── Debug.xcconfig │ │ ├── Release.xcconfig │ │ └── Warnings.xcconfig │ ├── DebugProfile.entitlements │ ├── Info.plist │ ├── MainFlutterWindow.swift │ └── Release.entitlements └── RunnerTests │ └── RunnerTests.swift ├── pubspec.lock ├── pubspec.yaml ├── test └── src │ ├── features │ └── jobs │ │ └── domain │ │ └── job_test.dart │ └── mocks.dart ├── update-android-project.sh └── web ├── favicon.png ├── flutter_bootstrap.js ├── icons ├── Icon-192.png ├── Icon-512.png ├── Icon-maskable-192.png └── Icon-maskable-512.png ├── index.html └── manifest.json /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "starter-architecture-flutter" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.github/images/time-tracker-screenshots.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/.github/images/time-tracker-screenshots.png -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | on: [push, workflow_dispatch] 3 | jobs: 4 | drive: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v1 8 | - name: Write firebase_options.dart 9 | run: echo '{{ secrets.FIREBASE_OPTIONS }}' > lib/firebase_options.dart 10 | - name: Write GoogleService-info.plist 11 | run: echo '{{ secrets.GOOGLE_SERVICE_INFO_PLIST }}' > ios/Runner/GoogleService-Info.plist 12 | - name: Write google-services.json 13 | run: echo '{{ secrets.GOOGLE_SERVICES_JSON }}' > android/app/google-services.json 14 | - uses: subosito/flutter-action@v2.8.0 15 | - name: Run Flutter tests 16 | run: flutter test 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | #*.lock 4 | *.log 5 | *.pyc 6 | *.swp 7 | .DS_Store 8 | .atom/ 9 | .buildlog/ 10 | .history 11 | .svn/ 12 | 13 | # IntelliJ related 14 | *.iml 15 | *.ipr 16 | *.iws 17 | .idea/ 18 | 19 | # Visual Studio Code related 20 | #.vscode/ 21 | .vscode/settings.json 22 | 23 | # Flutter repo-specific 24 | /bin/cache/ 25 | /bin/mingit/ 26 | /dev/benchmarks/mega_gallery/ 27 | /dev/bots/.recipe_deps 28 | /dev/bots/android_tools/ 29 | /dev/docs/doc/ 30 | /dev/docs/flutter.docs.zip 31 | /dev/docs/lib/ 32 | /dev/docs/pubspec.yaml 33 | /packages/flutter/coverage/ 34 | version 35 | 36 | # Flutter/Dart/Pub related 37 | **/doc/api/ 38 | .dart_tool/ 39 | .flutter-plugins 40 | .packages 41 | .pub-cache/ 42 | .pub/ 43 | build/ 44 | flutter_*.png 45 | linked_*.ds 46 | unlinked.ds 47 | unlinked_spec.ds 48 | .flutter-plugins-dependencies 49 | 50 | # Android related 51 | **/android/**/gradle-wrapper.jar 52 | **/android/.gradle 53 | **/android/captures/ 54 | **/android/gradlew 55 | **/android/gradlew.bat 56 | **/android/local.properties 57 | **/android/**/GeneratedPluginRegistrant.java 58 | **/android/key.properties 59 | *.jks 60 | 61 | # iOS/XCode related 62 | **/ios/**/*.mode1v3 63 | **/ios/**/*.mode2v3 64 | **/ios/**/*.moved-aside 65 | **/ios/**/*.pbxuser 66 | **/ios/**/*.perspectivev3 67 | **/ios/**/*sync/ 68 | **/ios/**/.sconsign.dblite 69 | **/ios/**/.tags* 70 | **/ios/**/.vagrant/ 71 | **/ios/**/DerivedData/ 72 | **/ios/**/Icon? 73 | **/ios/**/Pods/ 74 | **/ios/**/.symlinks/ 75 | **/ios/**/profile 76 | **/ios/**/xcuserdata 77 | **/ios/.generated/ 78 | **/ios/Flutter/App.framework 79 | **/ios/Flutter/Flutter.framework 80 | **/ios/Flutter/Generated.xcconfig 81 | **/ios/Flutter/app.flx 82 | **/ios/Flutter/app.zip 83 | **/ios/Flutter/flutter_assets/ 84 | **/ios/ServiceDefinitions.json 85 | **/ios/Runner/GeneratedPluginRegistrant.* 86 | **/ios/Flutter/flutter_export_environment.sh 87 | **/ios/Flutter/.last_build_id 88 | 89 | 90 | # Exceptions to above rules. 91 | !**/ios/**/default.mode1v3 92 | !**/ios/**/default.mode2v3 93 | !**/ios/**/default.pbxuser 94 | !**/ios/**/default.perspectivev3 95 | !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages 96 | 97 | # Firebase configuration files 98 | lib/firebase_options.dart 99 | ios/Runner/GoogleService-Info.plist 100 | ios/firebase_app_id_file.json 101 | macos/Runner/GoogleService-Info.plist 102 | macos/firebase_app_id_file.json 103 | android/app/google-services.json 104 | 105 | # Firebase hosting 106 | .firebase/ -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: "4cf269e36de2573851eaef3c763994f8f9be494d" 8 | channel: "stable" 9 | 10 | project_type: app 11 | 12 | # Tracks metadata for the flutter migrate command 13 | migration: 14 | platforms: 15 | - platform: root 16 | create_revision: 4cf269e36de2573851eaef3c763994f8f9be494d 17 | base_revision: 4cf269e36de2573851eaef3c763994f8f9be494d 18 | - platform: android 19 | create_revision: 4cf269e36de2573851eaef3c763994f8f9be494d 20 | base_revision: 4cf269e36de2573851eaef3c763994f8f9be494d 21 | 22 | # User provided section 23 | 24 | # List of Local paths (relative to this file) that should be 25 | # ignored by the migrate tool. 26 | # 27 | # Files that are not part of the templates will be ignored by default. 28 | unmanaged_files: 29 | - 'lib/main.dart' 30 | - 'ios/Runner.xcodeproj/project.pbxproj' 31 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Run", 9 | "request": "launch", 10 | "type": "dart" 11 | }, 12 | { 13 | "name": "Run (profile mode)", 14 | "request": "launch", 15 | "type": "dart", 16 | "flutterMode": "profile" 17 | }, 18 | { 19 | "name": "Run (release mode)", 20 | "request": "launch", 21 | "type": "dart", 22 | "flutterMode": "release" 23 | } 24 | ] 25 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Andrea Bizzotto [bizz84@gmail.com](mailto:bizz84@gmail.com) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Time Tracking app with Flutter & Firebase 2 | 3 | A time tracking application built with Flutter & Firebase: 4 | 5 | ![](/.github/images/time-tracker-screenshots.png) 6 | 7 | This is intended as a **reference app** based on my [Riverpod Architecture](https://codewithandrea.com/articles/flutter-app-architecture-riverpod-introduction/). 8 | 9 | > **Note**: this project used to be called "Started Architecture for Flutter & Firebase" (based on this [old article](https://codewithandrea.com/videos/starter-architecture-flutter-firebase/)). As of January 2023, it follows my updated [Riverpod Architecture](https://codewithandrea.com/articles/flutter-app-architecture-riverpod-introduction/), using the latest packages. 10 | 11 | ## Flutter web preview 12 | 13 | A Flutter web preview of the app is available here: 14 | 15 | - [Time Tracker | Flutter web demo](https://starter-architecture-flutter.web.app) 16 | 17 | ## Features 18 | 19 | - **Simple onboarding page** 20 | - **Full authentication flow** (using email & password) 21 | - **Jobs**: users can view, create, edit, and delete their own private jobs (each job has a name and hourly rate) 22 | - **Entries**: for each job, user can view, create, edit, and delete the corresponding entries (an entry is a task with a start and end time, with an optional comment) 23 | - **A report page** that shows a daily breakdown of all jobs, hours worked and pay, along with the totals. 24 | 25 | All the data is persisted with Firestore and is kept in sync across multiple devices. 26 | 27 | ## Roadmap 28 | 29 | - [ ] Add missing tests 30 | - [x] Stateful Nested Navigation (available since GoRouter 7.1) 31 | - [ ] Use controllers / notifiers consistently across the app (some code still needs to be updated) 32 | - [ ] Add localization 33 | - [ ] Use the new Firebase UI packages where useful 34 | - [ ] Responsive UI 35 | 36 | > This is a tentative roadmap. There is no ETA for any of the points above. This is a low priority project and I don't have much time to maintain it. 37 | 38 | ## Relevant Articles 39 | 40 | The app is based on my Flutter Riverpod architecture, which is explained in detail here: 41 | 42 | - [Flutter App Architecture with Riverpod: An Introduction](https://codewithandrea.com/articles/flutter-app-architecture-riverpod-introduction/) 43 | - [Flutter Project Structure: Feature-first or Layer-first?](https://codewithandrea.com/articles/flutter-project-structure/) 44 | - [Flutter App Architecture: The Repository Pattern](https://codewithandrea.com/articles/flutter-repository-pattern/) 45 | - [How to Build a Robust Flutter App Initialization Flow with Riverpod](https://codewithandrea.com/articles/robust-app-initialization-riverpod/) 46 | 47 | More more info on Riverpod, read this: 48 | 49 | - [Flutter Riverpod 2.0: The Ultimate Guide](https://codewithandrea.com/articles/flutter-state-management-riverpod/) 50 | 51 | ## Packages in use 52 | 53 | These are the main packages used in the app: 54 | 55 | - [Flutter Riverpod](https://pub.dev/packages/flutter_riverpod) for data caching, dependency injection, and more 56 | - [Riverpod Generator](https://pub.dev/packages/riverpod_generator) and [Riverpod Lint](https://pub.dev/packages/riverpod_lint) for the latest Riverpod APIs 57 | - [GoRouter](https://pub.dev/packages/go_router) for navigation 58 | - [Firebase Auth](https://pub.dev/packages/firebase_auth) and [Firebase UI Auth](https://pub.dev/packages/firebase_ui_auth) for authentication 59 | - [Cloud Firestore](https://pub.dev/packages/cloud_firestore) as a realtime database 60 | - [Firebase UI for Firestore](https://pub.dev/packages/firebase_ui_firestore) for the `FirestoreListView` widget with pagination support 61 | - [RxDart](https://pub.dev/packages/rxdart) for combining multiple Firestore collections as needed 62 | - [Intl](https://pub.dev/packages/intl) for currency, date, time formatting 63 | - [Mocktail](https://pub.dev/packages/mocktail) for testing 64 | - [Equatable](https://pub.dev/packages/equatable) to reduce boilerplate code in model classes 65 | 66 | See the [pubspec.yaml](pubspec.yaml) file for the complete list. 67 | 68 | ## Running the project with Firebase 69 | 70 | To use this project with Firebase, follow these steps: 71 | 72 | - Create a new project with the Firebase console 73 | - Enable Firebase Authentication, along with the Email/Password Authentication Sign-in provider in the Firebase Console (Authentication > Sign-in method > Email/Password > Edit > Enable > Save) 74 | - Enable Cloud Firestore 75 | 76 | Then, follow one of the two approaches below. 👇 77 | 78 | ### 1. Using the CLI 79 | 80 | Make sure you have the Firebase CLI and [FlutterFire CLI](https://pub.dev/packages/flutterfire_cli) installed. 81 | 82 | Then run this on the terminal from the root of this project: 83 | 84 | - Run `firebase login` so you have access to the Firebase project you have created 85 | - Run `flutterfire configure` and follow all the steps 86 | 87 | For more info, follow this guide: 88 | 89 | - [How to add Firebase to a Flutter app with FlutterFire CLI](https://codewithandrea.com/articles/flutter-firebase-flutterfire-cli/) 90 | 91 | ### 2. Manual way (not recommended) 92 | 93 | If you don't want to use FlutterFire CLI, follow these steps instead: 94 | 95 | - Register separate iOS, Android, and web apps in the Firebase project settings. 96 | - On Android, use `com.example.starter_architecture_flutter_firebase` as the package name. 97 | - then, [download and copy](https://firebase.google.com/docs/flutter/setup#configure_an_android_app) `google-services.json` into `android/app`. 98 | - On iOS, use `com.example.starterArchitectureFlutterFirebase` as the bundle ID. 99 | - then, [download and copy](https://firebase.google.com/docs/flutter/setup#configure_an_ios_app) `GoogleService-Info.plist` into `iOS/Runner`, and add it to the Runner target in Xcode. 100 | 101 | That's it. Have fun! 102 | 103 | ## [License: MIT](LICENSE.md) 104 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | analyzer: 13 | plugins: 14 | - custom_lint 15 | 16 | linter: 17 | # The lint rules applied to this project can be customized in the 18 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 19 | # included above or to enable additional rules. A list of all available lints 20 | # and their documentation is published at 21 | # https://dart-lang.github.io/linter/lints/index.html. 22 | # 23 | # Instead of disabling a lint rule for the entire project in the 24 | # section below, it can also be suppressed for a single line of code 25 | # or a specific dart file by using the `// ignore: name_of_lint` and 26 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 27 | # producing the lint. 28 | rules: 29 | # avoid_print: false # Uncomment to disable the `avoid_print` rule 30 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 31 | 32 | # Additional information about this file can be found at 33 | # https://dart.dev/guides/language/analysis-options 34 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | 9 | # Remember to never publicly share your keystore. 10 | # See https://flutter.dev/to/reference-keystore 11 | key.properties 12 | **/*.keystore 13 | **/*.jks 14 | app/.cxx -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "com.android.application" 3 | // START: FlutterFire Configuration 4 | id 'com.google.gms.google-services' 5 | // END: FlutterFire Configuration 6 | id "kotlin-android" 7 | // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. 8 | id "dev.flutter.flutter-gradle-plugin" 9 | } 10 | 11 | android { 12 | namespace = "com.example.starter_architecture_flutter_firebase" 13 | compileSdk = flutter.compileSdkVersion 14 | ndkVersion = "27.0.12077973" 15 | 16 | compileOptions { 17 | sourceCompatibility = JavaVersion.VERSION_17 18 | targetCompatibility = JavaVersion.VERSION_17 19 | } 20 | 21 | kotlinOptions { 22 | jvmTarget = JavaVersion.VERSION_17 23 | } 24 | 25 | defaultConfig { 26 | // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). 27 | applicationId = "com.example.starter_architecture_flutter_firebase" 28 | // You can update the following values to match your application needs. 29 | // For more information, see: https://flutter.dev/to/review-gradle-config. 30 | minSdk = 24 31 | targetSdk = 34 32 | versionCode = flutter.versionCode 33 | versionName = flutter.versionName 34 | } 35 | 36 | buildTypes { 37 | release { 38 | // TODO: Add your own signing config for the release build. 39 | // Signing with the debug keys for now, so `flutter run --release` works. 40 | signingConfig = signingConfigs.debug 41 | } 42 | } 43 | } 44 | 45 | flutter { 46 | source = "../.." 47 | } 48 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 15 | 19 | 23 | 24 | 25 | 26 | 27 | 28 | 30 | 33 | 34 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/com/example/starter_architecture_flutter_firebase/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.example.starter_architecture_flutter_firebase 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity: FlutterActivity() 6 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-v21/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | allprojects { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | } 6 | } 7 | 8 | rootProject.buildDir = "../build" 9 | subprojects { 10 | project.buildDir = "${rootProject.buildDir}/${project.name}" 11 | } 12 | subprojects { 13 | project.evaluationDependsOn(":app") 14 | } 15 | 16 | tasks.register("clean", Delete) { 17 | delete rootProject.buildDir 18 | } 19 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | zipStoreBase=GRADLE_USER_HOME 4 | zipStorePath=wrapper/dists 5 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip 6 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | def flutterSdkPath = { 3 | def properties = new Properties() 4 | file("local.properties").withInputStream { properties.load(it) } 5 | def flutterSdkPath = properties.getProperty("flutter.sdk") 6 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 7 | return flutterSdkPath 8 | }() 9 | 10 | includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") 11 | 12 | repositories { 13 | google() 14 | mavenCentral() 15 | gradlePluginPortal() 16 | } 17 | } 18 | 19 | plugins { 20 | id "dev.flutter.flutter-plugin-loader" version "1.0.0" 21 | id "com.android.application" version "8.7.2" apply false 22 | // START: FlutterFire Configuration 23 | id "com.google.gms.google-services" version "4.3.15" apply false 24 | // END: FlutterFire Configuration 25 | id "org.jetbrains.kotlin.android" version "1.8.22" apply false 26 | } 27 | 28 | include ":app" 29 | -------------------------------------------------------------------------------- /assets/time-tracking.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | {"hosting":{"public":"build/web","ignore":["firebase.json","**/.*","**/node_modules/**"],"rewrites":[{"source":"**","destination":"/index.html"}]},"flutter":{"platforms":{"android":{"default":{"projectId":"starter-architecture-flutter","appId":"1:204483935261:android:6bf22efec4b84563779af4","fileOutput":"android/app/google-services.json"}},"ios":{"default":{"projectId":"starter-architecture-flutter","appId":"1:204483935261:ios:df913fb4eeda0a29779af4","uploadDebugSymbols":false,"fileOutput":"ios/Runner/GoogleService-Info.plist"}},"macos":{"default":{"projectId":"starter-architecture-flutter","appId":"1:204483935261:ios:df913fb4eeda0a29779af4","uploadDebugSymbols":false,"fileOutput":"macos/Runner/GoogleService-Info.plist"}},"dart":{"lib/firebase_options.dart":{"projectId":"starter-architecture-flutter","configurations":{"android":"1:204483935261:android:6bf22efec4b84563779af4","ios":"1:204483935261:ios:df913fb4eeda0a29779af4","macos":"1:204483935261:ios:df913fb4eeda0a29779af4","web":"1:204483935261:web:2eff4a630625a401779af4"}}}}}} -------------------------------------------------------------------------------- /ios/.gitignore: -------------------------------------------------------------------------------- 1 | **/dgph 2 | *.mode1v3 3 | *.mode2v3 4 | *.moved-aside 5 | *.pbxuser 6 | *.perspectivev3 7 | **/*sync/ 8 | .sconsign.dblite 9 | .tags* 10 | **/.vagrant/ 11 | **/DerivedData/ 12 | Icon? 13 | **/Pods/ 14 | **/.symlinks/ 15 | profile 16 | xcuserdata 17 | **/.generated/ 18 | Flutter/App.framework 19 | Flutter/Flutter.framework 20 | Flutter/Flutter.podspec 21 | Flutter/Generated.xcconfig 22 | Flutter/ephemeral/ 23 | Flutter/app.flx 24 | Flutter/app.zip 25 | Flutter/flutter_assets/ 26 | Flutter/flutter_export_environment.sh 27 | ServiceDefinitions.json 28 | Runner/GeneratedPluginRegistrant.* 29 | 30 | # Exceptions to above rules. 31 | !default.mode1v3 32 | !default.mode2v3 33 | !default.pbxuser 34 | !default.perspectivev3 35 | -------------------------------------------------------------------------------- /ios/Flutter/AppFrameworkInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | App 9 | CFBundleIdentifier 10 | io.flutter.flutter.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | App 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1.0 23 | MinimumOSVersion 24 | 12.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment this line to define a global platform for your project 2 | platform :ios, '13.0' 3 | 4 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 5 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' 6 | 7 | project 'Runner', { 8 | 'Debug' => :debug, 9 | 'Profile' => :release, 10 | 'Release' => :release, 11 | } 12 | 13 | def flutter_root 14 | generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) 15 | unless File.exist?(generated_xcode_build_settings_path) 16 | raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" 17 | end 18 | 19 | File.foreach(generated_xcode_build_settings_path) do |line| 20 | matches = line.match(/FLUTTER_ROOT\=(.*)/) 21 | return matches[1].strip if matches 22 | end 23 | raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" 24 | end 25 | 26 | require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) 27 | 28 | flutter_ios_podfile_setup 29 | 30 | target 'Runner' do 31 | pod 'FirebaseFirestore', :git => 'https://github.com/invertase/firestore-ios-sdk-frameworks.git', :tag => '11.10.0' 32 | use_frameworks! 33 | use_modular_headers! 34 | 35 | flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) 36 | end 37 | 38 | post_install do |installer| 39 | # Ensure pods also use the minimum deployment target set above 40 | # https://stackoverflow.com/a/64385584/436422 41 | puts 'Determining pod project minimum deployment target' 42 | 43 | pods_project = installer.pods_project 44 | deployment_target_key = 'IPHONEOS_DEPLOYMENT_TARGET' 45 | deployment_targets = pods_project.build_configurations.map{ |config| config.build_settings[deployment_target_key] } 46 | minimum_deployment_target = deployment_targets.min_by{ |version| Gem::Version.new(version) } 47 | 48 | puts 'Minimal deployment target is ' + minimum_deployment_target 49 | puts 'Setting each pod deployment target to ' + minimum_deployment_target 50 | 51 | installer.pods_project.targets.each do |target| 52 | flutter_additional_ios_build_settings(target) 53 | target.build_configurations.each do |config| 54 | #config.build_settings['ENABLE_BITCODE'] = 'NO' 55 | config.build_settings[deployment_target_key] = minimum_deployment_target 56 | # https://stackoverflow.com/a/77142190 57 | xcconfig_path = config.base_configuration_reference.real_path 58 | xcconfig = File.read(xcconfig_path) 59 | xcconfig_mod = xcconfig.gsub(/DT_TOOLCHAIN_DIR/, "TOOLCHAIN_DIR") 60 | File.open(xcconfig_path, "w") { |file| file << xcconfig_mod } 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 37 | 38 | 39 | 40 | 41 | 42 | 53 | 55 | 61 | 62 | 63 | 64 | 70 | 72 | 78 | 79 | 80 | 81 | 83 | 84 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Flutter 3 | 4 | @main 5 | @objc class AppDelegate: FlutterAppDelegate { 6 | override func application( 7 | _ application: UIApplication, 8 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 9 | ) -> Bool { 10 | GeneratedPluginRegistrant.register(with: self) 11 | return super.application(application, didFinishLaunchingWithOptions: launchOptions) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "Icon-App-20x20@2x.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "Icon-App-20x20@3x.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "Icon-App-29x29@1x.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "Icon-App-29x29@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "29x29", 29 | "idiom" : "iphone", 30 | "filename" : "Icon-App-29x29@3x.png", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "Icon-App-40x40@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "40x40", 41 | "idiom" : "iphone", 42 | "filename" : "Icon-App-40x40@3x.png", 43 | "scale" : "3x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "Icon-App-60x60@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "60x60", 53 | "idiom" : "iphone", 54 | "filename" : "Icon-App-60x60@3x.png", 55 | "scale" : "3x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "Icon-App-20x20@1x.png", 61 | "scale" : "1x" 62 | }, 63 | { 64 | "size" : "20x20", 65 | "idiom" : "ipad", 66 | "filename" : "Icon-App-20x20@2x.png", 67 | "scale" : "2x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "Icon-App-29x29@1x.png", 73 | "scale" : "1x" 74 | }, 75 | { 76 | "size" : "29x29", 77 | "idiom" : "ipad", 78 | "filename" : "Icon-App-29x29@2x.png", 79 | "scale" : "2x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "Icon-App-40x40@1x.png", 85 | "scale" : "1x" 86 | }, 87 | { 88 | "size" : "40x40", 89 | "idiom" : "ipad", 90 | "filename" : "Icon-App-40x40@2x.png", 91 | "scale" : "2x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "Icon-App-76x76@1x.png", 97 | "scale" : "1x" 98 | }, 99 | { 100 | "size" : "76x76", 101 | "idiom" : "ipad", 102 | "filename" : "Icon-App-76x76@2x.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "size" : "83.5x83.5", 107 | "idiom" : "ipad", 108 | "filename" : "Icon-App-83.5x83.5@2x.png", 109 | "scale" : "2x" 110 | }, 111 | { 112 | "size" : "1024x1024", 113 | "idiom" : "ios-marketing", 114 | "filename" : "Icon-App-1024x1024@1x.png", 115 | "scale" : "1x" 116 | } 117 | ], 118 | "info" : { 119 | "version" : 1, 120 | "author" : "xcode" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "LaunchImage.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "LaunchImage@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "LaunchImage@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md: -------------------------------------------------------------------------------- 1 | # Launch Screen Assets 2 | 3 | You can customize the launch screen with your own desired assets by replacing the image files in this directory. 4 | 5 | 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. -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /ios/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Starter Architecture Flutter Firebase 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | starter_architecture_flutter_firebase 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(FLUTTER_BUILD_NAME) 21 | CFBundleSignature 22 | ???? 23 | CFBundleVersion 24 | $(FLUTTER_BUILD_NUMBER) 25 | LSRequiresIPhoneOS 26 | 27 | UILaunchStoryboardName 28 | LaunchScreen 29 | UIMainStoryboardFile 30 | Main 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UISupportedInterfaceOrientations~ipad 38 | 39 | UIInterfaceOrientationPortrait 40 | UIInterfaceOrientationPortraitUpsideDown 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | UIViewControllerBasedStatusBarAppearance 45 | 46 | CADisableMinimumFrameDurationOnPhone 47 | 48 | UIApplicationSupportsIndirectInputEvents 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /ios/Runner/Runner-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "GeneratedPluginRegistrant.h" 2 | -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:firebase_core/firebase_core.dart'; 2 | import 'package:flutter/foundation.dart'; 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 | import 'package:starter_architecture_flutter_firebase/firebase_options.dart'; 6 | import 'package:starter_architecture_flutter_firebase/src/app.dart'; 7 | import 'package:starter_architecture_flutter_firebase/src/localization/string_hardcoded.dart'; 8 | // ignore:depend_on_referenced_packages 9 | import 'package:flutter_web_plugins/url_strategy.dart'; 10 | 11 | Future main() async { 12 | WidgetsFlutterBinding.ensureInitialized(); 13 | // turn off the # in the URLs on the web 14 | usePathUrlStrategy(); 15 | // * Register error handlers. For more info, see: 16 | // * https://docs.flutter.dev/testing/errors 17 | registerErrorHandlers(); 18 | // * Initialize Firebase 19 | await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); 20 | // * Entry point of the app 21 | runApp(const ProviderScope( 22 | child: MyApp(), 23 | )); 24 | } 25 | 26 | void registerErrorHandlers() { 27 | // * Show some error UI if any uncaught exception happens 28 | FlutterError.onError = (FlutterErrorDetails details) { 29 | FlutterError.presentError(details); 30 | debugPrint(details.toString()); 31 | }; 32 | // * Handle errors from the underlying platform/OS 33 | PlatformDispatcher.instance.onError = (Object error, StackTrace stack) { 34 | debugPrint(error.toString()); 35 | return true; 36 | }; 37 | // * Show some error UI when any widget in the app fails to build 38 | ErrorWidget.builder = (FlutterErrorDetails details) { 39 | return Scaffold( 40 | appBar: AppBar( 41 | backgroundColor: Colors.red, 42 | title: Text('An error occurred'.hardcoded), 43 | ), 44 | body: Center(child: Text(details.toString())), 45 | ); 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /lib/src/app.dart: -------------------------------------------------------------------------------- 1 | import 'dart:developer'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 | import 'package:force_update_helper/force_update_helper.dart'; 6 | import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; 7 | import 'package:starter_architecture_flutter_firebase/src/routing/app_startup.dart'; 8 | import 'package:starter_architecture_flutter_firebase/src/routing/go_router_delegate_listener.dart'; 9 | import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; 10 | import 'package:url_launcher/url_launcher.dart'; 11 | 12 | class MyApp extends ConsumerWidget { 13 | const MyApp({super.key}); 14 | 15 | static const primaryColor = Colors.indigo; 16 | 17 | @override 18 | Widget build(BuildContext context, WidgetRef ref) { 19 | final goRouter = ref.watch(goRouterProvider); 20 | return MaterialApp.router( 21 | routerConfig: goRouter, 22 | builder: (_, child) { 23 | // * Important: Use AppStartupWidget to wrap ForceUpdateWidget otherwise you will get this error: 24 | // * Navigator operation requested with a context that does not include a Navigator. 25 | // * The context used to push or pop routes from the Navigator must be that of a widget that is a descendant of a Navigator widget. 26 | return AppStartupWidget( 27 | onLoaded: (_) => ForceUpdateWidget( 28 | navigatorKey: goRouter.routerDelegate.navigatorKey, 29 | forceUpdateClient: ForceUpdateClient( 30 | // * Real apps should fetch this from an API endpoint or via 31 | // * Firebase Remote Config 32 | fetchRequiredVersion: () => Future.value('2.0.0'), 33 | // * Example ID from this app: https://fluttertips.dev/ 34 | // * To avoid mistakes, store the ID as an environment variable and 35 | // * read it with String.fromEnvironment 36 | iosAppStoreId: '6482293361', 37 | ), 38 | allowCancel: false, 39 | showForceUpdateAlert: (context, allowCancel) => showAlertDialog( 40 | context: context, 41 | title: 'App Update Required', 42 | content: 'Please update to continue using the app.', 43 | cancelActionText: allowCancel ? 'Later' : null, 44 | defaultActionText: 'Update Now', 45 | ), 46 | showStoreListing: (storeUrl) async { 47 | if (await canLaunchUrl(storeUrl)) { 48 | await launchUrl( 49 | storeUrl, 50 | // * Open app store app directly (or fallback to browser) 51 | mode: LaunchMode.externalApplication, 52 | ); 53 | } else { 54 | log('Cannot launch URL: $storeUrl'); 55 | } 56 | }, 57 | onException: (e, st) { 58 | log(e.toString()); 59 | }, 60 | child: GoRouterDelegateListener(child: child!), 61 | ), 62 | ); 63 | }, 64 | theme: ThemeData( 65 | colorSchemeSeed: primaryColor, 66 | unselectedWidgetColor: Colors.grey, 67 | appBarTheme: const AppBarTheme( 68 | backgroundColor: primaryColor, 69 | foregroundColor: Colors.white, 70 | elevation: 2.0, 71 | centerTitle: true, 72 | ), 73 | scaffoldBackgroundColor: Colors.grey[200], 74 | dividerColor: Colors.grey[400], 75 | elevatedButtonTheme: ElevatedButtonThemeData( 76 | style: ElevatedButton.styleFrom( 77 | backgroundColor: primaryColor, 78 | foregroundColor: Colors.white, 79 | ), 80 | ), 81 | outlinedButtonTheme: OutlinedButtonThemeData( 82 | style: OutlinedButton.styleFrom( 83 | backgroundColor: primaryColor, 84 | foregroundColor: Colors.white, 85 | ), 86 | ), 87 | floatingActionButtonTheme: const FloatingActionButtonThemeData( 88 | backgroundColor: primaryColor, 89 | ), 90 | ), 91 | debugShowCheckedModeBanner: false, 92 | ); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /lib/src/common_widgets/action_text_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:starter_architecture_flutter_firebase/src/constants/app_sizes.dart'; 3 | 4 | /// Text button to be used as an [AppBar] action 5 | class ActionTextButton extends StatelessWidget { 6 | const ActionTextButton({super.key, required this.text, this.onPressed}); 7 | final String text; 8 | final VoidCallback? onPressed; 9 | @override 10 | Widget build(BuildContext context) { 11 | return Padding( 12 | padding: const EdgeInsets.symmetric(horizontal: Sizes.p16), 13 | child: TextButton( 14 | onPressed: onPressed, 15 | child: Text(text, 16 | style: Theme.of(context) 17 | .textTheme 18 | .titleLarge! 19 | .copyWith(color: Colors.white)), 20 | ), 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/src/common_widgets/async_value_widget.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:starter_architecture_flutter_firebase/src/common_widgets/error_message_widget.dart'; 4 | 5 | class AsyncValueWidget extends StatelessWidget { 6 | const AsyncValueWidget({super.key, required this.value, required this.data}); 7 | final AsyncValue value; 8 | final Widget Function(T) data; 9 | 10 | @override 11 | Widget build(BuildContext context) { 12 | return value.when( 13 | data: data, 14 | error: (e, st) => Center(child: ErrorMessageWidget(e.toString())), 15 | loading: () => const Center(child: CircularProgressIndicator()), 16 | ); 17 | } 18 | } 19 | 20 | class ScaffoldAsyncValueWidget extends StatelessWidget { 21 | const ScaffoldAsyncValueWidget( 22 | {super.key, required this.value, required this.data}); 23 | final AsyncValue value; 24 | final Widget Function(T) data; 25 | 26 | @override 27 | Widget build(BuildContext context) { 28 | return value.when( 29 | data: data, 30 | error: (e, st) => Scaffold( 31 | appBar: AppBar(), 32 | body: Center(child: ErrorMessageWidget(e.toString())), 33 | ), 34 | loading: () => Scaffold( 35 | appBar: AppBar(), 36 | body: const Center(child: CircularProgressIndicator()), 37 | ), 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /lib/src/common_widgets/avatar.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class Avatar extends StatelessWidget { 4 | const Avatar({ 5 | super.key, 6 | this.photoUrl, 7 | required this.radius, 8 | this.borderColor, 9 | this.borderWidth, 10 | }); 11 | final String? photoUrl; 12 | final double radius; 13 | final Color? borderColor; 14 | final double? borderWidth; 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | return Container( 19 | decoration: _borderDecoration(), 20 | child: CircleAvatar( 21 | radius: radius, 22 | backgroundColor: Colors.black12, 23 | backgroundImage: photoUrl != null ? NetworkImage(photoUrl!) : null, 24 | child: photoUrl == null ? Icon(Icons.camera_alt, size: radius) : null, 25 | ), 26 | ); 27 | } 28 | 29 | Decoration? _borderDecoration() { 30 | if (borderColor != null && borderWidth != null) { 31 | return BoxDecoration( 32 | shape: BoxShape.circle, 33 | border: Border.all( 34 | color: borderColor!, 35 | width: borderWidth!, 36 | ), 37 | ); 38 | } 39 | return null; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/src/common_widgets/custom_text_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:starter_architecture_flutter_firebase/src/constants/app_sizes.dart'; 3 | 4 | /// Custom text button with a fixed height 5 | class CustomTextButton extends StatelessWidget { 6 | const CustomTextButton( 7 | {super.key, required this.text, this.style, this.onPressed}); 8 | final String text; 9 | final TextStyle? style; 10 | final VoidCallback? onPressed; 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | return SizedBox( 15 | height: Sizes.p48, 16 | child: TextButton( 17 | onPressed: onPressed, 18 | child: Text( 19 | text, 20 | style: style, 21 | textAlign: TextAlign.center, 22 | ), 23 | ), 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/src/common_widgets/date_time_picker.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:starter_architecture_flutter_firebase/src/common_widgets/input_dropdown.dart'; 5 | import 'package:starter_architecture_flutter_firebase/src/constants/app_sizes.dart'; 6 | import 'package:starter_architecture_flutter_firebase/src/utils/format.dart'; 7 | 8 | class DateTimePicker extends StatelessWidget { 9 | const DateTimePicker({ 10 | super.key, 11 | required this.labelText, 12 | required this.selectedDate, 13 | required this.selectedTime, 14 | this.onSelectedDate, 15 | this.onSelectedTime, 16 | }); 17 | 18 | final String labelText; 19 | final DateTime selectedDate; 20 | final TimeOfDay selectedTime; 21 | final ValueChanged? onSelectedDate; 22 | final ValueChanged? onSelectedTime; 23 | 24 | Future _selectDate(BuildContext context) async { 25 | final pickedDate = await showDatePicker( 26 | context: context, 27 | initialDate: selectedDate, 28 | firstDate: DateTime(2019, 1), 29 | lastDate: DateTime(2100), 30 | ); 31 | if (pickedDate != null && pickedDate != selectedDate) { 32 | onSelectedDate?.call(pickedDate); 33 | } 34 | } 35 | 36 | Future _selectTime(BuildContext context) async { 37 | final pickedTime = 38 | await showTimePicker(context: context, initialTime: selectedTime); 39 | if (pickedTime != null && pickedTime != selectedTime) { 40 | onSelectedTime?.call(pickedTime); 41 | } 42 | } 43 | 44 | @override 45 | Widget build(BuildContext context) { 46 | final valueStyle = Theme.of(context).textTheme.titleLarge!; 47 | return Row( 48 | crossAxisAlignment: CrossAxisAlignment.end, 49 | children: [ 50 | Expanded( 51 | flex: 5, 52 | child: InputDropdown( 53 | labelText: labelText, 54 | valueText: Format.date(selectedDate), 55 | valueStyle: valueStyle, 56 | onPressed: () => _selectDate(context), 57 | ), 58 | ), 59 | gapW12, 60 | Expanded( 61 | flex: 4, 62 | child: InputDropdown( 63 | valueText: selectedTime.format(context), 64 | valueStyle: valueStyle, 65 | onPressed: () => _selectTime(context), 66 | ), 67 | ), 68 | ], 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /lib/src/common_widgets/empty_content.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class EmptyContent extends StatelessWidget { 4 | const EmptyContent({ 5 | super.key, 6 | this.title = 'Nothing here', 7 | this.message = 'Add a new item to get started', 8 | }); 9 | final String title; 10 | final String message; 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | return Center( 15 | child: Column( 16 | mainAxisAlignment: MainAxisAlignment.center, 17 | children: [ 18 | Text( 19 | title, 20 | style: const TextStyle(fontSize: 32.0, color: Colors.black54), 21 | ), 22 | Text( 23 | message, 24 | style: const TextStyle(fontSize: 16.0, color: Colors.black54), 25 | ), 26 | ], 27 | ), 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lib/src/common_widgets/empty_placeholder_widget.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:go_router/go_router.dart'; 4 | import 'package:starter_architecture_flutter_firebase/src/common_widgets/primary_button.dart'; 5 | import 'package:starter_architecture_flutter_firebase/src/constants/app_sizes.dart'; 6 | import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; 7 | import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; 8 | 9 | /// Placeholder widget showing a message and CTA to go back to the home screen. 10 | class EmptyPlaceholderWidget extends ConsumerWidget { 11 | const EmptyPlaceholderWidget({super.key, required this.message}); 12 | final String message; 13 | 14 | @override 15 | Widget build(BuildContext context, WidgetRef ref) { 16 | return Padding( 17 | padding: const EdgeInsets.all(Sizes.p16), 18 | child: Center( 19 | child: Column( 20 | mainAxisSize: MainAxisSize.min, 21 | crossAxisAlignment: CrossAxisAlignment.center, 22 | children: [ 23 | Text( 24 | message, 25 | style: Theme.of(context).textTheme.headlineMedium, 26 | textAlign: TextAlign.center, 27 | ), 28 | gapH32, 29 | PrimaryButton( 30 | onPressed: () { 31 | final isLoggedIn = 32 | ref.watch(authRepositoryProvider).currentUser != null; 33 | context.goNamed( 34 | isLoggedIn ? AppRoute.jobs.name : AppRoute.signIn.name); 35 | }, 36 | text: 'Go Home', 37 | ) 38 | ], 39 | ), 40 | ), 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/src/common_widgets/error_message_widget.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class ErrorMessageWidget extends StatelessWidget { 4 | const ErrorMessageWidget(this.errorMessage, {super.key}); 5 | final String errorMessage; 6 | @override 7 | Widget build(BuildContext context) { 8 | return Text( 9 | errorMessage, 10 | style: Theme.of(context).textTheme.titleLarge!.copyWith(color: Colors.red), 11 | ); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /lib/src/common_widgets/input_dropdown.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class InputDropdown extends StatelessWidget { 4 | const InputDropdown({ 5 | super.key, 6 | this.labelText, 7 | required this.valueText, 8 | required this.valueStyle, 9 | this.onPressed, 10 | }); 11 | 12 | final String? labelText; 13 | final String valueText; 14 | final TextStyle valueStyle; 15 | final VoidCallback? onPressed; 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | return InkWell( 20 | onTap: onPressed, 21 | child: InputDecorator( 22 | decoration: InputDecoration( 23 | labelText: labelText, 24 | ), 25 | baseStyle: valueStyle, 26 | child: Row( 27 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 28 | mainAxisSize: MainAxisSize.min, 29 | children: [ 30 | Text(valueText, style: valueStyle), 31 | Icon(Icons.arrow_drop_down, 32 | color: Theme.of(context).brightness == Brightness.light 33 | ? Colors.grey.shade700 34 | : Colors.white70), 35 | ], 36 | ), 37 | ), 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /lib/src/common_widgets/list_items_builder.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:starter_architecture_flutter_firebase/src/common_widgets/empty_content.dart'; 4 | 5 | typedef ItemWidgetBuilder = Widget Function(BuildContext context, T item); 6 | 7 | class ListItemsBuilder extends StatelessWidget { 8 | const ListItemsBuilder({ 9 | super.key, 10 | required this.data, 11 | required this.itemBuilder, 12 | }); 13 | final AsyncValue> data; 14 | final ItemWidgetBuilder itemBuilder; 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | return data.when( 19 | data: (items) => items.isNotEmpty 20 | ? ListView.separated( 21 | itemCount: items.length + 2, 22 | separatorBuilder: (context, index) => const Divider(height: 0.5), 23 | itemBuilder: (context, index) { 24 | if (index == 0 || index == items.length + 1) { 25 | return const SizedBox.shrink(); 26 | } 27 | return itemBuilder(context, items[index - 1]); 28 | }, 29 | ) 30 | : const EmptyContent(), 31 | loading: () => const Center(child: CircularProgressIndicator()), 32 | error: (_, __) => const EmptyContent( 33 | title: 'Something went wrong', 34 | message: 'Can\'t load items right now', 35 | ), 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/src/common_widgets/primary_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:starter_architecture_flutter_firebase/src/constants/app_sizes.dart'; 3 | 4 | /// Primary button based on [ElevatedButton]. 5 | /// Useful for CTAs in the app. 6 | /// @param text - text to display on the button. 7 | /// @param isLoading - if true, a loading indicator will be displayed instead of 8 | /// the text. 9 | /// @param onPressed - callback to be called when the button is pressed. 10 | class PrimaryButton extends StatelessWidget { 11 | const PrimaryButton( 12 | {super.key, required this.text, this.isLoading = false, this.onPressed}); 13 | final String text; 14 | final bool isLoading; 15 | final VoidCallback? onPressed; 16 | @override 17 | Widget build(BuildContext context) { 18 | return SizedBox( 19 | height: Sizes.p48, 20 | child: ElevatedButton( 21 | onPressed: onPressed, 22 | child: isLoading 23 | ? const CircularProgressIndicator() 24 | : Text( 25 | text, 26 | textAlign: TextAlign.center, 27 | style: Theme.of(context) 28 | .textTheme 29 | .titleLarge! 30 | .copyWith(color: Colors.white), 31 | ), 32 | ), 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /lib/src/common_widgets/responsive_center.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:starter_architecture_flutter_firebase/src/constants/breakpoints.dart'; 3 | 4 | /// Reusable widget for showing a child with a maximum content width constraint. 5 | /// If available width is larger than the maximum width, the child will be 6 | /// centered. 7 | /// If available width is smaller than the maximum width, the child use all the 8 | /// available width. 9 | class ResponsiveCenter extends StatelessWidget { 10 | const ResponsiveCenter({ 11 | super.key, 12 | this.maxContentWidth = Breakpoint.desktop, 13 | this.padding = EdgeInsets.zero, 14 | required this.child, 15 | }); 16 | final double maxContentWidth; 17 | final EdgeInsetsGeometry padding; 18 | final Widget child; 19 | 20 | @override 21 | Widget build(BuildContext context) { 22 | // Use Center as it has *unconstrained* width (loose constraints) 23 | return Center( 24 | // together with SizedBox to specify the max width (tight constraints) 25 | // See this thread for more info: 26 | // https://twitter.com/biz84/status/1445400059894542337 27 | child: SizedBox( 28 | width: maxContentWidth, 29 | child: Padding( 30 | padding: padding, 31 | child: child, 32 | ), 33 | ), 34 | ); 35 | } 36 | } 37 | 38 | /// Sliver-equivalent of [ResponsiveCenter]. 39 | class ResponsiveSliverCenter extends StatelessWidget { 40 | const ResponsiveSliverCenter({ 41 | super.key, 42 | this.maxContentWidth = Breakpoint.desktop, 43 | this.padding = EdgeInsets.zero, 44 | required this.child, 45 | }); 46 | final double maxContentWidth; 47 | final EdgeInsetsGeometry padding; 48 | final Widget child; 49 | @override 50 | Widget build(BuildContext context) { 51 | return SliverToBoxAdapter( 52 | child: ResponsiveCenter( 53 | maxContentWidth: maxContentWidth, 54 | padding: padding, 55 | child: child, 56 | ), 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /lib/src/common_widgets/responsive_scrollable_card.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:starter_architecture_flutter_firebase/src/common_widgets/responsive_center.dart'; 3 | import 'package:starter_architecture_flutter_firebase/src/constants/app_sizes.dart'; 4 | import 'package:starter_architecture_flutter_firebase/src/constants/breakpoints.dart'; 5 | 6 | /// Scrollable widget that shows a responsive card with a given child widget. 7 | /// Useful for displaying forms and other widgets that need to be scrollable. 8 | class ResponsiveScrollableCard extends StatelessWidget { 9 | const ResponsiveScrollableCard({super.key, required this.child}); 10 | final Widget child; 11 | @override 12 | Widget build(BuildContext context) { 13 | return SingleChildScrollView( 14 | child: ResponsiveCenter( 15 | maxContentWidth: Breakpoint.tablet, 16 | child: Padding( 17 | padding: const EdgeInsets.all(Sizes.p16), 18 | child: Card( 19 | child: Padding( 20 | padding: const EdgeInsets.all(Sizes.p16), 21 | child: child, 22 | ), 23 | ), 24 | ), 25 | ), 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /lib/src/common_widgets/segmented_control.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | 3 | class SegmentedControl extends StatelessWidget { 4 | const SegmentedControl({ 5 | super.key, 6 | required this.header, 7 | required this.value, 8 | required this.children, 9 | required this.onValueChanged, 10 | }); 11 | final Widget header; 12 | final T value; 13 | final Map children; 14 | final ValueChanged onValueChanged; 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | return Column( 19 | crossAxisAlignment: CrossAxisAlignment.start, 20 | children: [ 21 | Padding( 22 | padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), 23 | child: header, 24 | ), 25 | SizedBox( 26 | width: double.infinity, 27 | child: CupertinoSegmentedControl( 28 | children: children, 29 | groupValue: value, 30 | onValueChanged: onValueChanged, 31 | ), 32 | ), 33 | ], 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/src/constants/app_sizes.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | /// Constant sizes to be used in the app (paddings, gaps, rounded corners etc.) 4 | class Sizes { 5 | static const p4 = 4.0; 6 | static const p8 = 8.0; 7 | static const p12 = 12.0; 8 | static const p16 = 16.0; 9 | static const p20 = 20.0; 10 | static const p24 = 24.0; 11 | static const p32 = 32.0; 12 | static const p48 = 48.0; 13 | static const p64 = 64.0; 14 | } 15 | 16 | /// Constant gap widths 17 | const gapW4 = SizedBox(width: Sizes.p4); 18 | const gapW8 = SizedBox(width: Sizes.p8); 19 | const gapW12 = SizedBox(width: Sizes.p12); 20 | const gapW16 = SizedBox(width: Sizes.p16); 21 | const gapW20 = SizedBox(width: Sizes.p20); 22 | const gapW24 = SizedBox(width: Sizes.p24); 23 | const gapW32 = SizedBox(width: Sizes.p32); 24 | const gapW48 = SizedBox(width: Sizes.p48); 25 | const gapW64 = SizedBox(width: Sizes.p64); 26 | 27 | /// Constant gap heights 28 | const gapH4 = SizedBox(height: Sizes.p4); 29 | const gapH8 = SizedBox(height: Sizes.p8); 30 | const gapH12 = SizedBox(height: Sizes.p12); 31 | const gapH16 = SizedBox(height: Sizes.p16); 32 | const gapH20 = SizedBox(height: Sizes.p20); 33 | const gapH24 = SizedBox(height: Sizes.p24); 34 | const gapH32 = SizedBox(height: Sizes.p32); 35 | const gapH48 = SizedBox(height: Sizes.p48); 36 | const gapH64 = SizedBox(height: Sizes.p64); 37 | -------------------------------------------------------------------------------- /lib/src/constants/breakpoints.dart: -------------------------------------------------------------------------------- 1 | /// Layout breakpoints used in the app. 2 | class Breakpoint { 3 | static const double desktop = 900; 4 | static const double tablet = 600; 5 | } 6 | -------------------------------------------------------------------------------- /lib/src/constants/keys.dart: -------------------------------------------------------------------------------- 1 | class Keys { 2 | static const String emailPassword = 'email-password'; 3 | static const String anonymous = 'anonymous'; 4 | static const String tabBar = 'tabBar'; 5 | static const String jobsTab = 'jobsTab'; 6 | static const String entriesTab = 'entriesTab'; 7 | static const String accountTab = 'accountTab'; 8 | static const String logout = 'logout'; 9 | static const String alertDefault = 'alertDefault'; 10 | static const String alertCancel = 'alertCancel'; 11 | } 12 | -------------------------------------------------------------------------------- /lib/src/constants/strings.dart: -------------------------------------------------------------------------------- 1 | class Strings { 2 | // Generic strings 3 | static const String ok = 'OK'; 4 | static const String cancel = 'Cancel'; 5 | 6 | // Logout 7 | static const String logout = 'Logout'; 8 | static const String logoutAreYouSure = 9 | 'Are you sure that you want to logout?'; 10 | static const String logoutFailed = 'Logout failed'; 11 | 12 | // Sign In Page 13 | static const String signIn = 'Sign in'; 14 | static const String signInWithEmailPassword = 'Sign in with email & password'; 15 | static const String goAnonymous = 'Go anonymous'; 16 | static const String or = 'or'; 17 | static const String signInFailed = 'Sign in failed'; 18 | 19 | // Home page 20 | static const String homePage = 'Home Page'; 21 | 22 | // Jobs page 23 | static const String jobs = 'Jobs'; 24 | 25 | // Entries page 26 | static const String entries = 'Entries'; 27 | 28 | // Account page 29 | static const String account = 'Account'; 30 | static const String accountPage = 'Account Page'; 31 | } 32 | -------------------------------------------------------------------------------- /lib/src/features/authentication/data/firebase_auth_repository.dart: -------------------------------------------------------------------------------- 1 | import 'package:firebase_auth/firebase_auth.dart'; 2 | import 'package:riverpod/riverpod.dart'; 3 | import 'package:riverpod_annotation/riverpod_annotation.dart'; 4 | 5 | part 'firebase_auth_repository.g.dart'; 6 | 7 | class AuthRepository { 8 | AuthRepository(this._auth); 9 | final FirebaseAuth _auth; 10 | 11 | Stream authStateChanges() => _auth.authStateChanges(); 12 | User? get currentUser => _auth.currentUser; 13 | 14 | Future signInAnonymously() { 15 | return _auth.signInAnonymously(); 16 | } 17 | } 18 | 19 | @Riverpod(keepAlive: true) 20 | FirebaseAuth firebaseAuth(Ref ref) { 21 | return FirebaseAuth.instance; 22 | } 23 | 24 | @Riverpod(keepAlive: true) 25 | AuthRepository authRepository(Ref ref) { 26 | return AuthRepository(ref.watch(firebaseAuthProvider)); 27 | } 28 | 29 | @riverpod 30 | Stream authStateChanges(Ref ref) { 31 | return ref.watch(authRepositoryProvider).authStateChanges(); 32 | } 33 | -------------------------------------------------------------------------------- /lib/src/features/authentication/data/firebase_auth_repository.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'firebase_auth_repository.dart'; 4 | 5 | // ************************************************************************** 6 | // RiverpodGenerator 7 | // ************************************************************************** 8 | 9 | String _$firebaseAuthHash() => r'cb440927c3ab863427fd4b052a8ccba4c024c863'; 10 | 11 | /// See also [firebaseAuth]. 12 | @ProviderFor(firebaseAuth) 13 | final firebaseAuthProvider = Provider.internal( 14 | firebaseAuth, 15 | name: r'firebaseAuthProvider', 16 | debugGetCreateSourceHash: 17 | const bool.fromEnvironment('dart.vm.product') ? null : _$firebaseAuthHash, 18 | dependencies: null, 19 | allTransitiveDependencies: null, 20 | ); 21 | 22 | @Deprecated('Will be removed in 3.0. Use Ref instead') 23 | // ignore: unused_element 24 | typedef FirebaseAuthRef = ProviderRef; 25 | String _$authRepositoryHash() => r'0e32dee9e183c43ec14a6b58d74d26deb3950cbc'; 26 | 27 | /// See also [authRepository]. 28 | @ProviderFor(authRepository) 29 | final authRepositoryProvider = Provider.internal( 30 | authRepository, 31 | name: r'authRepositoryProvider', 32 | debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') 33 | ? null 34 | : _$authRepositoryHash, 35 | dependencies: null, 36 | allTransitiveDependencies: null, 37 | ); 38 | 39 | @Deprecated('Will be removed in 3.0. Use Ref instead') 40 | // ignore: unused_element 41 | typedef AuthRepositoryRef = ProviderRef; 42 | String _$authStateChangesHash() => r'7bdb56f405df8ffc5554e0128ec15d474f011ec9'; 43 | 44 | /// See also [authStateChanges]. 45 | @ProviderFor(authStateChanges) 46 | final authStateChangesProvider = AutoDisposeStreamProvider.internal( 47 | authStateChanges, 48 | name: r'authStateChangesProvider', 49 | debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') 50 | ? null 51 | : _$authStateChangesHash, 52 | dependencies: null, 53 | allTransitiveDependencies: null, 54 | ); 55 | 56 | @Deprecated('Will be removed in 3.0. Use Ref instead') 57 | // ignore: unused_element 58 | typedef AuthStateChangesRef = AutoDisposeStreamProviderRef; 59 | // ignore_for_file: type=lint 60 | // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package 61 | -------------------------------------------------------------------------------- /lib/src/features/authentication/domain/app_user.dart: -------------------------------------------------------------------------------- 1 | /// Type defining a user ID from Firebase. 2 | typedef UserID = String; 3 | 4 | /// Simple class representing the user UID and email. 5 | class AppUser { 6 | const AppUser({ 7 | required this.uid, 8 | required this.email, 9 | }); 10 | final String uid; 11 | final String email; 12 | 13 | @override 14 | bool operator ==(Object other) { 15 | if (identical(this, other)) return true; 16 | 17 | return other is AppUser && other.uid == uid && other.email == email; 18 | } 19 | 20 | @override 21 | int get hashCode => uid.hashCode ^ email.hashCode; 22 | 23 | @override 24 | String toString() => 'AppUser(uid: $uid, email: $email)'; 25 | } 26 | -------------------------------------------------------------------------------- /lib/src/features/authentication/presentation/auth_providers.dart: -------------------------------------------------------------------------------- 1 | import 'package:firebase_auth/firebase_auth.dart' 2 | hide EmailAuthProvider, AuthProvider; 3 | import 'package:firebase_ui_auth/firebase_ui_auth.dart'; 4 | import 'package:riverpod/riverpod.dart'; 5 | import 'package:riverpod_annotation/riverpod_annotation.dart'; 6 | 7 | part 'auth_providers.g.dart'; 8 | 9 | @Riverpod(keepAlive: true) 10 | List> authProviders( 11 | Ref ref) { 12 | return [ 13 | EmailAuthProvider(), 14 | ]; 15 | } 16 | -------------------------------------------------------------------------------- /lib/src/features/authentication/presentation/auth_providers.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'auth_providers.dart'; 4 | 5 | // ************************************************************************** 6 | // RiverpodGenerator 7 | // ************************************************************************** 8 | 9 | String _$authProvidersHash() => r'8a83535c31539dac72f21c3f27b7d7fb77161e5f'; 10 | 11 | /// See also [authProviders]. 12 | @ProviderFor(authProviders) 13 | final authProvidersProvider = 14 | Provider>>.internal( 15 | authProviders, 16 | name: r'authProvidersProvider', 17 | debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') 18 | ? null 19 | : _$authProvidersHash, 20 | dependencies: null, 21 | allTransitiveDependencies: null, 22 | ); 23 | 24 | @Deprecated('Will be removed in 3.0. Use Ref instead') 25 | // ignore: unused_element 26 | typedef AuthProvidersRef 27 | = ProviderRef>>; 28 | // ignore_for_file: type=lint 29 | // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package 30 | -------------------------------------------------------------------------------- /lib/src/features/authentication/presentation/custom_profile_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:firebase_ui_auth/firebase_ui_auth.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 | import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/auth_providers.dart'; 5 | 6 | class CustomProfileScreen extends ConsumerWidget { 7 | const CustomProfileScreen({super.key}); 8 | 9 | @override 10 | Widget build(BuildContext context, WidgetRef ref) { 11 | final authProviders = ref.watch(authProvidersProvider); 12 | return ProfileScreen( 13 | appBar: AppBar( 14 | title: const Text('Profile'), 15 | ), 16 | providers: authProviders, 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /lib/src/features/authentication/presentation/custom_sign_in_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:firebase_ui_auth/firebase_ui_auth.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 | import 'package:starter_architecture_flutter_firebase/src/constants/app_sizes.dart'; 5 | import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; 6 | 7 | import 'auth_providers.dart'; 8 | 9 | class CustomSignInScreen extends ConsumerWidget { 10 | const CustomSignInScreen({super.key}); 11 | 12 | @override 13 | Widget build(BuildContext context, WidgetRef ref) { 14 | final authProviders = ref.watch(authProvidersProvider); 15 | return Scaffold( 16 | appBar: AppBar( 17 | title: const Text('Sign in'), 18 | ), 19 | body: SignInScreen( 20 | providers: authProviders, 21 | footerBuilder: (context, action) => const SignInAnonymouslyFooter(), 22 | ), 23 | ); 24 | } 25 | } 26 | 27 | class SignInAnonymouslyFooter extends ConsumerWidget { 28 | const SignInAnonymouslyFooter({super.key}); 29 | 30 | @override 31 | Widget build(BuildContext context, WidgetRef ref) { 32 | return Column( 33 | children: [ 34 | gapH8, 35 | const Row( 36 | children: [ 37 | Expanded(child: Divider()), 38 | Padding( 39 | padding: EdgeInsets.symmetric(horizontal: Sizes.p8), 40 | child: Text('or'), 41 | ), 42 | Expanded(child: Divider()), 43 | ], 44 | ), 45 | TextButton( 46 | onPressed: () => ref.read(firebaseAuthProvider).signInAnonymously(), 47 | child: const Text('Sign in anonymously'), 48 | ), 49 | ], 50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /lib/src/features/entries/application/entries_service.dart: -------------------------------------------------------------------------------- 1 | import 'package:riverpod/riverpod.dart'; 2 | import 'package:riverpod_annotation/riverpod_annotation.dart'; 3 | import 'package:rxdart/rxdart.dart'; 4 | import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; 5 | import 'package:starter_architecture_flutter_firebase/src/features/authentication/domain/app_user.dart'; 6 | import 'package:starter_architecture_flutter_firebase/src/features/entries/data/entries_repository.dart'; 7 | import 'package:starter_architecture_flutter_firebase/src/features/entries/domain/daily_jobs_details.dart'; 8 | import 'package:starter_architecture_flutter_firebase/src/features/entries/domain/entries_list_tile_model.dart'; 9 | import 'package:starter_architecture_flutter_firebase/src/features/entries/domain/entry_job.dart'; 10 | import 'package:starter_architecture_flutter_firebase/src/features/jobs/data/jobs_repository.dart'; 11 | import 'package:starter_architecture_flutter_firebase/src/utils/format.dart'; 12 | import 'package:starter_architecture_flutter_firebase/src/features/entries/domain/entry.dart'; 13 | import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; 14 | 15 | part 'entries_service.g.dart'; 16 | 17 | // TODO: Clean up this code a bit more 18 | class EntriesService { 19 | EntriesService( 20 | {required this.jobsRepository, required this.entriesRepository}); 21 | final JobsRepository jobsRepository; 22 | final EntriesRepository entriesRepository; 23 | 24 | /// combine List, List into List 25 | Stream> _allEntriesStream(UserID uid) => 26 | CombineLatestStream.combine2( 27 | entriesRepository.watchEntries(uid: uid), 28 | jobsRepository.watchJobs(uid: uid), 29 | _entriesJobsCombiner, 30 | ); 31 | 32 | static List _entriesJobsCombiner( 33 | List entries, List jobs) { 34 | return entries.map((entry) { 35 | final job = jobs.firstWhere((job) => job.id == entry.jobId); 36 | return EntryJob(entry, job); 37 | }).toList(); 38 | } 39 | 40 | /// Output stream 41 | Stream> entriesTileModelStream(UserID uid) => 42 | _allEntriesStream(uid).map(_createModels); 43 | 44 | static List _createModels(List allEntries) { 45 | if (allEntries.isEmpty) { 46 | return []; 47 | } 48 | final allDailyJobsDetails = DailyJobsDetails.all(allEntries); 49 | 50 | // total duration across all jobs 51 | final totalDuration = allDailyJobsDetails 52 | .map((dateJobsDuration) => dateJobsDuration.duration) 53 | .reduce((value, element) => value + element); 54 | 55 | // total pay across all jobs 56 | final totalPay = allDailyJobsDetails 57 | .map((dateJobsDuration) => dateJobsDuration.pay) 58 | .reduce((value, element) => value + element); 59 | 60 | return [ 61 | EntriesListTileModel( 62 | leadingText: 'All Entries', 63 | middleText: Format.currency(totalPay), 64 | trailingText: Format.hours(totalDuration), 65 | ), 66 | for (DailyJobsDetails dailyJobsDetails in allDailyJobsDetails) ...[ 67 | EntriesListTileModel( 68 | isHeader: true, 69 | leadingText: Format.date(dailyJobsDetails.date), 70 | middleText: Format.currency(dailyJobsDetails.pay), 71 | trailingText: Format.hours(dailyJobsDetails.duration), 72 | ), 73 | for (JobDetails jobDuration in dailyJobsDetails.jobsDetails) 74 | EntriesListTileModel( 75 | leadingText: jobDuration.name, 76 | middleText: Format.currency(jobDuration.pay), 77 | trailingText: Format.hours(jobDuration.durationInHours), 78 | ), 79 | ] 80 | ]; 81 | } 82 | } 83 | 84 | @riverpod 85 | EntriesService entriesService(Ref ref) { 86 | return EntriesService( 87 | jobsRepository: ref.watch(jobsRepositoryProvider), 88 | entriesRepository: ref.watch(entriesRepositoryProvider), 89 | ); 90 | } 91 | 92 | @riverpod 93 | Stream> entriesTileModelStream( 94 | Ref ref) { 95 | final user = ref.watch(firebaseAuthProvider).currentUser; 96 | if (user == null) { 97 | throw AssertionError('User can\'t be null when fetching entries'); 98 | } 99 | final entriesService = ref.watch(entriesServiceProvider); 100 | return entriesService.entriesTileModelStream(user.uid); 101 | } 102 | -------------------------------------------------------------------------------- /lib/src/features/entries/application/entries_service.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'entries_service.dart'; 4 | 5 | // ************************************************************************** 6 | // RiverpodGenerator 7 | // ************************************************************************** 8 | 9 | String _$entriesServiceHash() => r'106c29e519ac1706956f952263745337399caba9'; 10 | 11 | /// See also [entriesService]. 12 | @ProviderFor(entriesService) 13 | final entriesServiceProvider = AutoDisposeProvider.internal( 14 | entriesService, 15 | name: r'entriesServiceProvider', 16 | debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') 17 | ? null 18 | : _$entriesServiceHash, 19 | dependencies: null, 20 | allTransitiveDependencies: null, 21 | ); 22 | 23 | @Deprecated('Will be removed in 3.0. Use Ref instead') 24 | // ignore: unused_element 25 | typedef EntriesServiceRef = AutoDisposeProviderRef; 26 | String _$entriesTileModelStreamHash() => 27 | r'e8f3184f1b1db43eb92198669492a36d3ee03356'; 28 | 29 | /// See also [entriesTileModelStream]. 30 | @ProviderFor(entriesTileModelStream) 31 | final entriesTileModelStreamProvider = 32 | AutoDisposeStreamProvider>.internal( 33 | entriesTileModelStream, 34 | name: r'entriesTileModelStreamProvider', 35 | debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') 36 | ? null 37 | : _$entriesTileModelStreamHash, 38 | dependencies: null, 39 | allTransitiveDependencies: null, 40 | ); 41 | 42 | @Deprecated('Will be removed in 3.0. Use Ref instead') 43 | // ignore: unused_element 44 | typedef EntriesTileModelStreamRef 45 | = AutoDisposeStreamProviderRef>; 46 | // ignore_for_file: type=lint 47 | // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package 48 | -------------------------------------------------------------------------------- /lib/src/features/entries/data/entries_repository.dart: -------------------------------------------------------------------------------- 1 | import 'package:cloud_firestore/cloud_firestore.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:riverpod_annotation/riverpod_annotation.dart'; 4 | import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; 5 | import 'package:starter_architecture_flutter_firebase/src/features/authentication/domain/app_user.dart'; 6 | import 'package:starter_architecture_flutter_firebase/src/features/entries/domain/entry.dart'; 7 | import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; 8 | 9 | part 'entries_repository.g.dart'; 10 | 11 | class EntriesRepository { 12 | const EntriesRepository(this._firestore); 13 | final FirebaseFirestore _firestore; 14 | 15 | static String entryPath(String uid, String entryId) => 16 | 'users/$uid/entries/$entryId'; 17 | static String entriesPath(String uid) => 'users/$uid/entries'; 18 | 19 | // create 20 | Future addEntry({ 21 | required UserID uid, 22 | required JobID jobId, 23 | required DateTime start, 24 | required DateTime end, 25 | required String comment, 26 | }) => 27 | _firestore.collection(entriesPath(uid)).add({ 28 | 'jobId': jobId, 29 | 'start': start.millisecondsSinceEpoch, 30 | 'end': end.millisecondsSinceEpoch, 31 | 'comment': comment, 32 | }); 33 | 34 | // update 35 | Future updateEntry({ 36 | required UserID uid, 37 | required Entry entry, 38 | }) => 39 | _firestore.doc(entryPath(uid, entry.id)).update(entry.toMap()); 40 | 41 | // delete 42 | Future deleteEntry({required UserID uid, required EntryID entryId}) => 43 | _firestore.doc(entryPath(uid, entryId)).delete(); 44 | 45 | // read 46 | Stream> watchEntries({required UserID uid, JobID? jobId}) => 47 | queryEntries(uid: uid, jobId: jobId) 48 | .snapshots() 49 | .map((snapshot) => snapshot.docs.map((doc) => doc.data()).toList()); 50 | 51 | Query queryEntries({required UserID uid, JobID? jobId}) { 52 | Query query = 53 | _firestore.collection(entriesPath(uid)).withConverter( 54 | fromFirestore: (snapshot, _) => 55 | Entry.fromMap(snapshot.data()!, snapshot.id), 56 | toFirestore: (entry, _) => entry.toMap(), 57 | ); 58 | if (jobId != null) { 59 | query = query.where('jobId', isEqualTo: jobId); 60 | } 61 | return query; 62 | } 63 | } 64 | 65 | @riverpod 66 | EntriesRepository entriesRepository(Ref ref) { 67 | return EntriesRepository(FirebaseFirestore.instance); 68 | } 69 | 70 | @riverpod 71 | Query jobEntriesQuery(Ref ref, String jobId) { 72 | final user = ref.watch(firebaseAuthProvider).currentUser; 73 | if (user == null) { 74 | throw AssertionError('User can\'t be null when fetching jobs'); 75 | } 76 | final repository = ref.watch(entriesRepositoryProvider); 77 | return repository.queryEntries(uid: user.uid, jobId: jobId); 78 | } 79 | -------------------------------------------------------------------------------- /lib/src/features/entries/domain/daily_jobs_details.dart: -------------------------------------------------------------------------------- 1 | import 'package:starter_architecture_flutter_firebase/src/features/entries/domain/entry_job.dart'; 2 | 3 | /// Temporary model class to store the time tracked and pay for a job 4 | class JobDetails { 5 | JobDetails({ 6 | required this.name, 7 | required this.durationInHours, 8 | required this.pay, 9 | }); 10 | final String name; 11 | double durationInHours; 12 | double pay; 13 | } 14 | 15 | /// Groups together all jobs/entries on a given day 16 | class DailyJobsDetails { 17 | DailyJobsDetails({required this.date, required this.jobsDetails}); 18 | final DateTime date; 19 | final List jobsDetails; 20 | 21 | double get pay => jobsDetails 22 | .map((jobDuration) => jobDuration.pay) 23 | .reduce((value, element) => value + element); 24 | 25 | double get duration => jobsDetails 26 | .map((jobDuration) => jobDuration.durationInHours) 27 | .reduce((value, element) => value + element); 28 | 29 | /// splits all entries into separate groups by date 30 | static Map> _entriesByDate(List entries) { 31 | final Map> map = {}; 32 | for (final entryJob in entries) { 33 | final entryDayStart = DateTime(entryJob.entry.start.year, 34 | entryJob.entry.start.month, entryJob.entry.start.day); 35 | if (map[entryDayStart] == null) { 36 | map[entryDayStart] = [entryJob]; 37 | } else { 38 | map[entryDayStart]!.add(entryJob); 39 | } 40 | } 41 | return map; 42 | } 43 | 44 | /// maps an unordered list of EntryJob into a list of DailyJobsDetails with date information 45 | static List all(List entries) { 46 | final byDate = _entriesByDate(entries); 47 | final List list = []; 48 | for (final pair in byDate.entries) { 49 | final date = pair.key; 50 | final entriesByDate = pair.value; 51 | final byJob = _jobsDetails(entriesByDate); 52 | list.add(DailyJobsDetails(date: date, jobsDetails: byJob)); 53 | } 54 | return list.toList(); 55 | } 56 | 57 | /// groups entries by job 58 | static List _jobsDetails(List entries) { 59 | final Map jobDuration = {}; 60 | for (final entryJob in entries) { 61 | final entry = entryJob.entry; 62 | final pay = entry.durationInHours * entryJob.job.ratePerHour; 63 | if (jobDuration[entry.jobId] == null) { 64 | jobDuration[entry.jobId] = JobDetails( 65 | name: entryJob.job.name, 66 | durationInHours: entry.durationInHours, 67 | pay: pay, 68 | ); 69 | } else { 70 | jobDuration[entry.jobId]!.pay += pay; 71 | jobDuration[entry.jobId]!.durationInHours += entry.durationInHours; 72 | } 73 | } 74 | return jobDuration.values.toList(); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /lib/src/features/entries/domain/entries_list_tile_model.dart: -------------------------------------------------------------------------------- 1 | class EntriesListTileModel { 2 | const EntriesListTileModel({ 3 | required this.leadingText, 4 | required this.trailingText, 5 | this.middleText, 6 | this.isHeader = false, 7 | }); 8 | final String leadingText; 9 | final String trailingText; 10 | final String? middleText; 11 | final bool isHeader; 12 | } 13 | -------------------------------------------------------------------------------- /lib/src/features/entries/domain/entry.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; 3 | 4 | typedef EntryID = String; 5 | 6 | class Entry extends Equatable { 7 | const Entry({ 8 | required this.id, 9 | required this.jobId, 10 | required this.start, 11 | required this.end, 12 | required this.comment, 13 | }); 14 | final EntryID id; 15 | final JobID jobId; 16 | final DateTime start; 17 | final DateTime end; 18 | final String comment; 19 | 20 | @override 21 | List get props => [id, jobId, start, end, comment]; 22 | 23 | @override 24 | bool get stringify => true; 25 | 26 | double get durationInHours => 27 | end.difference(start).inMinutes.toDouble() / 60.0; 28 | 29 | factory Entry.fromMap(Map value, EntryID id) { 30 | final startMilliseconds = value['start'] as int; 31 | final endMilliseconds = value['end'] as int; 32 | return Entry( 33 | id: id, 34 | jobId: value['jobId'] as String, 35 | start: DateTime.fromMillisecondsSinceEpoch(startMilliseconds), 36 | end: DateTime.fromMillisecondsSinceEpoch(endMilliseconds), 37 | comment: value['comment'] as String? ?? '', 38 | ); 39 | } 40 | 41 | Map toMap() { 42 | return { 43 | 'jobId': jobId, 44 | 'start': start.millisecondsSinceEpoch, 45 | 'end': end.millisecondsSinceEpoch, 46 | 'comment': comment, 47 | }; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /lib/src/features/entries/domain/entry_job.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:starter_architecture_flutter_firebase/src/features/entries/domain/entry.dart'; 3 | import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; 4 | 5 | class EntryJob extends Equatable { 6 | const EntryJob(this.entry, this.job); 7 | 8 | final Entry entry; 9 | final Job job; 10 | 11 | @override 12 | List get props => [entry, job]; 13 | 14 | @override 15 | bool? get stringify => true; 16 | } 17 | -------------------------------------------------------------------------------- /lib/src/features/entries/presentation/entries_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:starter_architecture_flutter_firebase/src/constants/app_sizes.dart'; 4 | import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; 5 | import 'package:starter_architecture_flutter_firebase/src/features/entries/domain/entries_list_tile_model.dart'; 6 | import 'package:starter_architecture_flutter_firebase/src/features/entries/application/entries_service.dart'; 7 | import 'package:starter_architecture_flutter_firebase/src/common_widgets/list_items_builder.dart'; 8 | 9 | class EntriesScreen extends ConsumerWidget { 10 | const EntriesScreen({super.key}); 11 | 12 | @override 13 | Widget build(BuildContext context, WidgetRef ref) { 14 | return Scaffold( 15 | appBar: AppBar( 16 | title: const Text(Strings.entries), 17 | ), 18 | body: Consumer( 19 | builder: (context, ref, child) { 20 | // * This data is combined from two streams, so it can't be returned 21 | // * directly as a Query object from the repository. 22 | // * As a result, we can't use FirestoreListView here. 23 | final entriesTileModelStream = 24 | ref.watch(entriesTileModelStreamProvider); 25 | return ListItemsBuilder( 26 | data: entriesTileModelStream, 27 | itemBuilder: (context, model) => EntriesListTile(model: model), 28 | ); 29 | }, 30 | ), 31 | ); 32 | } 33 | } 34 | 35 | class EntriesListTile extends StatelessWidget { 36 | const EntriesListTile({super.key, required this.model}); 37 | final EntriesListTileModel model; 38 | 39 | @override 40 | Widget build(BuildContext context) { 41 | const fontSize = 16.0; 42 | return Container( 43 | color: model.isHeader ? Colors.indigo[100] : null, 44 | padding: const EdgeInsets.symmetric( 45 | vertical: Sizes.p8, 46 | horizontal: Sizes.p16, 47 | ), 48 | child: Row( 49 | children: [ 50 | Text(model.leadingText, style: const TextStyle(fontSize: fontSize)), 51 | Expanded(child: Container()), 52 | if (model.middleText != null) 53 | Text( 54 | model.middleText!, 55 | style: TextStyle(color: Colors.green[700], fontSize: fontSize), 56 | textAlign: TextAlign.right, 57 | ), 58 | SizedBox( 59 | width: 60.0, 60 | child: Text( 61 | model.trailingText, 62 | style: const TextStyle(fontSize: fontSize), 63 | textAlign: TextAlign.right, 64 | ), 65 | ), 66 | ], 67 | ), 68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /lib/src/features/entries/presentation/entry_screen/entry_screen.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 | import 'package:go_router/go_router.dart'; 6 | import 'package:starter_architecture_flutter_firebase/src/common_widgets/date_time_picker.dart'; 7 | import 'package:starter_architecture_flutter_firebase/src/common_widgets/responsive_center.dart'; 8 | import 'package:starter_architecture_flutter_firebase/src/constants/app_sizes.dart'; 9 | import 'package:starter_architecture_flutter_firebase/src/constants/breakpoints.dart'; 10 | import 'package:starter_architecture_flutter_firebase/src/features/entries/domain/entry.dart'; 11 | import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; 12 | import 'package:starter_architecture_flutter_firebase/src/features/entries/presentation/entry_screen/entry_screen_controller.dart'; 13 | import 'package:starter_architecture_flutter_firebase/src/utils/async_value_ui.dart'; 14 | import 'package:starter_architecture_flutter_firebase/src/utils/format.dart'; 15 | 16 | class EntryScreen extends ConsumerStatefulWidget { 17 | const EntryScreen({super.key, required this.jobId, this.entryId, this.entry}); 18 | final JobID jobId; 19 | final EntryID? entryId; 20 | final Entry? entry; 21 | 22 | @override 23 | ConsumerState createState() => _EntryPageState(); 24 | } 25 | 26 | class _EntryPageState extends ConsumerState { 27 | late DateTime _startDate; 28 | late TimeOfDay _startTime; 29 | late DateTime _endDate; 30 | late TimeOfDay _endTime; 31 | late String _comment; 32 | 33 | DateTime get start => DateTime(_startDate.year, _startDate.month, 34 | _startDate.day, _startTime.hour, _startTime.minute); 35 | DateTime get end => DateTime(_endDate.year, _endDate.month, _endDate.day, 36 | _endTime.hour, _endTime.minute); 37 | 38 | @override 39 | void initState() { 40 | super.initState(); 41 | final start = widget.entry?.start ?? DateTime.now(); 42 | _startDate = DateTime(start.year, start.month, start.day); 43 | _startTime = TimeOfDay.fromDateTime(start); 44 | 45 | final end = widget.entry?.end ?? DateTime.now(); 46 | _endDate = DateTime(end.year, end.month, end.day); 47 | _endTime = TimeOfDay.fromDateTime(end); 48 | 49 | _comment = widget.entry?.comment ?? ''; 50 | } 51 | 52 | Future _setEntryAndDismiss() async { 53 | final success = 54 | await ref.read(entryScreenControllerProvider.notifier).submit( 55 | entryId: widget.entryId, 56 | jobId: widget.jobId, 57 | start: start, 58 | end: end, 59 | comment: _comment, 60 | ); 61 | if (success && mounted) { 62 | context.pop(); 63 | } 64 | } 65 | 66 | @override 67 | Widget build(BuildContext context) { 68 | ref.listen( 69 | entryScreenControllerProvider, 70 | (_, state) => state.showAlertDialogOnError(context), 71 | ); 72 | return Scaffold( 73 | appBar: AppBar( 74 | title: Text(widget.entry != null ? 'Edit Entry' : 'New Entry'), 75 | actions: [ 76 | TextButton( 77 | child: Text( 78 | widget.entry != null ? 'Update' : 'Create', 79 | style: const TextStyle(fontSize: 18.0, color: Colors.white), 80 | ), 81 | onPressed: () => _setEntryAndDismiss(), 82 | ), 83 | ], 84 | ), 85 | body: SingleChildScrollView( 86 | child: ResponsiveCenter( 87 | maxContentWidth: Breakpoint.tablet, 88 | padding: const EdgeInsets.all(Sizes.p16), 89 | child: Column( 90 | mainAxisSize: MainAxisSize.min, 91 | crossAxisAlignment: CrossAxisAlignment.start, 92 | children: [ 93 | _buildStartDate(), 94 | _buildEndDate(), 95 | gapH8, 96 | _buildDuration(), 97 | gapH8, 98 | _buildComment(), 99 | ], 100 | ), 101 | ), 102 | ), 103 | ); 104 | } 105 | 106 | Widget _buildStartDate() { 107 | return DateTimePicker( 108 | labelText: 'Start', 109 | selectedDate: _startDate, 110 | selectedTime: _startTime, 111 | onSelectedDate: (date) => setState(() => _startDate = date), 112 | onSelectedTime: (time) => setState(() => _startTime = time), 113 | ); 114 | } 115 | 116 | Widget _buildEndDate() { 117 | return DateTimePicker( 118 | labelText: 'End', 119 | selectedDate: _endDate, 120 | selectedTime: _endTime, 121 | onSelectedDate: (date) => setState(() => _endDate = date), 122 | onSelectedTime: (time) => setState(() => _endTime = time), 123 | ); 124 | } 125 | 126 | Widget _buildDuration() { 127 | final durationInHours = end.difference(start).inMinutes.toDouble() / 60.0; 128 | final durationFormatted = Format.hours(durationInHours); 129 | return Row( 130 | mainAxisAlignment: MainAxisAlignment.end, 131 | children: [ 132 | Text( 133 | 'Duration: $durationFormatted', 134 | style: const TextStyle(fontSize: 18.0, fontWeight: FontWeight.w500), 135 | maxLines: 1, 136 | overflow: TextOverflow.ellipsis, 137 | ), 138 | ], 139 | ); 140 | } 141 | 142 | Widget _buildComment() { 143 | return TextField( 144 | keyboardType: TextInputType.text, 145 | maxLength: 50, 146 | controller: TextEditingController(text: _comment), 147 | decoration: const InputDecoration( 148 | labelText: 'Comment', 149 | labelStyle: TextStyle(fontSize: 18.0, fontWeight: FontWeight.w500), 150 | ), 151 | keyboardAppearance: Brightness.light, 152 | style: const TextStyle(fontSize: 20.0, color: Colors.black), 153 | maxLines: null, 154 | onChanged: (comment) => _comment = comment, 155 | ); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /lib/src/features/entries/presentation/entry_screen/entry_screen_controller.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:riverpod_annotation/riverpod_annotation.dart'; 4 | import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; 5 | import 'package:starter_architecture_flutter_firebase/src/features/entries/data/entries_repository.dart'; 6 | import 'package:starter_architecture_flutter_firebase/src/features/entries/domain/entry.dart'; 7 | import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; 8 | 9 | part 'entry_screen_controller.g.dart'; 10 | 11 | @riverpod 12 | class EntryScreenController extends _$EntryScreenController { 13 | @override 14 | FutureOr build() { 15 | // ok to leave this empty if the return type is FutureOr 16 | } 17 | 18 | Future submit({ 19 | EntryID? entryId, 20 | required JobID jobId, 21 | required DateTime start, 22 | required DateTime end, 23 | required String comment, 24 | }) async { 25 | final currentUser = ref.read(authRepositoryProvider).currentUser; 26 | if (currentUser == null) { 27 | throw AssertionError('User can\'t be null'); 28 | } 29 | final repository = ref.read(entriesRepositoryProvider); 30 | state = const AsyncLoading(); 31 | if (entryId == null) { 32 | state = await AsyncValue.guard(() => repository.addEntry( 33 | uid: currentUser.uid, 34 | jobId: jobId, 35 | start: start, 36 | end: end, 37 | comment: comment, 38 | )); 39 | } else { 40 | final entry = Entry( 41 | id: entryId, 42 | jobId: jobId, 43 | start: start, 44 | end: end, 45 | comment: comment, 46 | ); 47 | state = await AsyncValue.guard( 48 | () => repository.updateEntry(uid: currentUser.uid, entry: entry)); 49 | } 50 | return state.hasError == false; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /lib/src/features/entries/presentation/entry_screen/entry_screen_controller.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'entry_screen_controller.dart'; 4 | 5 | // ************************************************************************** 6 | // RiverpodGenerator 7 | // ************************************************************************** 8 | 9 | String _$entryScreenControllerHash() => 10 | r'75638e7eac6bacd498349a143fc5fc827171674a'; 11 | 12 | /// See also [EntryScreenController]. 13 | @ProviderFor(EntryScreenController) 14 | final entryScreenControllerProvider = 15 | AutoDisposeAsyncNotifierProvider.internal( 16 | EntryScreenController.new, 17 | name: r'entryScreenControllerProvider', 18 | debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') 19 | ? null 20 | : _$entryScreenControllerHash, 21 | dependencies: null, 22 | allTransitiveDependencies: null, 23 | ); 24 | 25 | typedef _$EntryScreenController = AutoDisposeAsyncNotifier; 26 | // ignore_for_file: type=lint 27 | // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package 28 | -------------------------------------------------------------------------------- /lib/src/features/jobs/data/jobs_repository.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:cloud_firestore/cloud_firestore.dart'; 4 | import 'package:riverpod/riverpod.dart'; 5 | import 'package:riverpod_annotation/riverpod_annotation.dart'; 6 | import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; 7 | import 'package:starter_architecture_flutter_firebase/src/features/authentication/domain/app_user.dart'; 8 | import 'package:starter_architecture_flutter_firebase/src/features/entries/data/entries_repository.dart'; 9 | import 'package:starter_architecture_flutter_firebase/src/features/entries/domain/entry.dart'; 10 | import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; 11 | 12 | part 'jobs_repository.g.dart'; 13 | 14 | class JobsRepository { 15 | const JobsRepository(this._firestore); 16 | final FirebaseFirestore _firestore; 17 | 18 | static String jobPath(String uid, String jobId) => 'users/$uid/jobs/$jobId'; 19 | static String jobsPath(String uid) => 'users/$uid/jobs'; 20 | static String entriesPath(String uid) => EntriesRepository.entriesPath(uid); 21 | 22 | // create 23 | Future addJob( 24 | {required UserID uid, 25 | required String name, 26 | required int ratePerHour}) => 27 | _firestore.collection(jobsPath(uid)).add({ 28 | 'name': name, 29 | 'ratePerHour': ratePerHour, 30 | }); 31 | 32 | // update 33 | Future updateJob({required UserID uid, required Job job}) => 34 | _firestore.doc(jobPath(uid, job.id)).update(job.toMap()); 35 | 36 | // delete 37 | Future deleteJob({required UserID uid, required JobID jobId}) async { 38 | // delete where entry.jobId == job.jobId 39 | final entriesRef = _firestore.collection(entriesPath(uid)); 40 | final entries = await entriesRef.get(); 41 | for (final snapshot in entries.docs) { 42 | final entry = Entry.fromMap(snapshot.data(), snapshot.id); 43 | if (entry.jobId == jobId) { 44 | await snapshot.reference.delete(); 45 | } 46 | } 47 | // delete job 48 | final jobRef = _firestore.doc(jobPath(uid, jobId)); 49 | await jobRef.delete(); 50 | } 51 | 52 | // read 53 | Stream watchJob({required UserID uid, required JobID jobId}) => 54 | _firestore 55 | .doc(jobPath(uid, jobId)) 56 | .withConverter( 57 | fromFirestore: (snapshot, _) => 58 | Job.fromMap(snapshot.data()!, snapshot.id), 59 | toFirestore: (job, _) => job.toMap(), 60 | ) 61 | .snapshots() 62 | .map((snapshot) => snapshot.data()!); 63 | 64 | Stream> watchJobs({required UserID uid}) => queryJobs(uid: uid) 65 | .snapshots() 66 | .map((snapshot) => snapshot.docs.map((doc) => doc.data()).toList()); 67 | 68 | Query queryJobs({required UserID uid}) => 69 | _firestore.collection(jobsPath(uid)).withConverter( 70 | fromFirestore: (snapshot, _) => 71 | Job.fromMap(snapshot.data()!, snapshot.id), 72 | toFirestore: (job, _) => job.toMap(), 73 | ); 74 | 75 | Future> fetchJobs({required UserID uid}) async { 76 | final jobs = await queryJobs(uid: uid).get(); 77 | return jobs.docs.map((doc) => doc.data()).toList(); 78 | } 79 | } 80 | 81 | @Riverpod(keepAlive: true) 82 | JobsRepository jobsRepository(Ref ref) { 83 | return JobsRepository(FirebaseFirestore.instance); 84 | } 85 | 86 | @riverpod 87 | Query jobsQuery(Ref ref) { 88 | final user = ref.watch(firebaseAuthProvider).currentUser; 89 | if (user == null) { 90 | throw AssertionError('User can\'t be null'); 91 | } 92 | final repository = ref.watch(jobsRepositoryProvider); 93 | return repository.queryJobs(uid: user.uid); 94 | } 95 | 96 | @riverpod 97 | Stream jobStream(Ref ref, JobID jobId) { 98 | final user = ref.watch(firebaseAuthProvider).currentUser; 99 | if (user == null) { 100 | throw AssertionError('User can\'t be null'); 101 | } 102 | final repository = ref.watch(jobsRepositoryProvider); 103 | return repository.watchJob(uid: user.uid, jobId: jobId); 104 | } 105 | -------------------------------------------------------------------------------- /lib/src/features/jobs/domain/job.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:flutter/foundation.dart'; 3 | 4 | typedef JobID = String; 5 | 6 | @immutable 7 | class Job extends Equatable { 8 | const Job({required this.id, required this.name, required this.ratePerHour}); 9 | final JobID id; 10 | final String name; 11 | final int ratePerHour; 12 | 13 | @override 14 | List get props => [name, ratePerHour]; 15 | 16 | @override 17 | bool get stringify => true; 18 | 19 | factory Job.fromMap(Map data, String id) { 20 | final name = data['name'] as String; 21 | final ratePerHour = data['ratePerHour'] as int; 22 | return Job( 23 | id: id, 24 | name: name, 25 | ratePerHour: ratePerHour, 26 | ); 27 | } 28 | 29 | Map toMap() { 30 | return { 31 | 'name': name, 32 | 'ratePerHour': ratePerHour, 33 | }; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 | import 'package:go_router/go_router.dart'; 6 | import 'package:starter_architecture_flutter_firebase/src/common_widgets/responsive_center.dart'; 7 | import 'package:starter_architecture_flutter_firebase/src/constants/breakpoints.dart'; 8 | import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; 9 | import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/edit_job_screen/edit_job_screen_controller.dart'; 10 | import 'package:starter_architecture_flutter_firebase/src/utils/async_value_ui.dart'; 11 | 12 | class EditJobScreen extends ConsumerStatefulWidget { 13 | const EditJobScreen({super.key, this.jobId, this.job}); 14 | final JobID? jobId; 15 | final Job? job; 16 | 17 | @override 18 | ConsumerState createState() => _EditJobPageState(); 19 | } 20 | 21 | class _EditJobPageState extends ConsumerState { 22 | final _formKey = GlobalKey(); 23 | 24 | String? _name; 25 | int? _ratePerHour; 26 | 27 | @override 28 | void initState() { 29 | super.initState(); 30 | if (widget.job != null) { 31 | _name = widget.job?.name; 32 | _ratePerHour = widget.job?.ratePerHour; 33 | } 34 | } 35 | 36 | bool _validateAndSaveForm() { 37 | final form = _formKey.currentState!; 38 | if (form.validate()) { 39 | form.save(); 40 | return true; 41 | } 42 | return false; 43 | } 44 | 45 | Future _submit() async { 46 | if (_validateAndSaveForm()) { 47 | final success = 48 | await ref.read(editJobScreenControllerProvider.notifier).submit( 49 | jobId: widget.jobId, 50 | oldJob: widget.job, 51 | name: _name ?? '', 52 | ratePerHour: _ratePerHour ?? 0, 53 | ); 54 | if (success && mounted) { 55 | context.pop(); 56 | } 57 | } 58 | } 59 | 60 | @override 61 | Widget build(BuildContext context) { 62 | ref.listen( 63 | editJobScreenControllerProvider, 64 | (_, state) => state.showAlertDialogOnError(context), 65 | ); 66 | final state = ref.watch(editJobScreenControllerProvider); 67 | return Scaffold( 68 | appBar: AppBar( 69 | title: Text(widget.job == null ? 'New Job' : 'Edit Job'), 70 | actions: [ 71 | TextButton( 72 | onPressed: state.isLoading ? null : _submit, 73 | child: const Text( 74 | 'Save', 75 | style: TextStyle(fontSize: 18, color: Colors.white), 76 | ), 77 | ), 78 | ], 79 | ), 80 | body: _buildContents(), 81 | ); 82 | } 83 | 84 | Widget _buildContents() { 85 | return SingleChildScrollView( 86 | child: ResponsiveCenter( 87 | maxContentWidth: Breakpoint.tablet, 88 | padding: const EdgeInsets.all(16.0), 89 | child: Card( 90 | child: Padding( 91 | padding: const EdgeInsets.all(16.0), 92 | child: _buildForm(), 93 | ), 94 | ), 95 | ), 96 | ); 97 | } 98 | 99 | Widget _buildForm() { 100 | return Form( 101 | key: _formKey, 102 | child: Column( 103 | crossAxisAlignment: CrossAxisAlignment.stretch, 104 | children: _buildFormChildren(), 105 | ), 106 | ); 107 | } 108 | 109 | List _buildFormChildren() { 110 | return [ 111 | TextFormField( 112 | decoration: const InputDecoration(labelText: 'Job name'), 113 | keyboardAppearance: Brightness.light, 114 | initialValue: _name, 115 | validator: (value) => 116 | (value ?? '').isNotEmpty ? null : 'Name can\'t be empty', 117 | onSaved: (value) => _name = value, 118 | ), 119 | TextFormField( 120 | decoration: const InputDecoration(labelText: 'Rate per hour'), 121 | keyboardAppearance: Brightness.light, 122 | initialValue: _ratePerHour != null ? '$_ratePerHour' : null, 123 | keyboardType: const TextInputType.numberWithOptions( 124 | signed: false, 125 | decimal: false, 126 | ), 127 | onSaved: (value) => _ratePerHour = int.tryParse(value ?? '') ?? 0, 128 | ), 129 | ]; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen_controller.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:riverpod_annotation/riverpod_annotation.dart'; 4 | import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; 5 | import 'package:starter_architecture_flutter_firebase/src/features/jobs/data/jobs_repository.dart'; 6 | import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; 7 | import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/edit_job_screen/job_submit_exception.dart'; 8 | 9 | part 'edit_job_screen_controller.g.dart'; 10 | 11 | @riverpod 12 | class EditJobScreenController extends _$EditJobScreenController { 13 | @override 14 | FutureOr build() { 15 | // 16 | } 17 | 18 | Future submit( 19 | {JobID? jobId, 20 | Job? oldJob, 21 | required String name, 22 | required int ratePerHour}) async { 23 | final currentUser = ref.read(authRepositoryProvider).currentUser; 24 | if (currentUser == null) { 25 | throw AssertionError('User can\'t be null'); 26 | } 27 | // set loading state 28 | state = const AsyncLoading().copyWithPrevious(state); 29 | // check if name is already in use 30 | final repository = ref.read(jobsRepositoryProvider); 31 | final jobs = await repository.fetchJobs(uid: currentUser.uid); 32 | final allLowerCaseNames = 33 | jobs.map((job) => job.name.toLowerCase()).toList(); 34 | // it's ok to use the same name as the old job 35 | if (oldJob != null) { 36 | allLowerCaseNames.remove(oldJob.name.toLowerCase()); 37 | } 38 | // check if name is already used 39 | if (allLowerCaseNames.contains(name.toLowerCase())) { 40 | state = AsyncError(JobSubmitException(), StackTrace.current); 41 | return false; 42 | } else { 43 | // job previously existed 44 | if (jobId != null) { 45 | final job = Job(id: jobId, name: name, ratePerHour: ratePerHour); 46 | state = await AsyncValue.guard( 47 | () => repository.updateJob(uid: currentUser.uid, job: job), 48 | ); 49 | } else { 50 | state = await AsyncValue.guard( 51 | () => repository.addJob( 52 | uid: currentUser.uid, name: name, ratePerHour: ratePerHour), 53 | ); 54 | } 55 | return state.hasError == false; 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen_controller.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'edit_job_screen_controller.dart'; 4 | 5 | // ************************************************************************** 6 | // RiverpodGenerator 7 | // ************************************************************************** 8 | 9 | String _$editJobScreenControllerHash() => 10 | r'e2985913f443860f6aa9d1b0aa462d4e5c25bed4'; 11 | 12 | /// See also [EditJobScreenController]. 13 | @ProviderFor(EditJobScreenController) 14 | final editJobScreenControllerProvider = 15 | AutoDisposeAsyncNotifierProvider.internal( 16 | EditJobScreenController.new, 17 | name: r'editJobScreenControllerProvider', 18 | debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') 19 | ? null 20 | : _$editJobScreenControllerHash, 21 | dependencies: null, 22 | allTransitiveDependencies: null, 23 | ); 24 | 25 | typedef _$EditJobScreenController = AutoDisposeAsyncNotifier; 26 | // ignore_for_file: type=lint 27 | // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package 28 | -------------------------------------------------------------------------------- /lib/src/features/jobs/presentation/edit_job_screen/job_submit_exception.dart: -------------------------------------------------------------------------------- 1 | class JobSubmitException { 2 | String get title => 'Name already used'; 3 | String get description => 'Please choose a different job name'; 4 | 5 | @override 6 | String toString() { 7 | return '$title. $description.'; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /lib/src/features/jobs/presentation/job_entries_screen/entry_list_item.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:starter_architecture_flutter_firebase/src/constants/app_sizes.dart'; 3 | import 'package:starter_architecture_flutter_firebase/src/utils/format.dart'; 4 | import 'package:starter_architecture_flutter_firebase/src/features/entries/domain/entry.dart'; 5 | import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; 6 | 7 | class EntryListItem extends StatelessWidget { 8 | const EntryListItem({ 9 | super.key, 10 | required this.entry, 11 | required this.job, 12 | this.onTap, 13 | }); 14 | 15 | final Entry entry; 16 | final Job job; 17 | final VoidCallback? onTap; 18 | 19 | @override 20 | Widget build(BuildContext context) { 21 | return InkWell( 22 | onTap: onTap, 23 | child: Container( 24 | padding: const EdgeInsets.symmetric( 25 | horizontal: Sizes.p16, 26 | vertical: Sizes.p8, 27 | ), 28 | child: Row( 29 | children: [ 30 | Expanded( 31 | child: _buildContents(context), 32 | ), 33 | const Icon(Icons.chevron_right, color: Colors.grey), 34 | ], 35 | ), 36 | ), 37 | ); 38 | } 39 | 40 | Widget _buildContents(BuildContext context) { 41 | final dayOfWeek = Format.dayOfWeek(entry.start); 42 | final startDate = Format.date(entry.start); 43 | final startTime = TimeOfDay.fromDateTime(entry.start).format(context); 44 | final endTime = TimeOfDay.fromDateTime(entry.end).format(context); 45 | final durationFormatted = Format.hours(entry.durationInHours); 46 | 47 | final pay = job.ratePerHour * entry.durationInHours; 48 | final payFormatted = Format.currency(pay); 49 | 50 | return Column( 51 | crossAxisAlignment: CrossAxisAlignment.start, 52 | children: [ 53 | Row(children: [ 54 | Text(dayOfWeek, 55 | style: const TextStyle(fontSize: 18.0, color: Colors.grey)), 56 | gapW16, 57 | Text(startDate, style: const TextStyle(fontSize: 18.0)), 58 | if (job.ratePerHour > 0.0) ...[ 59 | Expanded(child: Container()), 60 | Text( 61 | payFormatted, 62 | style: TextStyle(fontSize: 16.0, color: Colors.green[700]), 63 | ), 64 | ], 65 | ]), 66 | Row(children: [ 67 | Text('$startTime - $endTime', style: const TextStyle(fontSize: 16.0)), 68 | Expanded(child: Container()), 69 | Text(durationFormatted, style: const TextStyle(fontSize: 16.0)), 70 | ]), 71 | if (entry.comment.isNotEmpty) 72 | Text( 73 | entry.comment, 74 | style: const TextStyle(fontSize: 12.0), 75 | overflow: TextOverflow.ellipsis, 76 | maxLines: 1, 77 | ), 78 | ], 79 | ); 80 | } 81 | } 82 | 83 | class DismissibleEntryListItem extends StatelessWidget { 84 | const DismissibleEntryListItem({ 85 | super.key, 86 | required this.dismissibleKey, 87 | required this.entry, 88 | required this.job, 89 | this.onDismissed, 90 | this.onTap, 91 | }); 92 | 93 | final Key dismissibleKey; 94 | final Entry entry; 95 | final Job job; 96 | final VoidCallback? onDismissed; 97 | final VoidCallback? onTap; 98 | 99 | @override 100 | Widget build(BuildContext context) { 101 | return Dismissible( 102 | background: Container(color: Colors.red), 103 | key: dismissibleKey, 104 | direction: DismissDirection.endToStart, 105 | onDismissed: (direction) => onDismissed?.call(), 106 | child: EntryListItem( 107 | entry: entry, 108 | job: job, 109 | onTap: onTap, 110 | ), 111 | ); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /lib/src/features/jobs/presentation/job_entries_screen/job_entries_list.dart: -------------------------------------------------------------------------------- 1 | import 'package:firebase_ui_firestore/firebase_ui_firestore.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 | import 'package:go_router/go_router.dart'; 5 | import 'package:starter_architecture_flutter_firebase/src/features/entries/data/entries_repository.dart'; 6 | import 'package:starter_architecture_flutter_firebase/src/features/entries/domain/entry.dart'; 7 | import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; 8 | import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/job_entries_screen/entry_list_item.dart'; 9 | import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/job_entries_screen/job_entries_list_controller.dart'; 10 | import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; 11 | import 'package:starter_architecture_flutter_firebase/src/utils/async_value_ui.dart'; 12 | 13 | class JobEntriesList extends ConsumerWidget { 14 | const JobEntriesList({super.key, required this.job}); 15 | final Job job; 16 | 17 | @override 18 | Widget build(BuildContext context, WidgetRef ref) { 19 | ref.listen( 20 | jobsEntriesListControllerProvider, 21 | (_, state) => state.showAlertDialogOnError(context), 22 | ); 23 | final jobEntriesQuery = ref.watch(jobEntriesQueryProvider(job.id)); 24 | return FirestoreListView( 25 | query: jobEntriesQuery, 26 | itemBuilder: (context, doc) { 27 | final entry = doc.data(); 28 | return DismissibleEntryListItem( 29 | dismissibleKey: Key('entry-${entry.id}'), 30 | entry: entry, 31 | job: job, 32 | onDismissed: () => ref 33 | .read(jobsEntriesListControllerProvider.notifier) 34 | .deleteEntry(entry.id), 35 | onTap: () => context.goNamed( 36 | AppRoute.entry.name, 37 | pathParameters: {'id': job.id, 'eid': entry.id}, 38 | extra: entry, 39 | ), 40 | ); 41 | }, 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /lib/src/features/jobs/presentation/job_entries_screen/job_entries_list_controller.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:riverpod_annotation/riverpod_annotation.dart'; 4 | import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; 5 | import 'package:starter_architecture_flutter_firebase/src/features/entries/data/entries_repository.dart'; 6 | import 'package:starter_architecture_flutter_firebase/src/features/entries/domain/entry.dart'; 7 | 8 | part 'job_entries_list_controller.g.dart'; 9 | 10 | @riverpod 11 | class JobsEntriesListController extends _$JobsEntriesListController { 12 | @override 13 | FutureOr build() { 14 | // ok to leave this empty if the return type is FutureOr 15 | } 16 | 17 | Future deleteEntry(EntryID entryId) async { 18 | final currentUser = ref.read(authRepositoryProvider).currentUser; 19 | if (currentUser == null) { 20 | throw AssertionError('User can\'t be null'); 21 | } 22 | final repository = ref.read(entriesRepositoryProvider); 23 | state = const AsyncLoading(); 24 | state = await AsyncValue.guard( 25 | () => repository.deleteEntry(uid: currentUser.uid, entryId: entryId)); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /lib/src/features/jobs/presentation/job_entries_screen/job_entries_list_controller.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'job_entries_list_controller.dart'; 4 | 5 | // ************************************************************************** 6 | // RiverpodGenerator 7 | // ************************************************************************** 8 | 9 | String _$jobsEntriesListControllerHash() => 10 | r'f9a08b66a0c962d210a09aebb711d38acb354b1e'; 11 | 12 | /// See also [JobsEntriesListController]. 13 | @ProviderFor(JobsEntriesListController) 14 | final jobsEntriesListControllerProvider = 15 | AutoDisposeAsyncNotifierProvider.internal( 16 | JobsEntriesListController.new, 17 | name: r'jobsEntriesListControllerProvider', 18 | debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') 19 | ? null 20 | : _$jobsEntriesListControllerHash, 21 | dependencies: null, 22 | allTransitiveDependencies: null, 23 | ); 24 | 25 | typedef _$JobsEntriesListController = AutoDisposeAsyncNotifier; 26 | // ignore_for_file: type=lint 27 | // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package 28 | -------------------------------------------------------------------------------- /lib/src/features/jobs/presentation/job_entries_screen/job_entries_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:go_router/go_router.dart'; 4 | import 'package:starter_architecture_flutter_firebase/src/common_widgets/async_value_widget.dart'; 5 | import 'package:starter_architecture_flutter_firebase/src/features/jobs/data/jobs_repository.dart'; 6 | import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; 7 | import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/job_entries_screen/job_entries_list.dart'; 8 | import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; 9 | 10 | class JobEntriesScreen extends ConsumerWidget { 11 | const JobEntriesScreen({super.key, required this.jobId}); 12 | final JobID jobId; 13 | 14 | @override 15 | Widget build(BuildContext context, WidgetRef ref) { 16 | final jobAsync = ref.watch(jobStreamProvider(jobId)); 17 | return ScaffoldAsyncValueWidget( 18 | value: jobAsync, 19 | data: (job) => JobEntriesPageContents(job: job), 20 | ); 21 | } 22 | } 23 | 24 | class JobEntriesPageContents extends StatelessWidget { 25 | const JobEntriesPageContents({super.key, required this.job}); 26 | final Job job; 27 | 28 | @override 29 | Widget build(BuildContext context) { 30 | return Scaffold( 31 | appBar: AppBar( 32 | title: Text(job.name), 33 | actions: [ 34 | IconButton( 35 | icon: const Icon(Icons.edit, color: Colors.white), 36 | onPressed: () => context.goNamed( 37 | AppRoute.editJob.name, 38 | pathParameters: {'id': job.id}, 39 | extra: job, 40 | ), 41 | ), 42 | ], 43 | ), 44 | body: JobEntriesList(job: job), 45 | floatingActionButton: FloatingActionButton( 46 | child: const Icon(Icons.add, color: Colors.white), 47 | onPressed: () => context.goNamed( 48 | AppRoute.addEntry.name, 49 | pathParameters: {'id': job.id}, 50 | extra: job, 51 | ), 52 | ), 53 | ); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /lib/src/features/jobs/presentation/jobs_screen/jobs_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:firebase_ui_firestore/firebase_ui_firestore.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 | import 'package:go_router/go_router.dart'; 5 | import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; 6 | import 'package:starter_architecture_flutter_firebase/src/features/jobs/data/jobs_repository.dart'; 7 | import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; 8 | import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/jobs_screen/jobs_screen_controller.dart'; 9 | import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; 10 | import 'package:starter_architecture_flutter_firebase/src/utils/async_value_ui.dart'; 11 | 12 | class JobsScreen extends StatelessWidget { 13 | const JobsScreen({super.key}); 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | return Scaffold( 18 | appBar: AppBar( 19 | title: const Text(Strings.jobs), 20 | actions: [ 21 | IconButton( 22 | icon: const Icon(Icons.add, color: Colors.white), 23 | onPressed: () => context.goNamed(AppRoute.addJob.name), 24 | ), 25 | ], 26 | ), 27 | body: Consumer( 28 | builder: (context, ref, child) { 29 | ref.listen( 30 | jobsScreenControllerProvider, 31 | (_, state) => state.showAlertDialogOnError(context), 32 | ); 33 | final jobsQuery = ref.watch(jobsQueryProvider); 34 | return FirestoreListView( 35 | query: jobsQuery, 36 | emptyBuilder: (context) => const Center(child: Text('No data')), 37 | errorBuilder: (context, error, stackTrace) => Center( 38 | child: Text(error.toString()), 39 | ), 40 | loadingBuilder: (context) => 41 | const Center(child: CircularProgressIndicator()), 42 | itemBuilder: (context, doc) { 43 | final job = doc.data(); 44 | return Dismissible( 45 | key: Key('job-${job.id}'), 46 | background: Container(color: Colors.red), 47 | direction: DismissDirection.endToStart, 48 | onDismissed: (direction) => ref 49 | .read(jobsScreenControllerProvider.notifier) 50 | .deleteJob(job), 51 | child: JobListTile( 52 | job: job, 53 | onTap: () => context.goNamed( 54 | AppRoute.job.name, 55 | pathParameters: {'id': job.id}, 56 | ), 57 | ), 58 | ); 59 | }, 60 | ); 61 | }, 62 | ), 63 | ); 64 | } 65 | } 66 | 67 | class JobListTile extends StatelessWidget { 68 | const JobListTile({super.key, required this.job, this.onTap}); 69 | final Job job; 70 | final VoidCallback? onTap; 71 | 72 | @override 73 | Widget build(BuildContext context) { 74 | return ListTile( 75 | title: Text(job.name), 76 | trailing: const Icon(Icons.chevron_right), 77 | onTap: onTap, 78 | ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /lib/src/features/jobs/presentation/jobs_screen/jobs_screen_controller.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:riverpod_annotation/riverpod_annotation.dart'; 4 | import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; 5 | import 'package:starter_architecture_flutter_firebase/src/features/jobs/data/jobs_repository.dart'; 6 | import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; 7 | 8 | part 'jobs_screen_controller.g.dart'; 9 | 10 | @riverpod 11 | class JobsScreenController extends _$JobsScreenController { 12 | @override 13 | FutureOr build() { 14 | // ok to leave this empty if the return type is FutureOr 15 | } 16 | 17 | Future deleteJob(Job job) async { 18 | final currentUser = ref.read(authRepositoryProvider).currentUser; 19 | if (currentUser == null) { 20 | throw AssertionError('User can\'t be null'); 21 | } 22 | final repository = ref.read(jobsRepositoryProvider); 23 | state = const AsyncLoading(); 24 | state = await AsyncValue.guard( 25 | () => repository.deleteJob(uid: currentUser.uid, jobId: job.id)); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /lib/src/features/jobs/presentation/jobs_screen/jobs_screen_controller.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'jobs_screen_controller.dart'; 4 | 5 | // ************************************************************************** 6 | // RiverpodGenerator 7 | // ************************************************************************** 8 | 9 | String _$jobsScreenControllerHash() => 10 | r'e3a40258404cf512fd12924d8f0a485f75d7d6fb'; 11 | 12 | /// See also [JobsScreenController]. 13 | @ProviderFor(JobsScreenController) 14 | final jobsScreenControllerProvider = 15 | AutoDisposeAsyncNotifierProvider.internal( 16 | JobsScreenController.new, 17 | name: r'jobsScreenControllerProvider', 18 | debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') 19 | ? null 20 | : _$jobsScreenControllerHash, 21 | dependencies: null, 22 | allTransitiveDependencies: null, 23 | ); 24 | 25 | typedef _$JobsScreenController = AutoDisposeAsyncNotifier; 26 | // ignore_for_file: type=lint 27 | // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package 28 | -------------------------------------------------------------------------------- /lib/src/features/onboarding/data/onboarding_repository.dart: -------------------------------------------------------------------------------- 1 | import 'package:riverpod/riverpod.dart'; 2 | import 'package:riverpod_annotation/riverpod_annotation.dart'; 3 | import 'package:shared_preferences/shared_preferences.dart'; 4 | import 'package:starter_architecture_flutter_firebase/src/utils/shared_preferences_provider.dart'; 5 | 6 | part 'onboarding_repository.g.dart'; 7 | 8 | class OnboardingRepository { 9 | OnboardingRepository(this.sharedPreferences); 10 | final SharedPreferences sharedPreferences; 11 | 12 | static const onboardingCompleteKey = 'onboardingComplete'; 13 | 14 | Future setOnboardingComplete() async { 15 | await sharedPreferences.setBool(onboardingCompleteKey, true); 16 | } 17 | 18 | bool isOnboardingComplete() => 19 | sharedPreferences.getBool(onboardingCompleteKey) ?? false; 20 | } 21 | 22 | @Riverpod(keepAlive: true) 23 | Future onboardingRepository(Ref ref) async { 24 | final sharedPreferences = await ref.watch(sharedPreferencesProvider.future); 25 | return OnboardingRepository(sharedPreferences); 26 | } 27 | -------------------------------------------------------------------------------- /lib/src/features/onboarding/data/onboarding_repository.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'onboarding_repository.dart'; 4 | 5 | // ************************************************************************** 6 | // RiverpodGenerator 7 | // ************************************************************************** 8 | 9 | String _$onboardingRepositoryHash() => 10 | r'b3d2bcb49877fe1de659afaf4683aca9fccf5b3e'; 11 | 12 | /// See also [onboardingRepository]. 13 | @ProviderFor(onboardingRepository) 14 | final onboardingRepositoryProvider = 15 | FutureProvider.internal( 16 | onboardingRepository, 17 | name: r'onboardingRepositoryProvider', 18 | debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') 19 | ? null 20 | : _$onboardingRepositoryHash, 21 | dependencies: null, 22 | allTransitiveDependencies: null, 23 | ); 24 | 25 | @Deprecated('Will be removed in 3.0. Use Ref instead') 26 | // ignore: unused_element 27 | typedef OnboardingRepositoryRef = FutureProviderRef; 28 | // ignore_for_file: type=lint 29 | // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package 30 | -------------------------------------------------------------------------------- /lib/src/features/onboarding/presentation/onboarding_controller.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:riverpod_annotation/riverpod_annotation.dart'; 4 | import 'package:starter_architecture_flutter_firebase/src/features/onboarding/data/onboarding_repository.dart'; 5 | 6 | part 'onboarding_controller.g.dart'; 7 | 8 | @riverpod 9 | class OnboardingController extends _$OnboardingController { 10 | @override 11 | FutureOr build() { 12 | // no op 13 | } 14 | 15 | Future completeOnboarding() async { 16 | final onboardingRepository = 17 | ref.watch(onboardingRepositoryProvider).requireValue; 18 | state = const AsyncLoading(); 19 | state = await AsyncValue.guard(onboardingRepository.setOnboardingComplete); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /lib/src/features/onboarding/presentation/onboarding_controller.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'onboarding_controller.dart'; 4 | 5 | // ************************************************************************** 6 | // RiverpodGenerator 7 | // ************************************************************************** 8 | 9 | String _$onboardingControllerHash() => 10 | r'232966a6326a75bb5f5166c8b76bbbb15087adaf'; 11 | 12 | /// See also [OnboardingController]. 13 | @ProviderFor(OnboardingController) 14 | final onboardingControllerProvider = 15 | AutoDisposeAsyncNotifierProvider.internal( 16 | OnboardingController.new, 17 | name: r'onboardingControllerProvider', 18 | debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') 19 | ? null 20 | : _$onboardingControllerHash, 21 | dependencies: null, 22 | allTransitiveDependencies: null, 23 | ); 24 | 25 | typedef _$OnboardingController = AutoDisposeAsyncNotifier; 26 | // ignore_for_file: type=lint 27 | // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package 28 | -------------------------------------------------------------------------------- /lib/src/features/onboarding/presentation/onboarding_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:flutter_svg/flutter_svg.dart'; 4 | import 'package:go_router/go_router.dart'; 5 | import 'package:starter_architecture_flutter_firebase/src/common_widgets/primary_button.dart'; 6 | import 'package:starter_architecture_flutter_firebase/src/common_widgets/responsive_center.dart'; 7 | import 'package:starter_architecture_flutter_firebase/src/constants/app_sizes.dart'; 8 | import 'package:starter_architecture_flutter_firebase/src/features/onboarding/presentation/onboarding_controller.dart'; 9 | import 'package:starter_architecture_flutter_firebase/src/localization/string_hardcoded.dart'; 10 | import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; 11 | 12 | class OnboardingScreen extends ConsumerWidget { 13 | const OnboardingScreen({super.key}); 14 | 15 | @override 16 | Widget build(BuildContext context, WidgetRef ref) { 17 | final state = ref.watch(onboardingControllerProvider); 18 | return Scaffold( 19 | body: ResponsiveCenter( 20 | maxContentWidth: 450, 21 | padding: const EdgeInsets.all(16.0), 22 | child: Column( 23 | mainAxisAlignment: MainAxisAlignment.center, 24 | crossAxisAlignment: CrossAxisAlignment.stretch, 25 | children: [ 26 | Text( 27 | 'Track your time.\nBecause time counts.', 28 | style: Theme.of(context).textTheme.headlineSmall, 29 | textAlign: TextAlign.center, 30 | ), 31 | gapH16, 32 | SvgPicture.asset( 33 | 'assets/time-tracking.svg', 34 | width: 200, 35 | height: 200, 36 | semanticsLabel: 'Time tracking logo', 37 | ), 38 | gapH16, 39 | PrimaryButton( 40 | text: 'Get Started'.hardcoded, 41 | isLoading: state.isLoading, 42 | onPressed: state.isLoading 43 | ? null 44 | : () async { 45 | await ref 46 | .read(onboardingControllerProvider.notifier) 47 | .completeOnboarding(); 48 | if (context.mounted) { 49 | // go to sign in page after completing onboarding 50 | context.goNamed(AppRoute.signIn.name); 51 | } 52 | }, 53 | ), 54 | ], 55 | ), 56 | ), 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /lib/src/localization/string_hardcoded.dart: -------------------------------------------------------------------------------- 1 | /// A simple placeholder that can be used to search all the hardcoded strings 2 | /// in the code (useful to identify strings that need to be localized). 3 | extension StringHardcoded on String { 4 | String get hardcoded => this; 5 | } 6 | -------------------------------------------------------------------------------- /lib/src/routing/app_router.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'app_router.dart'; 4 | 5 | // ************************************************************************** 6 | // RiverpodGenerator 7 | // ************************************************************************** 8 | 9 | String _$goRouterHash() => r'bdb6fbb3c1421654e085ee95c8071b4996f3f578'; 10 | 11 | /// See also [goRouter]. 12 | @ProviderFor(goRouter) 13 | final goRouterProvider = AutoDisposeProvider.internal( 14 | goRouter, 15 | name: r'goRouterProvider', 16 | debugGetCreateSourceHash: 17 | const bool.fromEnvironment('dart.vm.product') ? null : _$goRouterHash, 18 | dependencies: null, 19 | allTransitiveDependencies: null, 20 | ); 21 | 22 | @Deprecated('Will be removed in 3.0. Use Ref instead') 23 | // ignore: unused_element 24 | typedef GoRouterRef = AutoDisposeProviderRef; 25 | // ignore_for_file: type=lint 26 | // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package 27 | -------------------------------------------------------------------------------- /lib/src/routing/app_startup.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:riverpod_annotation/riverpod_annotation.dart'; 4 | import 'package:starter_architecture_flutter_firebase/src/constants/app_sizes.dart'; 5 | import 'package:starter_architecture_flutter_firebase/src/features/onboarding/data/onboarding_repository.dart'; 6 | 7 | part 'app_startup.g.dart'; 8 | 9 | // https://codewithandrea.com/articles/robust-app-initialization-riverpod/ 10 | @Riverpod(keepAlive: true) 11 | Future appStartup(Ref ref) async { 12 | ref.onDispose(() { 13 | // ensure dependent providers are disposed as well 14 | ref.invalidate(onboardingRepositoryProvider); 15 | }); 16 | // Uncomment this to test that URL-based navigation and deep linking works 17 | // even when there's a delay in the app startup logic 18 | // await Future.delayed(Duration(seconds: 1)); 19 | // await for all initialization code to be complete before returning 20 | await ref.watch(onboardingRepositoryProvider.future); 21 | } 22 | 23 | /// Widget class to manage asynchronous app initialization 24 | class AppStartupWidget extends ConsumerWidget { 25 | const AppStartupWidget({super.key, required this.onLoaded}); 26 | final WidgetBuilder onLoaded; 27 | 28 | @override 29 | Widget build(BuildContext context, WidgetRef ref) { 30 | final appStartupState = ref.watch(appStartupProvider); 31 | return appStartupState.when( 32 | data: (_) => onLoaded(context), 33 | loading: () => const AppStartupLoadingWidget(), 34 | error: (e, st) => AppStartupErrorWidget( 35 | message: e.toString(), 36 | onRetry: () => ref.invalidate(appStartupProvider), 37 | ), 38 | ); 39 | } 40 | } 41 | 42 | /// Widget to show while initialization is in progress 43 | class AppStartupLoadingWidget extends StatelessWidget { 44 | const AppStartupLoadingWidget({super.key}); 45 | 46 | @override 47 | Widget build(BuildContext context) { 48 | return Scaffold( 49 | appBar: AppBar(), 50 | body: const Center( 51 | child: CircularProgressIndicator(), 52 | ), 53 | ); 54 | } 55 | } 56 | 57 | /// Widget to show if initialization fails 58 | class AppStartupErrorWidget extends StatelessWidget { 59 | const AppStartupErrorWidget( 60 | {super.key, required this.message, required this.onRetry}); 61 | final String message; 62 | final VoidCallback onRetry; 63 | 64 | @override 65 | Widget build(BuildContext context) { 66 | return Scaffold( 67 | appBar: AppBar(), 68 | body: Center( 69 | child: Column( 70 | mainAxisSize: MainAxisSize.min, 71 | children: [ 72 | Text(message, style: Theme.of(context).textTheme.headlineSmall), 73 | gapH16, 74 | ElevatedButton( 75 | onPressed: onRetry, 76 | child: const Text('Retry'), 77 | ), 78 | ], 79 | ), 80 | ), 81 | ); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /lib/src/routing/app_startup.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'app_startup.dart'; 4 | 5 | // ************************************************************************** 6 | // RiverpodGenerator 7 | // ************************************************************************** 8 | 9 | String _$appStartupHash() => r'e4ee7c8520e85c205f71d32783e8c8f4809ea3a6'; 10 | 11 | /// See also [appStartup]. 12 | @ProviderFor(appStartup) 13 | final appStartupProvider = FutureProvider.internal( 14 | appStartup, 15 | name: r'appStartupProvider', 16 | debugGetCreateSourceHash: 17 | const bool.fromEnvironment('dart.vm.product') ? null : _$appStartupHash, 18 | dependencies: null, 19 | allTransitiveDependencies: null, 20 | ); 21 | 22 | @Deprecated('Will be removed in 3.0. Use Ref instead') 23 | // ignore: unused_element 24 | typedef AppStartupRef = FutureProviderRef; 25 | // ignore_for_file: type=lint 26 | // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package 27 | -------------------------------------------------------------------------------- /lib/src/routing/go_router_delegate_listener.dart: -------------------------------------------------------------------------------- 1 | import 'dart:developer'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 | import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; 6 | 7 | // Listener widget to track screen views. Adapted from: 8 | // https://github.com/flutter/flutter/issues/112196#issuecomment-1680382232 9 | class GoRouterDelegateListener extends ConsumerStatefulWidget { 10 | const GoRouterDelegateListener({super.key, required this.child}); 11 | final Widget child; 12 | 13 | @override 14 | ConsumerState createState() => 15 | _GoRouterListenerState(); 16 | } 17 | 18 | class _GoRouterListenerState extends ConsumerState { 19 | /// Helper variable for retrieving the GoRouter delegate 20 | /// Note: using GoRouter.of(context) throws an exception so we use the goRouterProvider instead 21 | late final routerDelegate = ref.read(goRouterProvider).routerDelegate; 22 | 23 | @override 24 | void initState() { 25 | super.initState(); 26 | routerDelegate.addListener(_listener); 27 | } 28 | 29 | @override 30 | void dispose() { 31 | routerDelegate.removeListener(_listener); 32 | super.dispose(); 33 | } 34 | 35 | void _listener() { 36 | final config = routerDelegate.currentConfiguration; 37 | final screenName = config.last.route.name; 38 | if (screenName != null) { 39 | final pathParams = config.pathParameters; 40 | // TODO: Add your own logging or analytics screen tracking code 41 | log('screenName: $screenName, pathParams: $pathParams'); 42 | } 43 | } 44 | 45 | @override 46 | Widget build(BuildContext context) { 47 | return widget.child; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /lib/src/routing/go_router_refresh_stream.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/foundation.dart'; 4 | 5 | /// This class was imported from the migration guide for GoRouter 5.0 6 | class GoRouterRefreshStream extends ChangeNotifier { 7 | GoRouterRefreshStream(Stream stream) { 8 | notifyListeners(); 9 | _subscription = stream.asBroadcastStream().listen( 10 | (dynamic _) => notifyListeners(), 11 | ); 12 | } 13 | 14 | late final StreamSubscription _subscription; 15 | 16 | @override 17 | void dispose() { 18 | _subscription.cancel(); 19 | super.dispose(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /lib/src/routing/not_found_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:starter_architecture_flutter_firebase/src/common_widgets/empty_placeholder_widget.dart'; 3 | 4 | /// Simple not found screen used for 404 errors (page not found on web) 5 | class NotFoundScreen extends StatelessWidget { 6 | const NotFoundScreen({super.key}); 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | return Scaffold( 11 | appBar: AppBar(), 12 | body: const EmptyPlaceholderWidget( 13 | message: '404 - Page not found!', 14 | ), 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /lib/src/routing/scaffold_with_nested_navigation.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: public_member_api_docs, sort_constructors_first 2 | import 'package:flutter/material.dart'; 3 | import 'package:go_router/go_router.dart'; 4 | 5 | import 'package:starter_architecture_flutter_firebase/src/localization/string_hardcoded.dart'; 6 | 7 | // Stateful navigation based on: 8 | // https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/stateful_shell_route.dart 9 | class ScaffoldWithNestedNavigation extends StatelessWidget { 10 | const ScaffoldWithNestedNavigation({ 11 | Key? key, 12 | required this.navigationShell, 13 | }) : super(key: key ?? const ValueKey('ScaffoldWithNestedNavigation')); 14 | final StatefulNavigationShell navigationShell; 15 | 16 | void _goBranch(int index) { 17 | navigationShell.goBranch( 18 | index, 19 | // A common pattern when using bottom navigation bars is to support 20 | // navigating to the initial location when tapping the item that is 21 | // already active. This example demonstrates how to support this behavior, 22 | // using the initialLocation parameter of goBranch. 23 | initialLocation: index == navigationShell.currentIndex, 24 | ); 25 | } 26 | 27 | @override 28 | Widget build(BuildContext context) { 29 | final size = MediaQuery.sizeOf(context); 30 | if (size.width < 450) { 31 | return ScaffoldWithNavigationBar( 32 | body: navigationShell, 33 | currentIndex: navigationShell.currentIndex, 34 | onDestinationSelected: _goBranch, 35 | ); 36 | } else { 37 | return ScaffoldWithNavigationRail( 38 | body: navigationShell, 39 | currentIndex: navigationShell.currentIndex, 40 | onDestinationSelected: _goBranch, 41 | ); 42 | } 43 | } 44 | } 45 | 46 | class ScaffoldWithNavigationBar extends StatelessWidget { 47 | const ScaffoldWithNavigationBar({ 48 | super.key, 49 | required this.body, 50 | required this.currentIndex, 51 | required this.onDestinationSelected, 52 | }); 53 | final Widget body; 54 | final int currentIndex; 55 | final ValueChanged onDestinationSelected; 56 | 57 | @override 58 | Widget build(BuildContext context) { 59 | return Scaffold( 60 | body: body, 61 | bottomNavigationBar: NavigationBar( 62 | selectedIndex: currentIndex, 63 | destinations: [ 64 | // products 65 | NavigationDestination( 66 | icon: const Icon(Icons.work_outline), 67 | selectedIcon: const Icon(Icons.work), 68 | label: 'Jobs'.hardcoded, 69 | ), 70 | NavigationDestination( 71 | icon: const Icon(Icons.view_headline_outlined), 72 | selectedIcon: const Icon(Icons.view_headline), 73 | label: 'Entries'.hardcoded, 74 | ), 75 | NavigationDestination( 76 | icon: const Icon(Icons.person_outline), 77 | selectedIcon: const Icon(Icons.person), 78 | label: 'Account'.hardcoded, 79 | ), 80 | ], 81 | onDestinationSelected: onDestinationSelected, 82 | ), 83 | ); 84 | } 85 | } 86 | 87 | class ScaffoldWithNavigationRail extends StatelessWidget { 88 | const ScaffoldWithNavigationRail({ 89 | super.key, 90 | required this.body, 91 | required this.currentIndex, 92 | required this.onDestinationSelected, 93 | }); 94 | final Widget body; 95 | final int currentIndex; 96 | final ValueChanged onDestinationSelected; 97 | 98 | @override 99 | Widget build(BuildContext context) { 100 | return Scaffold( 101 | body: Row( 102 | children: [ 103 | NavigationRail( 104 | selectedIndex: currentIndex, 105 | onDestinationSelected: onDestinationSelected, 106 | labelType: NavigationRailLabelType.all, 107 | destinations: [ 108 | NavigationRailDestination( 109 | icon: const Icon(Icons.work_outline), 110 | selectedIcon: const Icon(Icons.work), 111 | label: Text('Jobs'.hardcoded), 112 | ), 113 | NavigationRailDestination( 114 | icon: const Icon(Icons.view_headline_outlined), 115 | selectedIcon: const Icon(Icons.view_headline), 116 | label: Text('Entries'.hardcoded), 117 | ), 118 | NavigationRailDestination( 119 | icon: const Icon(Icons.person_outline), 120 | selectedIcon: const Icon(Icons.person), 121 | label: Text('Account'.hardcoded), 122 | ), 123 | ], 124 | ), 125 | const VerticalDivider(thickness: 1, width: 1), 126 | // This is the main content. 127 | Expanded( 128 | child: body, 129 | ), 130 | ], 131 | ), 132 | ); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /lib/src/utils/alert_dialogs.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:flutter/cupertino.dart'; 4 | import 'package:flutter/foundation.dart'; 5 | import 'package:flutter/material.dart'; 6 | import 'package:firebase_core/firebase_core.dart'; 7 | import 'package:flutter/services.dart'; 8 | 9 | part 'show_alert_dialog.dart'; 10 | part 'show_exception_alert_dialog.dart'; 11 | -------------------------------------------------------------------------------- /lib/src/utils/async_value_ui.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:starter_architecture_flutter_firebase/src/localization/string_hardcoded.dart'; 4 | import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; 5 | 6 | extension AsyncValueUI on AsyncValue { 7 | void showAlertDialogOnError(BuildContext context) { 8 | debugPrint('isLoading: $isLoading, hasError: $hasError'); 9 | if (!isLoading && hasError) { 10 | final message = error.toString(); 11 | showExceptionAlertDialog( 12 | context: context, 13 | title: 'Error'.hardcoded, 14 | exception: message, 15 | ); 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lib/src/utils/format.dart: -------------------------------------------------------------------------------- 1 | import 'package:intl/intl.dart'; 2 | 3 | class Format { 4 | static String hours(double hours) { 5 | final hoursNotNegative = hours < 0.0 ? 0.0 : hours; 6 | final formatter = NumberFormat.decimalPattern(); 7 | final formatted = formatter.format(hoursNotNegative); 8 | return '${formatted}h'; 9 | } 10 | 11 | static String date(DateTime date) { 12 | return DateFormat.yMMMd().format(date); 13 | } 14 | 15 | static String dayOfWeek(DateTime date) { 16 | return DateFormat.E().format(date); 17 | } 18 | 19 | static String currency(double pay) { 20 | if (pay != 0.0) { 21 | final formatter = NumberFormat.simpleCurrency(decimalDigits: 0); 22 | return formatter.format(pay); 23 | } 24 | return ''; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/src/utils/shared_preferences_provider.dart: -------------------------------------------------------------------------------- 1 | import 'package:riverpod/riverpod.dart'; 2 | import 'package:riverpod_annotation/riverpod_annotation.dart'; 3 | import 'package:shared_preferences/shared_preferences.dart'; 4 | 5 | part 'shared_preferences_provider.g.dart'; 6 | 7 | @Riverpod(keepAlive: true) 8 | Future sharedPreferences(Ref ref) { 9 | return SharedPreferences.getInstance(); 10 | } 11 | -------------------------------------------------------------------------------- /lib/src/utils/shared_preferences_provider.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'shared_preferences_provider.dart'; 4 | 5 | // ************************************************************************** 6 | // RiverpodGenerator 7 | // ************************************************************************** 8 | 9 | String _$sharedPreferencesHash() => r'48e60558ea6530114ea20ea03e69b9fb339ab129'; 10 | 11 | /// See also [sharedPreferences]. 12 | @ProviderFor(sharedPreferences) 13 | final sharedPreferencesProvider = FutureProvider.internal( 14 | sharedPreferences, 15 | name: r'sharedPreferencesProvider', 16 | debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') 17 | ? null 18 | : _$sharedPreferencesHash, 19 | dependencies: null, 20 | allTransitiveDependencies: null, 21 | ); 22 | 23 | @Deprecated('Will be removed in 3.0. Use Ref instead') 24 | // ignore: unused_element 25 | typedef SharedPreferencesRef = FutureProviderRef; 26 | // ignore_for_file: type=lint 27 | // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package 28 | -------------------------------------------------------------------------------- /lib/src/utils/show_alert_dialog.dart: -------------------------------------------------------------------------------- 1 | part of 'alert_dialogs.dart'; 2 | 3 | Future showAlertDialog({ 4 | required BuildContext context, 5 | required String title, 6 | String? content, 7 | String? cancelActionText, 8 | required String defaultActionText, 9 | }) async { 10 | if (kIsWeb || !Platform.isIOS) { 11 | return showDialog( 12 | context: context, 13 | builder: (context) => AlertDialog( 14 | title: Text(title), 15 | content: content != null ? Text(content) : null, 16 | actions: [ 17 | if (cancelActionText != null) 18 | TextButton( 19 | child: Text(cancelActionText), 20 | onPressed: () => Navigator.of(context).pop(false), 21 | ), 22 | TextButton( 23 | child: Text(defaultActionText), 24 | onPressed: () => Navigator.of(context).pop(true), 25 | ), 26 | ], 27 | ), 28 | ); 29 | } 30 | return showCupertinoDialog( 31 | context: context, 32 | builder: (context) => CupertinoAlertDialog( 33 | title: Text(title), 34 | content: content != null ? Text(content) : null, 35 | actions: [ 36 | if (cancelActionText != null) 37 | CupertinoDialogAction( 38 | child: Text(cancelActionText), 39 | onPressed: () => Navigator.of(context).pop(false), 40 | ), 41 | CupertinoDialogAction( 42 | child: Text(defaultActionText), 43 | onPressed: () => Navigator.of(context).pop(true), 44 | ), 45 | ], 46 | ), 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /lib/src/utils/show_exception_alert_dialog.dart: -------------------------------------------------------------------------------- 1 | part of 'alert_dialogs.dart'; 2 | 3 | Future showExceptionAlertDialog({ 4 | required BuildContext context, 5 | required String title, 6 | required dynamic exception, 7 | }) => 8 | showAlertDialog( 9 | context: context, 10 | title: title, 11 | content: _message(exception), 12 | defaultActionText: 'OK', 13 | ); 14 | 15 | String _message(dynamic exception) { 16 | if (exception is FirebaseException) { 17 | return exception.message ?? exception.toString(); 18 | } 19 | if (exception is PlatformException) { 20 | return exception.message ?? exception.toString(); 21 | } 22 | return exception.toString(); 23 | } 24 | 25 | // TODO: Revisit this 26 | // NOTE: The full list of FirebaseAuth errors is stored here: 27 | // https://github.com/firebase/firebase-ios-sdk/blob/2e77efd786e4895d50c3788371ec15980c729053/Firebase/Auth/Source/FIRAuthErrorUtils.m 28 | // These are just the most relevant for email & password sign in: 29 | // Map _errors = { 30 | // 'ERROR_WEAK_PASSWORD': 'The password must be 8 characters long or more.', 31 | // 'ERROR_INVALID_CREDENTIAL': 'The email address is badly formatted.', 32 | // 'ERROR_EMAIL_ALREADY_IN_USE': 33 | // 'The email address is already registered. Sign in instead?', 34 | // 'ERROR_INVALID_EMAIL': 'The email address is badly formatted.', 35 | // 'ERROR_WRONG_PASSWORD': 'The password is incorrect. Please try again.', 36 | // 'ERROR_USER_NOT_FOUND': 37 | // 'The email address is not registered. Need an account?', 38 | // 'ERROR_TOO_MANY_REQUESTS': 39 | // 'We have blocked all requests from this device due to unusual activity. Try again later.', 40 | // 'ERROR_OPERATION_NOT_ALLOWED': 41 | // 'This sign in method is not allowed. Please contact support.', 42 | // }; 43 | -------------------------------------------------------------------------------- /macos/.gitignore: -------------------------------------------------------------------------------- 1 | # Flutter-related 2 | **/Flutter/ephemeral/ 3 | **/Pods/ 4 | 5 | # Xcode-related 6 | **/dgph 7 | **/xcuserdata/ 8 | -------------------------------------------------------------------------------- /macos/Flutter/Flutter-Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "ephemeral/Flutter-Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /macos/Flutter/Flutter-Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "ephemeral/Flutter-Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /macos/Flutter/GeneratedPluginRegistrant.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Generated file. Do not edit. 3 | // 4 | 5 | import FlutterMacOS 6 | import Foundation 7 | 8 | import cloud_firestore 9 | import desktop_webview_auth 10 | import firebase_auth 11 | import firebase_core 12 | import package_info_plus 13 | import shared_preferences_foundation 14 | import url_launcher_macos 15 | 16 | func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { 17 | FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin")) 18 | DesktopWebviewAuthPlugin.register(with: registry.registrar(forPlugin: "DesktopWebviewAuthPlugin")) 19 | FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) 20 | FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) 21 | FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) 22 | SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) 23 | UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) 24 | } 25 | -------------------------------------------------------------------------------- /macos/Podfile: -------------------------------------------------------------------------------- 1 | platform :osx, '12.0' 2 | 3 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 4 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' 5 | 6 | project 'Runner', { 7 | 'Debug' => :debug, 8 | 'Profile' => :release, 9 | 'Release' => :release, 10 | } 11 | 12 | def flutter_root 13 | generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) 14 | unless File.exist?(generated_xcode_build_settings_path) 15 | raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" 16 | end 17 | 18 | File.foreach(generated_xcode_build_settings_path) do |line| 19 | matches = line.match(/FLUTTER_ROOT\=(.*)/) 20 | return matches[1].strip if matches 21 | end 22 | raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" 23 | end 24 | 25 | require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) 26 | 27 | flutter_macos_podfile_setup 28 | 29 | target 'Runner' do 30 | pod 'FirebaseFirestore', :git => 'https://github.com/invertase/firestore-ios-sdk-frameworks.git', :tag => '11.10.0' 31 | use_frameworks! 32 | use_modular_headers! 33 | 34 | flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) 35 | target 'RunnerTests' do 36 | inherit! :search_paths 37 | end 38 | end 39 | 40 | post_install do |installer| 41 | # Ensure pods also use the minimum deployment target set above 42 | # https://stackoverflow.com/a/64385584/436422 43 | puts 'Determining pod project minimum deployment target' 44 | 45 | pods_project = installer.pods_project 46 | deployment_target_key = 'MACOSX_DEPLOYMENT_TARGET' 47 | deployment_targets = pods_project.build_configurations.map{ |config| config.build_settings[deployment_target_key] } 48 | minimum_deployment_target = deployment_targets.min_by{ |version| Gem::Version.new(version) } 49 | 50 | puts 'Minimal deployment target is ' + minimum_deployment_target 51 | puts 'Setting each pod deployment target to ' + minimum_deployment_target 52 | 53 | installer.pods_project.targets.each do |target| 54 | flutter_additional_macos_build_settings(target) 55 | target.build_configurations.each do |config| 56 | config.build_settings[deployment_target_key] = minimum_deployment_target 57 | # https://stackoverflow.com/a/77142190 58 | xcconfig_path = config.base_configuration_reference.real_path 59 | xcconfig = File.read(xcconfig_path) 60 | xcconfig_mod = xcconfig.gsub(/DT_TOOLCHAIN_DIR/, "TOOLCHAIN_DIR") 61 | File.open(xcconfig_path, "w") { |file| file << xcconfig_mod } 62 | end 63 | end 64 | end -------------------------------------------------------------------------------- /macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 37 | 38 | 39 | 40 | 43 | 49 | 50 | 51 | 52 | 53 | 64 | 66 | 72 | 73 | 74 | 75 | 81 | 83 | 89 | 90 | 91 | 92 | 94 | 95 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /macos/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /macos/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import FlutterMacOS 3 | 4 | @main 5 | class AppDelegate: FlutterAppDelegate { 6 | override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { 7 | return true 8 | } 9 | 10 | override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { 11 | return true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "16x16", 5 | "idiom" : "mac", 6 | "filename" : "app_icon_16.png", 7 | "scale" : "1x" 8 | }, 9 | { 10 | "size" : "16x16", 11 | "idiom" : "mac", 12 | "filename" : "app_icon_32.png", 13 | "scale" : "2x" 14 | }, 15 | { 16 | "size" : "32x32", 17 | "idiom" : "mac", 18 | "filename" : "app_icon_32.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "32x32", 23 | "idiom" : "mac", 24 | "filename" : "app_icon_64.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "128x128", 29 | "idiom" : "mac", 30 | "filename" : "app_icon_128.png", 31 | "scale" : "1x" 32 | }, 33 | { 34 | "size" : "128x128", 35 | "idiom" : "mac", 36 | "filename" : "app_icon_256.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "256x256", 41 | "idiom" : "mac", 42 | "filename" : "app_icon_256.png", 43 | "scale" : "1x" 44 | }, 45 | { 46 | "size" : "256x256", 47 | "idiom" : "mac", 48 | "filename" : "app_icon_512.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "512x512", 53 | "idiom" : "mac", 54 | "filename" : "app_icon_512.png", 55 | "scale" : "1x" 56 | }, 57 | { 58 | "size" : "512x512", 59 | "idiom" : "mac", 60 | "filename" : "app_icon_1024.png", 61 | "scale" : "2x" 62 | } 63 | ], 64 | "info" : { 65 | "version" : 1, 66 | "author" : "xcode" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png -------------------------------------------------------------------------------- /macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png -------------------------------------------------------------------------------- /macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png -------------------------------------------------------------------------------- /macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png -------------------------------------------------------------------------------- /macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png -------------------------------------------------------------------------------- /macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png -------------------------------------------------------------------------------- /macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png -------------------------------------------------------------------------------- /macos/Runner/Configs/AppInfo.xcconfig: -------------------------------------------------------------------------------- 1 | // Application-level settings for the Runner target. 2 | // 3 | // This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the 4 | // future. If not, the values below would default to using the project name when this becomes a 5 | // 'flutter create' template. 6 | 7 | // The application's name. By default this is also the title of the Flutter window. 8 | PRODUCT_NAME = starter_architecture_flutter_firebase 9 | 10 | // The application's bundle identifier 11 | PRODUCT_BUNDLE_IDENTIFIER = com.example.starterArchitectureFlutterFirebase 12 | 13 | // The copyright displayed in application information 14 | PRODUCT_COPYRIGHT = Copyright © 2023 com.example. All rights reserved. 15 | -------------------------------------------------------------------------------- /macos/Runner/Configs/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "../../Flutter/Flutter-Debug.xcconfig" 2 | #include "Warnings.xcconfig" 3 | -------------------------------------------------------------------------------- /macos/Runner/Configs/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include "../../Flutter/Flutter-Release.xcconfig" 2 | #include "Warnings.xcconfig" 3 | -------------------------------------------------------------------------------- /macos/Runner/Configs/Warnings.xcconfig: -------------------------------------------------------------------------------- 1 | WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings 2 | GCC_WARN_UNDECLARED_SELECTOR = YES 3 | CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES 4 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE 5 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES 6 | CLANG_WARN_PRAGMA_PACK = YES 7 | CLANG_WARN_STRICT_PROTOTYPES = YES 8 | CLANG_WARN_COMMA = YES 9 | GCC_WARN_STRICT_SELECTOR_MATCH = YES 10 | CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES 11 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES 12 | GCC_WARN_SHADOW = YES 13 | CLANG_WARN_UNREACHABLE_CODE = YES 14 | -------------------------------------------------------------------------------- /macos/Runner/DebugProfile.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.cs.allow-jit 8 | 9 | com.apple.security.network.client 10 | 11 | com.apple.security.network.server 12 | 13 | keychain-access-groups 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /macos/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(FLUTTER_BUILD_NAME) 21 | CFBundleVersion 22 | $(FLUTTER_BUILD_NUMBER) 23 | LSMinimumSystemVersion 24 | $(MACOSX_DEPLOYMENT_TARGET) 25 | NSHumanReadableCopyright 26 | $(PRODUCT_COPYRIGHT) 27 | NSMainNibFile 28 | MainMenu 29 | NSPrincipalClass 30 | NSApplication 31 | 32 | 33 | -------------------------------------------------------------------------------- /macos/Runner/MainFlutterWindow.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import FlutterMacOS 3 | 4 | class MainFlutterWindow: NSWindow { 5 | override func awakeFromNib() { 6 | let flutterViewController = FlutterViewController() 7 | let windowFrame = self.frame 8 | self.contentViewController = flutterViewController 9 | self.setFrame(windowFrame, display: true) 10 | 11 | RegisterGeneratedPlugins(registry: flutterViewController) 12 | 13 | super.awakeFromNib() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /macos/Runner/Release.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.network.client 8 | 9 | keychain-access-groups 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /macos/RunnerTests/RunnerTests.swift: -------------------------------------------------------------------------------- 1 | import FlutterMacOS 2 | import Cocoa 3 | import XCTest 4 | 5 | class RunnerTests: XCTestCase { 6 | 7 | func testExample() { 8 | // If you add code to the Runner application, consider adding tests here. 9 | // See https://developer.apple.com/documentation/xctest for more information about using XCTest. 10 | } 11 | 12 | } 13 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: starter_architecture_flutter_firebase 2 | description: A new Flutter project. 3 | 4 | publish_to: 'none' 5 | version: 2.1.0 6 | 7 | environment: 8 | sdk: ">=3.6.0 <4.0.0" 9 | 10 | dependencies: 11 | cloud_firestore: ^5.6.6 12 | cupertino_icons: ^1.0.8 13 | equatable: ^2.0.7 14 | firebase_auth: ^5.5.2 15 | firebase_core: ^3.13.0 16 | firebase_ui_auth: ^1.16.1 17 | firebase_ui_firestore: ^1.7.1 18 | flutter: 19 | sdk: flutter 20 | riverpod: ^2.6.1 21 | flutter_riverpod: ^2.6.1 22 | flutter_svg: ^2.0.17 23 | go_router: ^14.8.1 24 | intl: ^0.19.0 25 | rxdart: ^0.28.0 26 | shared_preferences: ^2.5.3 27 | # the annotation package containing @riverpod 28 | riverpod_annotation: ^2.6.1 29 | force_update_helper: ^0.2.1 30 | url_launcher: ^6.3.1 31 | 32 | dev_dependencies: 33 | flutter_test: 34 | sdk: flutter 35 | mocktail: ^1.0.4 36 | random_string: ^2.3.1 37 | flutter_lints: ^5.0.0 38 | # a tool for running code generators 39 | build_runner: ^2.4.15 40 | # the code generator 41 | riverpod_generator: ^2.6.5 42 | # riverpod_lint makes it easier to work with Riverpod 43 | riverpod_lint: ^2.6.5 44 | # import custom_lint too as riverpod_lint depends on it 45 | custom_lint: ^0.7.5 46 | 47 | 48 | flutter: 49 | uses-material-design: true 50 | assets: 51 | - assets/time-tracking.svg 52 | -------------------------------------------------------------------------------- /test/src/features/jobs/domain/job_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; 3 | 4 | void main() { 5 | group('fromMap', () { 6 | test('job with all properties', () { 7 | final job = Job.fromMap(const { 8 | 'name': 'Blogging', 9 | 'ratePerHour': 10, 10 | }, 'abc'); 11 | expect(job, const Job(name: 'Blogging', ratePerHour: 10, id: 'abc')); 12 | }); 13 | 14 | test('missing name', () { 15 | // * If the 'name' is missing, this error will be emitted: 16 | // * _CastError: 17 | // * We can detect it by expecting that the test throws a TypeError 18 | expect( 19 | () => Job.fromMap(const { 20 | 'ratePerHour': 10, 21 | }, 'abc'), 22 | throwsA(isInstanceOf())); 23 | }); 24 | }); 25 | 26 | group('toMap', () { 27 | test('valid name, ratePerHour', () { 28 | const job = Job(name: 'Blogging', ratePerHour: 10, id: 'abc'); 29 | expect(job.toMap(), { 30 | 'name': 'Blogging', 31 | 'ratePerHour': 10, 32 | }); 33 | }); 34 | }); 35 | 36 | group('equality', () { 37 | test('different properties, equality returns false', () { 38 | const job1 = Job(name: 'Blogging', ratePerHour: 10, id: 'abc'); 39 | const job2 = Job(name: 'Blogging', ratePerHour: 5, id: 'abc'); 40 | expect(job1 == job2, false); 41 | }); 42 | test('same properties, equality returns true', () { 43 | const job1 = Job(name: 'Blogging', ratePerHour: 10, id: 'abc'); 44 | const job2 = Job(name: 'Blogging', ratePerHour: 10, id: 'abc'); 45 | expect(job1 == job2, true); 46 | }); 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /test/src/mocks.dart: -------------------------------------------------------------------------------- 1 | import 'package:firebase_auth/firebase_auth.dart'; 2 | import 'package:mocktail/mocktail.dart'; 3 | import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; 4 | import 'package:starter_architecture_flutter_firebase/src/features/onboarding/data/onboarding_repository.dart'; 5 | 6 | class MockAuthRepository extends Mock implements AuthRepository {} 7 | 8 | class MockFirebaseAuth extends Mock implements FirebaseAuth {} 9 | 10 | class MockUserCredential extends Mock implements UserCredential {} 11 | 12 | class MockUser extends Mock implements User {} 13 | 14 | class MockOnboardingRepository extends Mock implements OnboardingRepository {} 15 | 16 | class Listener extends Mock { 17 | void call(T? previous, T? next); 18 | } 19 | -------------------------------------------------------------------------------- /update-android-project.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Update Gradle, Java and other Android project settings in a Flutter project 3 | 4 | # See: https://gradle.org/releases/ 5 | DESIRED_GRADLE_VERSION="8.9" 6 | # Build errors often show the required Java version 7 | DESIRED_JAVA_VERSION="17" 8 | # See: https://developer.android.com/ndk/downloads 9 | DESIRED_NDK_VERSION="27.0.12077973" 10 | # The minimum Android SDK version 11 | DESIRED_MIN_SDK_VERSION="24" 12 | # Google Play Stores requires a minimum target SDK version 13 | DESIRED_TARGET_SDK="34" 14 | # This shouldn't be too old, otherwise it won't compile with the DESIRED_GRADLE_VERSION set above 15 | DESIRED_ANDROID_APPLICATION_VERSION="8.7.2" 16 | 17 | # Exit if this is not a Flutter project 18 | if [ ! -f "pubspec.yaml" ]; then 19 | echo "This is not a Flutter project" 20 | exit 1 21 | fi 22 | 23 | # Exit if the Android directory does not exist 24 | if [ ! -d "android" ]; then 25 | echo "The Android directory does not exist" 26 | exit 1 27 | fi 28 | 29 | # Navigate to the Android directory 30 | cd android 31 | 32 | # Update Gradle version (if specified) 33 | if [ -n "$DESIRED_GRADLE_VERSION" ]; then 34 | sed -i '' "s/gradle-.*-all.zip/gradle-${DESIRED_GRADLE_VERSION}-all.zip/" gradle/wrapper/gradle-wrapper.properties 35 | fi 36 | 37 | # Update Java version (if specified) 38 | if [ -n "$DESIRED_JAVA_VERSION" ]; then 39 | sed -i '' "s/JavaVersion.VERSION_[0-9_]*/JavaVersion.VERSION_${DESIRED_JAVA_VERSION}/" app/build.gradle 40 | fi 41 | 42 | # Update NDK version (if specified) 43 | if [ -n "$DESIRED_NDK_VERSION" ]; then 44 | sed -i '' "s/ndkVersion = .*/ndkVersion = \"${DESIRED_NDK_VERSION}\"/" app/build.gradle 45 | fi 46 | 47 | # Update minSdk version (if specified) 48 | if [ -n "$DESIRED_MIN_SDK_VERSION" ]; then 49 | sed -i '' "s/minSdk = .*/minSdk = ${DESIRED_MIN_SDK_VERSION}/" app/build.gradle 50 | fi 51 | 52 | # Update targetSdk version (if specified) 53 | if [ -n "$DESIRED_TARGET_SDK" ]; then 54 | sed -i '' "s/targetSdk = .*/targetSdk = ${DESIRED_TARGET_SDK}/" app/build.gradle 55 | fi 56 | 57 | # Update com.android.application version in settings.gradle (if specified) 58 | if [ -n "$DESIRED_ANDROID_APPLICATION_VERSION" ]; then 59 | sed -i '' "s/id \"com.android.application\" version \".*\" apply false/id \"com.android.application\" version \"${DESIRED_ANDROID_APPLICATION_VERSION}\" apply false/" settings.gradle 60 | fi 61 | 62 | echo "Android project updated. Run 'git diff' to see the changes or 'git reset --hard' to discard them." 63 | -------------------------------------------------------------------------------- /web/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/web/favicon.png -------------------------------------------------------------------------------- /web/flutter_bootstrap.js: -------------------------------------------------------------------------------- 1 | {{flutter_js}} 2 | {{flutter_build_config}} 3 | 4 | // Manipulate the DOM to add a loading spinner which is rendered with this HTML: 5 | //
6 | //
7 | //
8 | const loadingDiv = document.createElement('div'); 9 | loadingDiv.className = "loading"; 10 | document.body.appendChild(loadingDiv); 11 | const loaderDiv = document.createElement('div'); 12 | loaderDiv.className = "loader"; 13 | loadingDiv.appendChild(loaderDiv); 14 | 15 | _flutter.loader.load({ 16 | onEntrypointLoaded: async function(engineInitializer) { 17 | const appRunner = await engineInitializer.initializeEngine(); 18 | 19 | // Remove the loading spinner when the app runner is ready 20 | if (document.body.contains(loadingDiv)) { 21 | document.body.removeChild(loadingDiv); 22 | } 23 | await appRunner.runApp(); 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /web/icons/Icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/web/icons/Icon-192.png -------------------------------------------------------------------------------- /web/icons/Icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/web/icons/Icon-512.png -------------------------------------------------------------------------------- /web/icons/Icon-maskable-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/web/icons/Icon-maskable-192.png -------------------------------------------------------------------------------- /web/icons/Icon-maskable-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bizz84/starter_architecture_flutter_firebase/821e21295c2af7292143a9190897b01b574397e8/web/icons/Icon-maskable-512.png -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | starter_architecture_flutter_firebase 33 | 34 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /web/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "starter_architecture_flutter_firebase", 3 | "short_name": "starter_architecture_flutter_firebase", 4 | "start_url": ".", 5 | "display": "standalone", 6 | "background_color": "#0175C2", 7 | "theme_color": "#0175C2", 8 | "description": "A new Flutter project.", 9 | "orientation": "portrait-primary", 10 | "prefer_related_applications": false, 11 | "icons": [ 12 | { 13 | "src": "icons/Icon-192.png", 14 | "sizes": "192x192", 15 | "type": "image/png" 16 | }, 17 | { 18 | "src": "icons/Icon-512.png", 19 | "sizes": "512x512", 20 | "type": "image/png" 21 | }, 22 | { 23 | "src": "icons/Icon-maskable-192.png", 24 | "sizes": "192x192", 25 | "type": "image/png", 26 | "purpose": "maskable" 27 | }, 28 | { 29 | "src": "icons/Icon-maskable-512.png", 30 | "sizes": "512x512", 31 | "type": "image/png", 32 | "purpose": "maskable" 33 | } 34 | ] 35 | } 36 | --------------------------------------------------------------------------------