From 79f4e225b337310e146284970e9e153c0da40373 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Thu, 27 Oct 2022 17:22:55 +0100 Subject: [PATCH 01/45] Start migrating project to new packages --- ios/firebase_app_id_file.json | 7 + .../common_widgets/custom_text_button.dart | 26 +++ lib/app/common_widgets/primary_button.dart | 35 ++++ lib/app/common_widgets/responsive_center.dart | 59 ++++++ .../responsive_scrollable_card.dart | 28 +++ lib/app/constants/app_sizes.dart | 36 ++++ lib/app/constants/breakpoints.dart | 5 + lib/app/home/account/account_page.dart | 20 +- lib/app/home/cupertino_home_scaffold.dart | 9 +- lib/app/home/entries/entries_page.dart | 17 +- lib/app/home/job_entries/entry_page.dart | 17 +- .../home/job_entries/job_entries_page.dart | 9 +- lib/app/home/jobs/edit_job_page.dart | 16 +- lib/app/home/jobs/jobs_page.dart | 48 ++--- lib/app/localization/string_hardcoded.dart | 5 + lib/app/onboarding/onboarding_page.dart | 9 +- lib/app/repositories/app_user.dart | 22 ++ lib/app/repositories/fake_app_user.dart | 11 + .../repositories/fake_auth_repository.dart | 31 +++ .../email_password_sign_in_controller.dart | 37 ++++ .../email_password_sign_in_form_type.dart | 55 +++++ .../email_password_sign_in_screen.dart | 195 ++++++++++++++++++ .../email_password_sign_in_validators.dart | 41 ++++ .../email_password/string_validators.dart | 72 +++++++ lib/app/sign_in/sign_in_page.dart | 11 +- lib/app/top_level_providers.dart | 12 +- lib/app/utils/async_value_ui.dart | 17 ++ lib/main.dart | 10 +- lib/routing/app_router.dart | 9 +- packages/alert_dialogs/.gitignore | 75 +++++++ packages/alert_dialogs/.metadata | 10 + packages/alert_dialogs/CHANGELOG.md | 5 + packages/alert_dialogs/LICENSE | 7 + packages/alert_dialogs/README.md | 15 ++ packages/alert_dialogs/lib/alert_dialogs.dart | 12 ++ .../alert_dialogs/lib/show_alert_dialog.dart | 48 +++++ .../lib/show_exception_alert_dialog.dart | 42 ++++ packages/alert_dialogs/pubspec.yaml | 53 +++++ packages/custom_buttons/.gitignore | 75 +++++++ packages/custom_buttons/.metadata | 10 + packages/custom_buttons/CHANGELOG.md | 4 + packages/custom_buttons/LICENSE | 7 + packages/custom_buttons/README.md | 9 + .../custom_buttons/lib/custom_buttons.dart | 6 + .../lib/custom_raised_button.dart | 57 +++++ .../lib/form_submit_button.dart | 21 ++ packages/custom_buttons/pubspec.yaml | 53 +++++ packages/firestore_service/.gitignore | 75 +++++++ packages/firestore_service/.metadata | 10 + packages/firestore_service/CHANGELOG.md | 12 ++ packages/firestore_service/LICENSE | 7 + packages/firestore_service/README.md | 13 ++ .../lib/firestore_service.dart | 61 ++++++ packages/firestore_service/pubspec.yaml | 53 +++++ pubspec.yaml | 54 ++--- 55 files changed, 1532 insertions(+), 131 deletions(-) create mode 100644 ios/firebase_app_id_file.json create mode 100644 lib/app/common_widgets/custom_text_button.dart create mode 100644 lib/app/common_widgets/primary_button.dart create mode 100644 lib/app/common_widgets/responsive_center.dart create mode 100644 lib/app/common_widgets/responsive_scrollable_card.dart create mode 100644 lib/app/constants/app_sizes.dart create mode 100644 lib/app/constants/breakpoints.dart create mode 100644 lib/app/localization/string_hardcoded.dart create mode 100644 lib/app/repositories/app_user.dart create mode 100644 lib/app/repositories/fake_app_user.dart create mode 100644 lib/app/repositories/fake_auth_repository.dart create mode 100644 lib/app/sign_in/email_password/email_password_sign_in_controller.dart create mode 100644 lib/app/sign_in/email_password/email_password_sign_in_form_type.dart create mode 100644 lib/app/sign_in/email_password/email_password_sign_in_screen.dart create mode 100644 lib/app/sign_in/email_password/email_password_sign_in_validators.dart create mode 100644 lib/app/sign_in/email_password/string_validators.dart create mode 100644 lib/app/utils/async_value_ui.dart create mode 100644 packages/alert_dialogs/.gitignore create mode 100644 packages/alert_dialogs/.metadata create mode 100644 packages/alert_dialogs/CHANGELOG.md create mode 100644 packages/alert_dialogs/LICENSE create mode 100644 packages/alert_dialogs/README.md create mode 100644 packages/alert_dialogs/lib/alert_dialogs.dart create mode 100644 packages/alert_dialogs/lib/show_alert_dialog.dart create mode 100644 packages/alert_dialogs/lib/show_exception_alert_dialog.dart create mode 100644 packages/alert_dialogs/pubspec.yaml create mode 100644 packages/custom_buttons/.gitignore create mode 100644 packages/custom_buttons/.metadata create mode 100644 packages/custom_buttons/CHANGELOG.md create mode 100644 packages/custom_buttons/LICENSE create mode 100644 packages/custom_buttons/README.md create mode 100644 packages/custom_buttons/lib/custom_buttons.dart create mode 100644 packages/custom_buttons/lib/custom_raised_button.dart create mode 100644 packages/custom_buttons/lib/form_submit_button.dart create mode 100644 packages/custom_buttons/pubspec.yaml create mode 100644 packages/firestore_service/.gitignore create mode 100644 packages/firestore_service/.metadata create mode 100644 packages/firestore_service/CHANGELOG.md create mode 100644 packages/firestore_service/LICENSE create mode 100644 packages/firestore_service/README.md create mode 100644 packages/firestore_service/lib/firestore_service.dart create mode 100644 packages/firestore_service/pubspec.yaml diff --git a/ios/firebase_app_id_file.json b/ios/firebase_app_id_file.json new file mode 100644 index 00000000..3b1eef50 --- /dev/null +++ b/ios/firebase_app_id_file.json @@ -0,0 +1,7 @@ +{ + "file_generated_by": "FlutterFire CLI", + "purpose": "FirebaseAppID & ProjectID for this Firebase app in this directory", + "GOOGLE_APP_ID": "1:204483935261:ios:df913fb4eeda0a29779af4", + "FIREBASE_PROJECT_ID": "starter-architecture-flutter", + "GCM_SENDER_ID": "204483935261" +} \ No newline at end of file diff --git a/lib/app/common_widgets/custom_text_button.dart b/lib/app/common_widgets/custom_text_button.dart new file mode 100644 index 00000000..42020740 --- /dev/null +++ b/lib/app/common_widgets/custom_text_button.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:starter_architecture_flutter_firebase/app/constants/app_sizes.dart'; + +/// Custom text button with a fixed height +class CustomTextButton extends StatelessWidget { + const CustomTextButton( + {super.key, required this.text, this.style, this.onPressed}); + final String text; + final TextStyle? style; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: Sizes.p48, + child: TextButton( + onPressed: onPressed, + child: Text( + text, + style: style, + textAlign: TextAlign.center, + ), + ), + ); + } +} diff --git a/lib/app/common_widgets/primary_button.dart b/lib/app/common_widgets/primary_button.dart new file mode 100644 index 00000000..651f82f2 --- /dev/null +++ b/lib/app/common_widgets/primary_button.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:starter_architecture_flutter_firebase/app/constants/app_sizes.dart'; + +/// Primary button based on [ElevatedButton]. +/// Useful for CTAs in the app. +/// @param text - text to display on the button. +/// @param isLoading - if true, a loading indicator will be displayed instead of +/// the text. +/// @param onPressed - callback to be called when the button is pressed. +class PrimaryButton extends StatelessWidget { + const PrimaryButton( + {super.key, required this.text, this.isLoading = false, this.onPressed}); + final String text; + final bool isLoading; + final VoidCallback? onPressed; + @override + Widget build(BuildContext context) { + return SizedBox( + height: Sizes.p48, + child: ElevatedButton( + onPressed: onPressed, + child: isLoading + ? const CircularProgressIndicator() + : Text( + text, + textAlign: TextAlign.center, + style: Theme.of(context) + .textTheme + .headline6! + .copyWith(color: Colors.white), + ), + ), + ); + } +} diff --git a/lib/app/common_widgets/responsive_center.dart b/lib/app/common_widgets/responsive_center.dart new file mode 100644 index 00000000..5e53b0de --- /dev/null +++ b/lib/app/common_widgets/responsive_center.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:starter_architecture_flutter_firebase/app/constants/breakpoints.dart'; + +/// Reusable widget for showing a child with a maximum content width constraint. +/// If available width is larger than the maximum width, the child will be +/// centered. +/// If available width is smaller than the maximum width, the child use all the +/// available width. +class ResponsiveCenter extends StatelessWidget { + const ResponsiveCenter({ + super.key, + this.maxContentWidth = Breakpoint.desktop, + this.padding = EdgeInsets.zero, + required this.child, + }); + final double maxContentWidth; + final EdgeInsetsGeometry padding; + final Widget child; + + @override + Widget build(BuildContext context) { + // Use Center as it has *unconstrained* width (loose constraints) + return Center( + // together with SizedBox to specify the max width (tight constraints) + // See this thread for more info: + // https://twitter.com/biz84/status/1445400059894542337 + child: SizedBox( + width: maxContentWidth, + child: Padding( + padding: padding, + child: child, + ), + ), + ); + } +} + +/// Sliver-equivalent of [ResponsiveCenter]. +class ResponsiveSliverCenter extends StatelessWidget { + const ResponsiveSliverCenter({ + super.key, + this.maxContentWidth = Breakpoint.desktop, + this.padding = EdgeInsets.zero, + required this.child, + }); + final double maxContentWidth; + final EdgeInsetsGeometry padding; + final Widget child; + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: ResponsiveCenter( + maxContentWidth: maxContentWidth, + padding: padding, + child: child, + ), + ); + } +} diff --git a/lib/app/common_widgets/responsive_scrollable_card.dart b/lib/app/common_widgets/responsive_scrollable_card.dart new file mode 100644 index 00000000..e1795520 --- /dev/null +++ b/lib/app/common_widgets/responsive_scrollable_card.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:starter_architecture_flutter_firebase/app/common_widgets/responsive_center.dart'; +import 'package:starter_architecture_flutter_firebase/app/constants/app_sizes.dart'; +import 'package:starter_architecture_flutter_firebase/app/constants/breakpoints.dart'; + +/// Scrollable widget that shows a responsive card with a given child widget. +/// Useful for displaying forms and other widgets that need to be scrollable. +class ResponsiveScrollableCard extends StatelessWidget { + const ResponsiveScrollableCard({super.key, required this.child}); + final Widget child; + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: ResponsiveCenter( + maxContentWidth: Breakpoint.tablet, + child: Padding( + padding: const EdgeInsets.all(Sizes.p16), + child: Card( + child: Padding( + padding: const EdgeInsets.all(Sizes.p16), + child: child, + ), + ), + ), + ), + ); + } +} diff --git a/lib/app/constants/app_sizes.dart b/lib/app/constants/app_sizes.dart new file mode 100644 index 00000000..cf240a26 --- /dev/null +++ b/lib/app/constants/app_sizes.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +/// Constant sizes to be used in the app (paddings, gaps, rounded corners etc.) +class Sizes { + static const p4 = 4.0; + static const p8 = 8.0; + static const p12 = 12.0; + static const p16 = 16.0; + static const p20 = 20.0; + static const p24 = 24.0; + static const p32 = 32.0; + static const p48 = 48.0; + static const p64 = 64.0; +} + +/// Constant gap widths +const gapW4 = SizedBox(width: Sizes.p4); +const gapW8 = SizedBox(width: Sizes.p8); +const gapW12 = SizedBox(width: Sizes.p12); +const gapW16 = SizedBox(width: Sizes.p16); +const gapW20 = SizedBox(width: Sizes.p20); +const gapW24 = SizedBox(width: Sizes.p24); +const gapW32 = SizedBox(width: Sizes.p32); +const gapW48 = SizedBox(width: Sizes.p48); +const gapW64 = SizedBox(width: Sizes.p64); + +/// Constant gap heights +const gapH4 = SizedBox(height: Sizes.p4); +const gapH8 = SizedBox(height: Sizes.p8); +const gapH12 = SizedBox(height: Sizes.p12); +const gapH16 = SizedBox(height: Sizes.p16); +const gapH20 = SizedBox(height: Sizes.p20); +const gapH24 = SizedBox(height: Sizes.p24); +const gapH32 = SizedBox(height: Sizes.p32); +const gapH48 = SizedBox(height: Sizes.p48); +const gapH64 = SizedBox(height: Sizes.p64); diff --git a/lib/app/constants/breakpoints.dart b/lib/app/constants/breakpoints.dart new file mode 100644 index 00000000..34ee032c --- /dev/null +++ b/lib/app/constants/breakpoints.dart @@ -0,0 +1,5 @@ +/// Layout breakpoints used in the app. +class Breakpoint { + static const double desktop = 900; + static const double tablet = 600; +} diff --git a/lib/app/home/account/account_page.dart b/lib/app/home/account/account_page.dart index 445d49a5..34d32652 100644 --- a/lib/app/home/account/account_page.dart +++ b/lib/app/home/account/account_page.dart @@ -1,25 +1,24 @@ import 'dart:async'; +import 'package:alert_dialogs/alert_dialogs.dart'; import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/app/top_level_providers.dart'; +import 'package:starter_architecture_flutter_firebase/app/repositories/fake_auth_repository.dart'; import 'package:starter_architecture_flutter_firebase/common_widgets/avatar.dart'; -import 'package:alert_dialogs/alert_dialogs.dart'; import 'package:starter_architecture_flutter_firebase/constants/keys.dart'; import 'package:starter_architecture_flutter_firebase/constants/strings.dart'; -import 'package:flutter/material.dart'; -import 'package:pedantic/pedantic.dart'; class AccountPage extends ConsumerWidget { - Future _signOut(BuildContext context, FirebaseAuth firebaseAuth) async { + Future _signOut(BuildContext context, WidgetRef ref) async { try { - await firebaseAuth.signOut(); + await ref.read(authRepositoryProvider).signOut(); } catch (e) { - unawaited(showExceptionAlertDialog( + await showExceptionAlertDialog( context: context, title: Strings.logoutFailed, exception: e, - )); + ); } } @@ -40,8 +39,7 @@ class AccountPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final firebaseAuth = ref.watch(firebaseAuthProvider); - final user = firebaseAuth.currentUser!; + final user = ref.watch(authRepositoryProvider).currentUser!; return Scaffold( appBar: AppBar( title: const Text(Strings.accountPage), @@ -55,7 +53,7 @@ class AccountPage extends ConsumerWidget { color: Colors.white, ), ), - onPressed: () => _confirmSignOut(context, firebaseAuth), + onPressed: () => _confirmSignOut(context, ref), ), ], bottom: PreferredSize( diff --git a/lib/app/home/cupertino_home_scaffold.dart b/lib/app/home/cupertino_home_scaffold.dart index 1215a26b..48323d52 100644 --- a/lib/app/home/cupertino_home_scaffold.dart +++ b/lib/app/home/cupertino_home_scaffold.dart @@ -25,13 +25,13 @@ class CupertinoHomeScaffold extends StatelessWidget { tabBar: CupertinoTabBar( key: const Key(Keys.tabBar), currentIndex: currentTab.index, + activeColor: Colors.indigo, items: [ _buildItem(TabItem.jobs), _buildItem(TabItem.entries), _buildItem(TabItem.account), ], onTap: (index) => onSelectTab(TabItem.values[index]), - activeColor: Colors.indigo, ), tabBuilder: (context, index) { final item = TabItem.values[index]; @@ -46,8 +46,13 @@ class CupertinoHomeScaffold extends StatelessWidget { BottomNavigationBarItem _buildItem(TabItem tabItem) { final itemData = TabItemData.allTabs[tabItem]!; + final color = currentTab == tabItem ? Colors.indigo : Colors.grey; return BottomNavigationBarItem( - icon: Icon(itemData.icon), + icon: Icon( + itemData.icon, + key: Key(itemData.key), + color: color, + ), label: itemData.title, ); } diff --git a/lib/app/home/entries/entries_page.dart b/lib/app/home/entries/entries_page.dart index 944e686b..fed06e28 100644 --- a/lib/app/home/entries/entries_page.dart +++ b/lib/app/home/entries/entries_page.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/entries/entries_view_model.dart'; import 'package:starter_architecture_flutter_firebase/app/home/entries/entries_list_tile.dart'; +import 'package:starter_architecture_flutter_firebase/app/home/entries/entries_view_model.dart'; import 'package:starter_architecture_flutter_firebase/app/home/jobs/list_items_builder.dart'; import 'package:starter_architecture_flutter_firebase/app/top_level_providers.dart'; import 'package:starter_architecture_flutter_firebase/constants/strings.dart'; @@ -9,7 +9,7 @@ import 'package:starter_architecture_flutter_firebase/constants/strings.dart'; final entriesTileModelStreamProvider = StreamProvider.autoDispose>( (ref) { - final database = ref.watch(databaseProvider)!; + final database = ref.watch(databaseProvider); final vm = EntriesViewModel(database: database); return vm.entriesTileModelStream; }, @@ -18,15 +18,20 @@ final entriesTileModelStreamProvider = class EntriesPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final entriesTileModelStream = ref.watch(entriesTileModelStreamProvider); return Scaffold( appBar: AppBar( title: const Text(Strings.entries), elevation: 2.0, ), - body: ListItemsBuilder( - data: entriesTileModelStream, - itemBuilder: (context, model) => EntriesListTile(model: model), + body: Consumer( + builder: (context, ref, child) { + final entriesTileModelStream = + ref.watch(entriesTileModelStreamProvider); + return ListItemsBuilder( + data: entriesTileModelStream, + itemBuilder: (context, model) => EntriesListTile(model: model), + ); + }, ), ); } diff --git a/lib/app/home/job_entries/entry_page.dart b/lib/app/home/job_entries/entry_page.dart index 958dc7ea..6f5b9cae 100644 --- a/lib/app/home/job_entries/entry_page.dart +++ b/lib/app/home/job_entries/entry_page.dart @@ -1,16 +1,13 @@ -import 'package:flutter/cupertino.dart'; +import 'package:alert_dialogs/alert_dialogs.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/app/top_level_providers.dart'; -import 'package:starter_architecture_flutter_firebase/common_widgets/date_time_picker.dart'; import 'package:starter_architecture_flutter_firebase/app/home/job_entries/format.dart'; import 'package:starter_architecture_flutter_firebase/app/home/models/entry.dart'; import 'package:starter_architecture_flutter_firebase/app/home/models/job.dart'; -import 'package:alert_dialogs/alert_dialogs.dart'; +import 'package:starter_architecture_flutter_firebase/app/top_level_providers.dart'; +import 'package:starter_architecture_flutter_firebase/common_widgets/date_time_picker.dart'; import 'package:starter_architecture_flutter_firebase/routing/app_router.dart'; import 'package:starter_architecture_flutter_firebase/services/firestore_database.dart'; -import 'package:pedantic/pedantic.dart'; class EntryPage extends ConsumerStatefulWidget { const EntryPage({required this.job, this.entry}); @@ -29,7 +26,7 @@ class EntryPage extends ConsumerStatefulWidget { } @override - _EntryPageState createState() => _EntryPageState(); + ConsumerState createState() => _EntryPageState(); } class _EntryPageState extends ConsumerState { @@ -70,16 +67,16 @@ class _EntryPageState extends ConsumerState { Future _setEntryAndDismiss() async { try { - final database = ref.read(databaseProvider)!; + final database = ref.read(databaseProvider); final entry = _entryFromState(); await database.setEntry(entry); Navigator.of(context).pop(); } catch (e) { - unawaited(showExceptionAlertDialog( + await showExceptionAlertDialog( context: context, title: 'Operation failed', exception: e, - )); + ); } } diff --git a/lib/app/home/job_entries/job_entries_page.dart b/lib/app/home/job_entries/job_entries_page.dart index ebad475d..c6b6f107 100644 --- a/lib/app/home/job_entries/job_entries_page.dart +++ b/lib/app/home/job_entries/job_entries_page.dart @@ -1,11 +1,8 @@ import 'dart:async'; import 'package:alert_dialogs/alert_dialogs.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:pedantic/pedantic.dart'; import 'package:starter_architecture_flutter_firebase/app/home/job_entries/entry_list_item.dart'; import 'package:starter_architecture_flutter_firebase/app/home/job_entries/entry_page.dart'; import 'package:starter_architecture_flutter_firebase/app/home/jobs/edit_job_page.dart'; @@ -57,7 +54,7 @@ class JobEntriesPage extends StatelessWidget { final jobStreamProvider = StreamProvider.autoDispose.family((ref, jobId) { - final database = ref.watch(databaseProvider)!; + final database = ref.watch(databaseProvider); return database.jobStream(jobId: jobId); }); @@ -78,7 +75,7 @@ class JobEntriesAppBarTitle extends ConsumerWidget { final jobEntriesStreamProvider = StreamProvider.autoDispose.family, Job>((ref, job) { - final database = ref.watch(databaseProvider)!; + final database = ref.watch(databaseProvider); return database.entriesStream(job: job); }); @@ -89,7 +86,7 @@ class JobEntriesContents extends ConsumerWidget { Future _deleteEntry( BuildContext context, WidgetRef ref, Entry entry) async { try { - final database = ref.read(databaseProvider)!; + final database = ref.read(databaseProvider); await database.deleteEntry(entry); } catch (e) { unawaited(showExceptionAlertDialog( diff --git a/lib/app/home/jobs/edit_job_page.dart b/lib/app/home/jobs/edit_job_page.dart index 4e8e9f8e..6e744cca 100644 --- a/lib/app/home/jobs/edit_job_page.dart +++ b/lib/app/home/jobs/edit_job_page.dart @@ -1,12 +1,10 @@ +import 'package:alert_dialogs/alert_dialogs.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/app/home/models/job.dart'; -import 'package:alert_dialogs/alert_dialogs.dart'; import 'package:starter_architecture_flutter_firebase/app/top_level_providers.dart'; import 'package:starter_architecture_flutter_firebase/routing/app_router.dart'; import 'package:starter_architecture_flutter_firebase/services/firestore_database.dart'; -import 'package:pedantic/pedantic.dart'; class EditJobPage extends ConsumerStatefulWidget { const EditJobPage({Key? key, this.job}) : super(key: key); @@ -20,7 +18,7 @@ class EditJobPage extends ConsumerStatefulWidget { } @override - _EditJobPageState createState() => _EditJobPageState(); + ConsumerState createState() => _EditJobPageState(); } class _EditJobPageState extends ConsumerState { @@ -50,7 +48,7 @@ class _EditJobPageState extends ConsumerState { Future _submit() async { if (_validateAndSaveForm()) { try { - final database = ref.read(databaseProvider)!; + final database = ref.read(databaseProvider); final jobs = await database.jobsStream().first; final allLowerCaseNames = jobs.map((job) => job.name.toLowerCase()).toList(); @@ -58,12 +56,12 @@ class _EditJobPageState extends ConsumerState { allLowerCaseNames.remove(widget.job!.name.toLowerCase()); } if (allLowerCaseNames.contains(_name?.toLowerCase())) { - unawaited(showAlertDialog( + await showAlertDialog( context: context, title: 'Name already used', content: 'Please choose a different job name', defaultActionText: 'OK', - )); + ); } else { final id = widget.job?.id ?? documentIdFromCurrentDate(); final job = @@ -72,11 +70,11 @@ class _EditJobPageState extends ConsumerState { Navigator.of(context).pop(); } } catch (e) { - unawaited(showExceptionAlertDialog( + await showExceptionAlertDialog( context: context, title: 'Operation failed', exception: e, - )); + ); } } } diff --git a/lib/app/home/jobs/jobs_page.dart b/lib/app/home/jobs/jobs_page.dart index e4ca59c4..178dcae8 100644 --- a/lib/app/home/jobs/jobs_page.dart +++ b/lib/app/home/jobs/jobs_page.dart @@ -1,4 +1,4 @@ -import 'package:flutter/cupertino.dart'; +import 'package:alert_dialogs/alert_dialogs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/app/home/job_entries/job_entries_page.dart'; @@ -6,14 +6,11 @@ import 'package:starter_architecture_flutter_firebase/app/home/jobs/edit_job_pag import 'package:starter_architecture_flutter_firebase/app/home/jobs/job_list_tile.dart'; import 'package:starter_architecture_flutter_firebase/app/home/jobs/list_items_builder.dart'; import 'package:starter_architecture_flutter_firebase/app/home/models/job.dart'; -import 'package:alert_dialogs/alert_dialogs.dart'; import 'package:starter_architecture_flutter_firebase/app/top_level_providers.dart'; import 'package:starter_architecture_flutter_firebase/constants/strings.dart'; -import 'package:pedantic/pedantic.dart'; -import 'package:starter_architecture_flutter_firebase/services/firestore_database.dart'; final jobsStreamProvider = StreamProvider.autoDispose>((ref) { - final database = ref.watch(databaseProvider)!; + final database = ref.watch(databaseProvider); return database.jobsStream(); }); @@ -21,14 +18,13 @@ final jobsStreamProvider = StreamProvider.autoDispose>((ref) { class JobsPage extends ConsumerWidget { Future _delete(BuildContext context, WidgetRef ref, Job job) async { try { - final database = ref.read(databaseProvider)!; - await database.deleteJob(job); + await ref.read(databaseProvider).deleteJob(job); } catch (e) { - unawaited(showExceptionAlertDialog( + await showExceptionAlertDialog( context: context, title: 'Operation failed', exception: e, - )); + ); } } @@ -44,23 +40,23 @@ class JobsPage extends ConsumerWidget { ), ], ), - body: _buildContents(context, ref), - ); - } - - Widget _buildContents(BuildContext context, WidgetRef ref) { - final jobsAsyncValue = ref.watch(jobsStreamProvider); - return ListItemsBuilder( - data: jobsAsyncValue, - itemBuilder: (context, job) => Dismissible( - key: Key('job-${job.id}'), - background: Container(color: Colors.red), - direction: DismissDirection.endToStart, - onDismissed: (direction) => _delete(context, ref, job), - child: JobListTile( - job: job, - onTap: () => JobEntriesPage.show(context, job), - ), + body: Consumer( + builder: (context, ref, child) { + final jobsAsyncValue = ref.watch(jobsStreamProvider); + return ListItemsBuilder( + data: jobsAsyncValue, + itemBuilder: (context, job) => Dismissible( + key: Key('job-${job.id}'), + background: Container(color: Colors.red), + direction: DismissDirection.endToStart, + onDismissed: (direction) => _delete(context, ref, job), + child: JobListTile( + job: job, + onTap: () => JobEntriesPage.show(context, job), + ), + ), + ); + }, ), ); } diff --git a/lib/app/localization/string_hardcoded.dart b/lib/app/localization/string_hardcoded.dart new file mode 100644 index 00000000..f064ec8b --- /dev/null +++ b/lib/app/localization/string_hardcoded.dart @@ -0,0 +1,5 @@ +/// A simple placeholder that can be used to search all the hardcoded strings +/// in the code (useful to identify strings that need to be localized). +extension StringHardcoded on String { + String get hardcoded => this; +} diff --git a/lib/app/onboarding/onboarding_page.dart b/lib/app/onboarding/onboarding_page.dart index 0c277ba1..45fbbea9 100644 --- a/lib/app/onboarding/onboarding_page.dart +++ b/lib/app/onboarding/onboarding_page.dart @@ -5,11 +5,6 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:starter_architecture_flutter_firebase/app/onboarding/onboarding_view_model.dart'; class OnboardingPage extends ConsumerWidget { - Future onGetStarted(BuildContext context, WidgetRef ref) async { - final onboardingViewModel = ref.read(onboardingViewModelProvider.notifier); - await onboardingViewModel.completeOnboarding(); - } - @override Widget build(BuildContext context, WidgetRef ref) { return Scaffold( @@ -30,7 +25,9 @@ class OnboardingPage extends ConsumerWidget { semanticsLabel: 'Time tracking logo'), ), CustomRaisedButton( - onPressed: () => onGetStarted(context, ref), + onPressed: () => ref + .read(onboardingViewModelProvider.notifier) + .completeOnboarding(), color: Colors.indigo, borderRadius: 30, child: Text( diff --git a/lib/app/repositories/app_user.dart b/lib/app/repositories/app_user.dart new file mode 100644 index 00000000..ba81b44c --- /dev/null +++ b/lib/app/repositories/app_user.dart @@ -0,0 +1,22 @@ +/// Simple class representing the user UID and email. +class AppUser { + const AppUser({ + required this.uid, + required this.email, + }); + final String uid; + final String email; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is AppUser && other.uid == uid && other.email == email; + } + + @override + int get hashCode => uid.hashCode ^ email.hashCode; + + @override + String toString() => 'AppUser(uid: $uid, email: $email)'; +} diff --git a/lib/app/repositories/fake_app_user.dart b/lib/app/repositories/fake_app_user.dart new file mode 100644 index 00000000..d4f3e0e9 --- /dev/null +++ b/lib/app/repositories/fake_app_user.dart @@ -0,0 +1,11 @@ +import 'package:starter_architecture_flutter_firebase/app/repositories/app_user.dart'; + +/// Fake user class used to simulate a user account on the backend +class FakeAppUser extends AppUser { + FakeAppUser({ + required super.uid, + required super.email, + required this.password, + }); + final String password; +} diff --git a/lib/app/repositories/fake_auth_repository.dart b/lib/app/repositories/fake_auth_repository.dart new file mode 100644 index 00000000..1b5f3e52 --- /dev/null +++ b/lib/app/repositories/fake_auth_repository.dart @@ -0,0 +1,31 @@ +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class AuthRepository { + AuthRepository(this._auth); + final FirebaseAuth _auth; + + Stream authStateChanges() => _auth.authStateChanges(); + User? get currentUser => _auth.currentUser; + + Future signInWithEmailAndPassword(String email, String password) { + return _auth.signInWithEmailAndPassword(email: email, password: password); + } + + Future createUserWithEmailAndPassword(String email, String password) { + return _auth.createUserWithEmailAndPassword( + email: email, password: password); + } + + Future signOut() { + return _auth.signOut(); + } +} + +final authRepositoryProvider = Provider((ref) { + return AuthRepository(FirebaseAuth.instance); +}); + +final authStateChangesProvider = StreamProvider((ref) { + return ref.watch(authRepositoryProvider).authStateChanges(); +}); diff --git a/lib/app/sign_in/email_password/email_password_sign_in_controller.dart b/lib/app/sign_in/email_password/email_password_sign_in_controller.dart new file mode 100644 index 00000000..587c5b92 --- /dev/null +++ b/lib/app/sign_in/email_password/email_password_sign_in_controller.dart @@ -0,0 +1,37 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:starter_architecture_flutter_firebase/app/repositories/fake_auth_repository.dart'; +import 'package:starter_architecture_flutter_firebase/app/sign_in/email_password/email_password_sign_in_form_type.dart'; + +class EmailPasswordSignInController extends AutoDisposeAsyncNotifier { + @override + FutureOr build() { + // ok to leave this empty if the return type is FutureOr + } + + Future submit( + {required String email, + required String password, + required EmailPasswordSignInFormType formType}) async { + state = const AsyncValue.loading(); + state = + await AsyncValue.guard(() => _authenticate(email, password, formType)); + return state.hasError == false; + } + + Future _authenticate( + String email, String password, EmailPasswordSignInFormType formType) { + final authRepository = ref.read(authRepositoryProvider); + switch (formType) { + case EmailPasswordSignInFormType.signIn: + return authRepository.signInWithEmailAndPassword(email, password); + case EmailPasswordSignInFormType.register: + return authRepository.createUserWithEmailAndPassword(email, password); + } + } +} + +final emailPasswordSignInControllerProvider = + AutoDisposeAsyncNotifierProvider( + EmailPasswordSignInController.new); diff --git a/lib/app/sign_in/email_password/email_password_sign_in_form_type.dart b/lib/app/sign_in/email_password/email_password_sign_in_form_type.dart new file mode 100644 index 00000000..aa0479f0 --- /dev/null +++ b/lib/app/sign_in/email_password/email_password_sign_in_form_type.dart @@ -0,0 +1,55 @@ +import 'package:starter_architecture_flutter_firebase/app/localization/string_hardcoded.dart'; + +/// Form type for email & password authentication +enum EmailPasswordSignInFormType { signIn, register } + +extension EmailPasswordSignInFormTypeX on EmailPasswordSignInFormType { + String get passwordLabelText { + if (this == EmailPasswordSignInFormType.register) { + return 'Password (8+ characters)'.hardcoded; + } else { + return 'Password'.hardcoded; + } + } + + // Getters + String get primaryButtonText { + if (this == EmailPasswordSignInFormType.register) { + return 'Create an account'.hardcoded; + } else { + return 'Sign in'.hardcoded; + } + } + + String get secondaryButtonText { + if (this == EmailPasswordSignInFormType.register) { + return 'Have an account? Sign in'.hardcoded; + } else { + return 'Need an account? Register'.hardcoded; + } + } + + EmailPasswordSignInFormType get secondaryActionFormType { + if (this == EmailPasswordSignInFormType.register) { + return EmailPasswordSignInFormType.signIn; + } else { + return EmailPasswordSignInFormType.register; + } + } + + String get errorAlertTitle { + if (this == EmailPasswordSignInFormType.register) { + return 'Registration failed'.hardcoded; + } else { + return 'Sign in failed'.hardcoded; + } + } + + String get title { + if (this == EmailPasswordSignInFormType.register) { + return 'Register'.hardcoded; + } else { + return 'Sign in'.hardcoded; + } + } +} diff --git a/lib/app/sign_in/email_password/email_password_sign_in_screen.dart b/lib/app/sign_in/email_password/email_password_sign_in_screen.dart new file mode 100644 index 00000000..82701e0b --- /dev/null +++ b/lib/app/sign_in/email_password/email_password_sign_in_screen.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:starter_architecture_flutter_firebase/app/common_widgets/custom_text_button.dart'; +import 'package:starter_architecture_flutter_firebase/app/common_widgets/primary_button.dart'; +import 'package:starter_architecture_flutter_firebase/app/common_widgets/responsive_scrollable_card.dart'; +import 'package:starter_architecture_flutter_firebase/app/constants/app_sizes.dart'; +import 'package:starter_architecture_flutter_firebase/app/localization/string_hardcoded.dart'; +import 'package:starter_architecture_flutter_firebase/app/sign_in/email_password/email_password_sign_in_controller.dart'; +import 'package:starter_architecture_flutter_firebase/app/sign_in/email_password/email_password_sign_in_form_type.dart'; +import 'package:starter_architecture_flutter_firebase/app/sign_in/email_password/email_password_sign_in_validators.dart'; +import 'package:starter_architecture_flutter_firebase/app/sign_in/email_password/string_validators.dart'; +import 'package:starter_architecture_flutter_firebase/app/utils/async_value_ui.dart'; + +/// Email & password sign in screen. +/// Wraps the [EmailPasswordSignInContents] widget below with a [Scaffold] and +/// [AppBar] with a title. +class EmailPasswordSignInScreen extends StatelessWidget { + const EmailPasswordSignInScreen({super.key, required this.formType}); + final EmailPasswordSignInFormType formType; + + // * Keys for testing using find.byKey() + static const emailKey = Key('email'); + static const passwordKey = Key('password'); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Sign In'.hardcoded)), + body: EmailPasswordSignInContents( + formType: formType, + ), + ); + } +} + +/// A widget for email & password authentication, supporting the following: +/// - sign in +/// - register (create an account) +class EmailPasswordSignInContents extends ConsumerStatefulWidget { + const EmailPasswordSignInContents({ + super.key, + this.onSignedIn, + required this.formType, + }); + final VoidCallback? onSignedIn; + + /// The default form type to use. + final EmailPasswordSignInFormType formType; + @override + ConsumerState createState() => + _EmailPasswordSignInContentsState(); +} + +class _EmailPasswordSignInContentsState + extends ConsumerState + with EmailAndPasswordValidators { + final _formKey = GlobalKey(); + final _node = FocusScopeNode(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + + String get email => _emailController.text; + String get password => _passwordController.text; + + // local variable used to apply AutovalidateMode.onUserInteraction and show + // error hints only when the form has been submitted + // For more details on how this is implemented, see: + // https://codewithandrea.com/articles/flutter-text-field-form-validation/ + var _submitted = false; + // track the formType as a local state variable + late var _formType = widget.formType; + + @override + void dispose() { + // * TextEditingControllers should be always disposed + _node.dispose(); + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _submit() async { + setState(() => _submitted = true); + // only submit the form if validation passes + if (_formKey.currentState!.validate()) { + final controller = + ref.read(emailPasswordSignInControllerProvider.notifier); + final success = await controller.submit( + email: email, + password: password, + formType: _formType, + ); + if (success) { + widget.onSignedIn?.call(); + } + } + } + + void _emailEditingComplete() { + if (canSubmitEmail(email)) { + _node.nextFocus(); + } + } + + void _passwordEditingComplete() { + if (!canSubmitEmail(email)) { + _node.previousFocus(); + return; + } + _submit(); + } + + void _updateFormType() { + // * Toggle between register and sign in form + setState(() => _formType = _formType.secondaryActionFormType); + // * Clear the password field when doing so + _passwordController.clear(); + } + + @override + Widget build(BuildContext context) { + ref.listen( + emailPasswordSignInControllerProvider, + (_, state) => state.showAlertDialogOnError(context), + ); + final state = ref.watch(emailPasswordSignInControllerProvider); + return ResponsiveScrollableCard( + child: FocusScope( + node: _node, + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + gapH8, + // Email field + TextFormField( + key: EmailPasswordSignInScreen.emailKey, + controller: _emailController, + decoration: InputDecoration( + labelText: 'Email'.hardcoded, + hintText: 'test@test.com'.hardcoded, + enabled: !state.isLoading, + ), + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: (email) => + !_submitted ? null : emailErrorText(email ?? ''), + autocorrect: false, + textInputAction: TextInputAction.next, + keyboardType: TextInputType.emailAddress, + keyboardAppearance: Brightness.light, + onEditingComplete: () => _emailEditingComplete(), + inputFormatters: [ + ValidatorInputFormatter( + editingValidator: EmailEditingRegexValidator()), + ], + ), + gapH8, + // Password field + TextFormField( + key: EmailPasswordSignInScreen.passwordKey, + controller: _passwordController, + decoration: InputDecoration( + labelText: _formType.passwordLabelText, + enabled: !state.isLoading, + ), + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: (password) => !_submitted + ? null + : passwordErrorText(password ?? '', _formType), + obscureText: true, + autocorrect: false, + textInputAction: TextInputAction.done, + keyboardAppearance: Brightness.light, + onEditingComplete: () => _passwordEditingComplete(), + ), + gapH8, + PrimaryButton( + text: _formType.primaryButtonText, + isLoading: state.isLoading, + onPressed: state.isLoading ? null : () => _submit(), + ), + gapH8, + CustomTextButton( + text: _formType.secondaryButtonText, + onPressed: state.isLoading ? null : _updateFormType, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/app/sign_in/email_password/email_password_sign_in_validators.dart b/lib/app/sign_in/email_password/email_password_sign_in_validators.dart new file mode 100644 index 00000000..c2bac3a2 --- /dev/null +++ b/lib/app/sign_in/email_password/email_password_sign_in_validators.dart @@ -0,0 +1,41 @@ +import 'package:starter_architecture_flutter_firebase/app/localization/string_hardcoded.dart'; +import 'package:starter_architecture_flutter_firebase/app/sign_in/email_password/email_password_sign_in_form_type.dart'; +import 'package:starter_architecture_flutter_firebase/app/sign_in/email_password/string_validators.dart'; + +/// Mixin class to be used for client-side email & password validation +mixin EmailAndPasswordValidators { + final StringValidator emailSubmitValidator = EmailSubmitRegexValidator(); + final StringValidator passwordRegisterSubmitValidator = + MinLengthStringValidator(8); + final StringValidator passwordSignInSubmitValidator = + NonEmptyStringValidator(); + + bool canSubmitEmail(String email) { + return emailSubmitValidator.isValid(email); + } + + bool canSubmitPassword( + String password, EmailPasswordSignInFormType formType) { + if (formType == EmailPasswordSignInFormType.register) { + return passwordRegisterSubmitValidator.isValid(password); + } + return passwordSignInSubmitValidator.isValid(password); + } + + String? emailErrorText(String email) { + final bool showErrorText = !canSubmitEmail(email); + final String errorText = email.isEmpty + ? 'Email can\'t be empty'.hardcoded + : 'Invalid email'.hardcoded; + return showErrorText ? errorText : null; + } + + String? passwordErrorText( + String password, EmailPasswordSignInFormType formType) { + final bool showErrorText = !canSubmitPassword(password, formType); + final String errorText = password.isEmpty + ? 'Password can\'t be empty'.hardcoded + : 'Password is too short'.hardcoded; + return showErrorText ? errorText : null; + } +} diff --git a/lib/app/sign_in/email_password/string_validators.dart b/lib/app/sign_in/email_password/string_validators.dart new file mode 100644 index 00000000..9733e604 --- /dev/null +++ b/lib/app/sign_in/email_password/string_validators.dart @@ -0,0 +1,72 @@ +import 'package:flutter/services.dart'; + +/// This file contains some helper functions used for string validation. + +abstract class StringValidator { + bool isValid(String value); +} + +class RegexValidator implements StringValidator { + RegexValidator({required this.regexSource}); + final String regexSource; + + @override + bool isValid(String value) { + try { + // https://regex101.com/ + final RegExp regex = RegExp(regexSource); + final Iterable matches = regex.allMatches(value); + for (final match in matches) { + if (match.start == 0 && match.end == value.length) { + return true; + } + } + return false; + } catch (e) { + // Invalid regex + assert(false, e.toString()); + return true; + } + } +} + +class ValidatorInputFormatter implements TextInputFormatter { + ValidatorInputFormatter({required this.editingValidator}); + final StringValidator editingValidator; + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, TextEditingValue newValue) { + final bool oldValueValid = editingValidator.isValid(oldValue.text); + final bool newValueValid = editingValidator.isValid(newValue.text); + if (oldValueValid && !newValueValid) { + return oldValue; + } + return newValue; + } +} + +class EmailEditingRegexValidator extends RegexValidator { + EmailEditingRegexValidator() : super(regexSource: '^(|\\S)+\$'); +} + +class EmailSubmitRegexValidator extends RegexValidator { + EmailSubmitRegexValidator() : super(regexSource: '^\\S+@\\S+\\.\\S+\$'); +} + +class NonEmptyStringValidator extends StringValidator { + @override + bool isValid(String value) { + return value.isNotEmpty; + } +} + +class MinLengthStringValidator extends StringValidator { + MinLengthStringValidator(this.minLength); + final int minLength; + + @override + bool isValid(String value) { + return value.length >= minLength; + } +} diff --git a/lib/app/sign_in/sign_in_page.dart b/lib/app/sign_in/sign_in_page.dart index d82085f8..c6baae8e 100644 --- a/lib/app/sign_in/sign_in_page.dart +++ b/lib/app/sign_in/sign_in_page.dart @@ -1,14 +1,13 @@ import 'dart:math'; import 'package:alert_dialogs/alert_dialogs.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/app/top_level_providers.dart'; -import 'package:starter_architecture_flutter_firebase/app/sign_in/sign_in_view_model.dart'; import 'package:starter_architecture_flutter_firebase/app/sign_in/sign_in_button.dart'; +import 'package:starter_architecture_flutter_firebase/app/sign_in/sign_in_view_model.dart'; +import 'package:starter_architecture_flutter_firebase/app/top_level_providers.dart'; import 'package:starter_architecture_flutter_firebase/constants/keys.dart'; import 'package:starter_architecture_flutter_firebase/constants/strings.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:starter_architecture_flutter_firebase/routing/app_router.dart'; final signInModelProvider = ChangeNotifierProvider( @@ -18,8 +17,7 @@ final signInModelProvider = ChangeNotifierProvider( class SignInPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final signInModel = ref.watch(signInModelProvider); - ref.listen(signInModelProvider, (_, model) async { + ref.listen(signInModelProvider, (prev, model) async { if (model.error != null) { await showExceptionAlertDialog( context: context, @@ -28,6 +26,7 @@ class SignInPage extends ConsumerWidget { ); } }); + final signInModel = ref.watch(signInModelProvider); return SignInPageContents( viewModel: signInModel, title: 'Architecture Demo', diff --git a/lib/app/top_level_providers.dart b/lib/app/top_level_providers.dart index b908e819..025134c3 100644 --- a/lib/app/top_level_providers.dart +++ b/lib/app/top_level_providers.dart @@ -6,16 +6,16 @@ import 'package:starter_architecture_flutter_firebase/services/firestore_databas final firebaseAuthProvider = Provider((ref) => FirebaseAuth.instance); -final authStateChangesProvider = StreamProvider.autoDispose( +final authStateChangesProvider = StreamProvider( (ref) => ref.watch(firebaseAuthProvider).authStateChanges()); -final databaseProvider = Provider.autoDispose((ref) { - final auth = ref.watch(authStateChangesProvider); +final databaseProvider = Provider((ref) { + final authStateAsync = ref.watch(authStateChangesProvider); - if (auth.asData?.value?.uid != null) { - return FirestoreDatabase(uid: auth.asData!.value!.uid); + if (authStateAsync.value?.uid != null) { + return FirestoreDatabase(uid: authStateAsync.value!.uid); } - return null; + throw UnimplementedError(); }); final loggerProvider = Provider((ref) => Logger( diff --git a/lib/app/utils/async_value_ui.dart b/lib/app/utils/async_value_ui.dart new file mode 100644 index 00000000..52ee06dc --- /dev/null +++ b/lib/app/utils/async_value_ui.dart @@ -0,0 +1,17 @@ +import 'package:alert_dialogs/alert_dialogs.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:starter_architecture_flutter_firebase/app/localization/string_hardcoded.dart'; + +extension AsyncValueUI on AsyncValue { + void showAlertDialogOnError(BuildContext context) { + if (!isRefreshing && hasError) { + final message = error.toString(); + showExceptionAlertDialog( + context: context, + title: 'Error'.hardcoded, + exception: message, + ); + } + } +} diff --git a/lib/main.dart b/lib/main.dart index 70dfddc1..3d54c7f6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,15 +1,15 @@ //import 'package:auth_widget_builder/auth_widget_builder.dart'; import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:starter_architecture_flutter_firebase/app/auth_widget.dart'; import 'package:starter_architecture_flutter_firebase/app/home/home_page.dart'; import 'package:starter_architecture_flutter_firebase/app/onboarding/onboarding_page.dart'; import 'package:starter_architecture_flutter_firebase/app/onboarding/onboarding_view_model.dart'; -import 'package:starter_architecture_flutter_firebase/app/top_level_providers.dart'; import 'package:starter_architecture_flutter_firebase/app/sign_in/sign_in_page.dart'; +import 'package:starter_architecture_flutter_firebase/app/top_level_providers.dart'; import 'package:starter_architecture_flutter_firebase/routing/app_router.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/services/shared_preferences_service.dart'; Future main() async { @@ -29,13 +29,13 @@ Future main() async { class MyApp extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final firebaseAuth = ref.watch(firebaseAuthProvider); + final firebaseAuth = ref.read(firebaseAuthProvider); return MaterialApp( theme: ThemeData(primarySwatch: Colors.indigo), debugShowCheckedModeBanner: false, home: AuthWidget( nonSignedInBuilder: (_) => Consumer( - builder: (context, ref, _) { + builder: (context, watch, _) { final didCompleteOnboarding = ref.watch(onboardingViewModelProvider); return didCompleteOnboarding ? SignInPage() : OnboardingPage(); diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index 49a821c6..4c8f1e5c 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -1,6 +1,7 @@ -import 'package:email_password_sign_in_ui/email_password_sign_in_ui.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; +import 'package:starter_architecture_flutter_firebase/app/sign_in/email_password/email_password_sign_in_form_type.dart'; +import 'package:starter_architecture_flutter_firebase/app/sign_in/email_password/email_password_sign_in_screen.dart'; import 'package:starter_architecture_flutter_firebase/app/home/job_entries/entry_page.dart'; import 'package:starter_architecture_flutter_firebase/app/home/jobs/edit_job_page.dart'; import 'package:starter_architecture_flutter_firebase/app/home/models/entry.dart'; @@ -12,6 +13,7 @@ class AppRoutes { static const entryPage = '/entry-page'; } +// ignore: avoid_classes_with_only_static_members class AppRouter { static Route? onGenerateRoute( RouteSettings settings, FirebaseAuth firebaseAuth) { @@ -19,8 +21,9 @@ class AppRouter { switch (settings.name) { case AppRoutes.emailPasswordSignInPage: return MaterialPageRoute( - builder: (_) => EmailPasswordSignInPage.withFirebaseAuth(firebaseAuth, - onSignedIn: args as void Function()), + builder: (_) => const EmailPasswordSignInScreen( + formType: EmailPasswordSignInFormType.signIn, + ), settings: settings, fullscreenDialog: true, ); diff --git a/packages/alert_dialogs/.gitignore b/packages/alert_dialogs/.gitignore new file mode 100644 index 00000000..bb431f0d --- /dev/null +++ b/packages/alert_dialogs/.gitignore @@ -0,0 +1,75 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages diff --git a/packages/alert_dialogs/.metadata b/packages/alert_dialogs/.metadata new file mode 100644 index 00000000..616b0463 --- /dev/null +++ b/packages/alert_dialogs/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: e6b34c2b5c96bb95325269a29a84e83ed8909b5f + channel: stable + +project_type: package diff --git a/packages/alert_dialogs/CHANGELOG.md b/packages/alert_dialogs/CHANGELOG.md new file mode 100644 index 00000000..1b493db4 --- /dev/null +++ b/packages/alert_dialogs/CHANGELOG.md @@ -0,0 +1,5 @@ +## [0.0.1] - 2020-05-16 + +* Initial release +* Add `showAlertDialog`, `showExceptionAlertDialog` methods +* Add `PlatformWeb` extension on `Platform` \ No newline at end of file diff --git a/packages/alert_dialogs/LICENSE b/packages/alert_dialogs/LICENSE new file mode 100644 index 00000000..6dbdfbbc --- /dev/null +++ b/packages/alert_dialogs/LICENSE @@ -0,0 +1,7 @@ +Copyright (c) 2020 Andrea Bizzotto [bizz84@gmail.com](mailto:bizz84@gmail.com) + +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: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +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. \ No newline at end of file diff --git a/packages/alert_dialogs/README.md b/packages/alert_dialogs/README.md new file mode 100644 index 00000000..596d939f --- /dev/null +++ b/packages/alert_dialogs/README.md @@ -0,0 +1,15 @@ +# alert_dialogs + +Platform-aware alert dialogs. + +Supported: +- title +- content (message) +- (optional) cancel action button +- default action button + +NOTE: The author will only maintain this package for his own internal projects. It will **not** be published on [pub.dev](https://pub.dev) and, while you're free to use it, it's not meant to be a community project. + +Breaking changes may be introduced at any time. + +[LICENSE: MIT](LICENSE) \ No newline at end of file diff --git a/packages/alert_dialogs/lib/alert_dialogs.dart b/packages/alert_dialogs/lib/alert_dialogs.dart new file mode 100644 index 00000000..6c26926c --- /dev/null +++ b/packages/alert_dialogs/lib/alert_dialogs.dart @@ -0,0 +1,12 @@ +library alert_dialogs; + +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/services.dart'; + +part 'show_alert_dialog.dart'; +part 'show_exception_alert_dialog.dart'; diff --git a/packages/alert_dialogs/lib/show_alert_dialog.dart b/packages/alert_dialogs/lib/show_alert_dialog.dart new file mode 100644 index 00000000..6a26798b --- /dev/null +++ b/packages/alert_dialogs/lib/show_alert_dialog.dart @@ -0,0 +1,48 @@ +part of alert_dialogs; + +Future showAlertDialog({ + required BuildContext context, + required String title, + required String content, + String? cancelActionText, + required String defaultActionText, +}) async { + if (kIsWeb || !Platform.isIOS) { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: Text(content), + actions: [ + if (cancelActionText != null) + TextButton( + child: Text(cancelActionText), + onPressed: () => Navigator.of(context).pop(false), + ), + TextButton( + child: Text(defaultActionText), + onPressed: () => Navigator.of(context).pop(true), + ), + ], + ), + ); + } + return showCupertinoDialog( + context: context, + builder: (context) => CupertinoAlertDialog( + title: Text(title), + content: Text(content), + actions: [ + if (cancelActionText != null) + CupertinoDialogAction( + child: Text(cancelActionText), + onPressed: () => Navigator.of(context).pop(false), + ), + CupertinoDialogAction( + child: Text(defaultActionText), + onPressed: () => Navigator.of(context).pop(true), + ), + ], + ), + ); +} diff --git a/packages/alert_dialogs/lib/show_exception_alert_dialog.dart b/packages/alert_dialogs/lib/show_exception_alert_dialog.dart new file mode 100644 index 00000000..0bbc6146 --- /dev/null +++ b/packages/alert_dialogs/lib/show_exception_alert_dialog.dart @@ -0,0 +1,42 @@ +part of alert_dialogs; + +Future showExceptionAlertDialog({ + required BuildContext context, + required String title, + required dynamic exception, +}) => + showAlertDialog( + context: context, + title: title, + content: _message(exception), + defaultActionText: 'OK', + ); + +String _message(dynamic exception) { + if (exception is FirebaseException) { + return exception.message ?? exception.toString(); + } + if (exception is PlatformException) { + return exception.message ?? exception.toString(); + } + return exception.toString(); +} + +// TODO: Revisit this +// NOTE: The full list of FirebaseAuth errors is stored here: +// https://github.com/firebase/firebase-ios-sdk/blob/2e77efd786e4895d50c3788371ec15980c729053/Firebase/Auth/Source/FIRAuthErrorUtils.m +// These are just the most relevant for email & password sign in: +// Map _errors = { +// 'ERROR_WEAK_PASSWORD': 'The password must be 8 characters long or more.', +// 'ERROR_INVALID_CREDENTIAL': 'The email address is badly formatted.', +// 'ERROR_EMAIL_ALREADY_IN_USE': +// 'The email address is already registered. Sign in instead?', +// 'ERROR_INVALID_EMAIL': 'The email address is badly formatted.', +// 'ERROR_WRONG_PASSWORD': 'The password is incorrect. Please try again.', +// 'ERROR_USER_NOT_FOUND': +// 'The email address is not registered. Need an account?', +// 'ERROR_TOO_MANY_REQUESTS': +// 'We have blocked all requests from this device due to unusual activity. Try again later.', +// 'ERROR_OPERATION_NOT_ALLOWED': +// 'This sign in method is not allowed. Please contact support.', +// }; diff --git a/packages/alert_dialogs/pubspec.yaml b/packages/alert_dialogs/pubspec.yaml new file mode 100644 index 00000000..6c90ffad --- /dev/null +++ b/packages/alert_dialogs/pubspec.yaml @@ -0,0 +1,53 @@ +name: alert_dialogs +description: Helper methods for showing alert dialogs +version: 0.2.0 +homepage: https://www.codewithandrea.com + +environment: + sdk: ">=2.17.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + firebase_core: + +dev_dependencies: + flutter_test: + sdk: flutter + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + + # To add assets to your package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # To add custom fonts to your package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/custom-fonts/#from-packages diff --git a/packages/custom_buttons/.gitignore b/packages/custom_buttons/.gitignore new file mode 100644 index 00000000..bb431f0d --- /dev/null +++ b/packages/custom_buttons/.gitignore @@ -0,0 +1,75 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages diff --git a/packages/custom_buttons/.metadata b/packages/custom_buttons/.metadata new file mode 100644 index 00000000..616b0463 --- /dev/null +++ b/packages/custom_buttons/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: e6b34c2b5c96bb95325269a29a84e83ed8909b5f + channel: stable + +project_type: package diff --git a/packages/custom_buttons/CHANGELOG.md b/packages/custom_buttons/CHANGELOG.md new file mode 100644 index 00000000..14316fa5 --- /dev/null +++ b/packages/custom_buttons/CHANGELOG.md @@ -0,0 +1,4 @@ +## [0.0.1] - 2020-05-16 + +* Initial release +* Add `CustomRaisedButton`, `FormSubmitButton` classes diff --git a/packages/custom_buttons/LICENSE b/packages/custom_buttons/LICENSE new file mode 100644 index 00000000..6dbdfbbc --- /dev/null +++ b/packages/custom_buttons/LICENSE @@ -0,0 +1,7 @@ +Copyright (c) 2020 Andrea Bizzotto [bizz84@gmail.com](mailto:bizz84@gmail.com) + +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: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +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. \ No newline at end of file diff --git a/packages/custom_buttons/README.md b/packages/custom_buttons/README.md new file mode 100644 index 00000000..ffcc9aa4 --- /dev/null +++ b/packages/custom_buttons/README.md @@ -0,0 +1,9 @@ +# custom_buttons + +Some custom reusable button widget classes. + +NOTE: The author will only maintain this package for his own internal projects. It will **not** be published on [pub.dev](https://pub.dev) and, while you're free to use it, it's not meant to be a community project. + +Breaking changes may be introduced at any time. + +[LICENSE: MIT](LICENSE) \ No newline at end of file diff --git a/packages/custom_buttons/lib/custom_buttons.dart b/packages/custom_buttons/lib/custom_buttons.dart new file mode 100644 index 00000000..fc268dde --- /dev/null +++ b/packages/custom_buttons/lib/custom_buttons.dart @@ -0,0 +1,6 @@ +library custom_buttons; + +import 'package:flutter/material.dart'; + +part 'custom_raised_button.dart'; +part 'form_submit_button.dart'; diff --git a/packages/custom_buttons/lib/custom_raised_button.dart b/packages/custom_buttons/lib/custom_raised_button.dart new file mode 100644 index 00000000..f0c04265 --- /dev/null +++ b/packages/custom_buttons/lib/custom_raised_button.dart @@ -0,0 +1,57 @@ +part of custom_buttons; + +@immutable +class CustomRaisedButton extends StatelessWidget { + const CustomRaisedButton({ + Key? key, + required this.child, + this.color, + this.textColor, + this.height = 50.0, + this.borderRadius = 4.0, + this.loading = false, + this.onPressed, + }) : super(key: key); + final Widget child; + final Color? color; + final Color? textColor; + final double height; + final double borderRadius; + final bool loading; + final VoidCallback? onPressed; + + Widget buildSpinner(BuildContext context) { + final ThemeData data = Theme.of(context); + return Theme( + data: data.copyWith(accentColor: Colors.white70), + child: const SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator( + strokeWidth: 3.0, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: height, + child: ElevatedButton( + child: loading ? buildSpinner(context) : child, + // shape: RoundedRectangleBorder( + // borderRadius: BorderRadius.all( + // Radius.circular(borderRadius), + // ), + // ), + style: ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: textColor, + disabledForegroundColor: textColor // foreground (text) color + ), // height / 2 + onPressed: onPressed, + ), + ); + } +} diff --git a/packages/custom_buttons/lib/form_submit_button.dart b/packages/custom_buttons/lib/form_submit_button.dart new file mode 100644 index 00000000..14ba2aac --- /dev/null +++ b/packages/custom_buttons/lib/form_submit_button.dart @@ -0,0 +1,21 @@ +part of custom_buttons; + +class FormSubmitButton extends CustomRaisedButton { + FormSubmitButton({ + Key? key, + required String text, + bool loading = false, + VoidCallback? onPressed, + }) : super( + key: key, + child: Text( + text, + style: const TextStyle(color: Colors.white, fontSize: 20.0), + ), + height: 44.0, + color: Colors.indigo, + textColor: Colors.black87, + loading: loading, + onPressed: onPressed, + ); +} diff --git a/packages/custom_buttons/pubspec.yaml b/packages/custom_buttons/pubspec.yaml new file mode 100644 index 00000000..f626e2a8 --- /dev/null +++ b/packages/custom_buttons/pubspec.yaml @@ -0,0 +1,53 @@ +name: custom_buttons +description: A new Flutter package project. +version: 0.1.0 +author: +homepage: + +environment: + sdk: ">=2.17.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + + # To add assets to your package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # To add custom fonts to your package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/custom-fonts/#from-packages diff --git a/packages/firestore_service/.gitignore b/packages/firestore_service/.gitignore new file mode 100644 index 00000000..bb431f0d --- /dev/null +++ b/packages/firestore_service/.gitignore @@ -0,0 +1,75 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages diff --git a/packages/firestore_service/.metadata b/packages/firestore_service/.metadata new file mode 100644 index 00000000..616b0463 --- /dev/null +++ b/packages/firestore_service/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: e6b34c2b5c96bb95325269a29a84e83ed8909b5f + channel: stable + +project_type: package diff --git a/packages/firestore_service/CHANGELOG.md b/packages/firestore_service/CHANGELOG.md new file mode 100644 index 00000000..38398ba0 --- /dev/null +++ b/packages/firestore_service/CHANGELOG.md @@ -0,0 +1,12 @@ +## [0.2.1] + +* Update to `cloud_firestore` 2.2.0 + +## [0.1.0] + +* Port to Null Safety and `cloud_firestore` 1.0.0 + +## [0.0.1] - 2020-05-16 + +* Initial release +* Add `FirestoreService` class. Supports `setData`, `deleteData`, `collectionStream`, `documentStream` methods. diff --git a/packages/firestore_service/LICENSE b/packages/firestore_service/LICENSE new file mode 100644 index 00000000..6dbdfbbc --- /dev/null +++ b/packages/firestore_service/LICENSE @@ -0,0 +1,7 @@ +Copyright (c) 2020 Andrea Bizzotto [bizz84@gmail.com](mailto:bizz84@gmail.com) + +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: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +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. \ No newline at end of file diff --git a/packages/firestore_service/README.md b/packages/firestore_service/README.md new file mode 100644 index 00000000..ae18cb8a --- /dev/null +++ b/packages/firestore_service/README.md @@ -0,0 +1,13 @@ +# firestore_service + +This package includes `FirestoreService`, a wrapper class for the `cloud_firestore` APIs. + +`FirestoreService` uses generics and the builder pattern to provide a type-based abstraction on top of `cloud_firestore`. + +It covers only a very limited subset of APIs from `cloud_firestore`. + +NOTE: The author will only maintain this package for his own internal projects. It will **not** be published on [pub.dev](https://pub.dev) and, while you're free to use it, it's not meant to be a community project. + +Breaking changes may be introduced at any time. + +[LICENSE: MIT](LICENSE) \ No newline at end of file diff --git a/packages/firestore_service/lib/firestore_service.dart b/packages/firestore_service/lib/firestore_service.dart new file mode 100644 index 00000000..f7bd83c8 --- /dev/null +++ b/packages/firestore_service/lib/firestore_service.dart @@ -0,0 +1,61 @@ +library firestore_service; + +import 'package:cloud_firestore/cloud_firestore.dart'; + +class FirestoreService { + FirestoreService._(); + static final instance = FirestoreService._(); + + Future setData({ + required String path, + required Map data, + bool merge = false, + }) async { + final reference = FirebaseFirestore.instance.doc(path); + print('$path: $data'); + await reference.set(data, SetOptions(merge: merge)); + } + + Future deleteData({required String path}) async { + final reference = FirebaseFirestore.instance.doc(path); + print('delete: $path'); + await reference.delete(); + } + + Stream> collectionStream({ + required String path, + required T Function(Map? data, String documentID) builder, + Query>? Function(Query> query)? + queryBuilder, + int Function(T lhs, T rhs)? sort, + }) { + Query> query = + FirebaseFirestore.instance.collection(path); + if (queryBuilder != null) { + query = queryBuilder(query)!; + } + final Stream>> snapshots = + query.snapshots(); + return snapshots.map((snapshot) { + final result = snapshot.docs + .map((snapshot) => builder(snapshot.data(), snapshot.id)) + .where((value) => value != null) + .toList(); + if (sort != null) { + result.sort(sort); + } + return result; + }); + } + + Stream documentStream({ + required String path, + required T Function(Map? data, String documentID) builder, + }) { + final DocumentReference> reference = + FirebaseFirestore.instance.doc(path); + final Stream>> snapshots = + reference.snapshots(); + return snapshots.map((snapshot) => builder(snapshot.data(), snapshot.id)); + } +} diff --git a/packages/firestore_service/pubspec.yaml b/packages/firestore_service/pubspec.yaml new file mode 100644 index 00000000..76e4631b --- /dev/null +++ b/packages/firestore_service/pubspec.yaml @@ -0,0 +1,53 @@ +name: firestore_service +description: Wrapper for Cloud Firestore +version: 0.2.1 +author: Andrea Bizzotto +homepage: https://www.codewithandrea.com + +environment: + sdk: ">=2.12.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + cloud_firestore: ^2.2.0 + +dev_dependencies: + flutter_test: + sdk: flutter + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec +# The following section is specific to Flutter. +flutter: null + +# To add assets to your package, add an assets section, like this: +# assets: +# - images/a_dot_burr.jpeg +# - images/a_dot_ham.jpeg +# +# For details regarding assets in packages, see +# https://flutter.dev/assets-and-images/#from-packages +# +# An image asset can refer to one or more resolution-specific "variants", see +# https://flutter.dev/assets-and-images/#resolution-aware. +# To add custom fonts to your package, add a fonts section here, +# in this "flutter" section. Each entry in this list should have a +# "family" key with the font family name, and a "fonts" key with a +# list giving the asset and other descriptors for the font. For +# example: +# fonts: +# - family: Schyler +# fonts: +# - asset: fonts/Schyler-Regular.ttf +# - asset: fonts/Schyler-Italic.ttf +# style: italic +# - family: Trajan Pro +# fonts: +# - asset: fonts/TrajanPro.ttf +# - asset: fonts/TrajanPro_Bold.ttf +# weight: 700 +# +# For details regarding fonts in packages, see +# https://flutter.dev/custom-fonts/#from-packages + diff --git a/pubspec.yaml b/pubspec.yaml index 4a9a0483..4b4e4b60 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,5 @@ name: starter_architecture_flutter_firebase description: A new Flutter project. -publish_to: none # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 @@ -12,53 +11,38 @@ publish_to: none # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.1.0+2 +version: 1.1.0+1 environment: - sdk: ">=2.14.0 <3.0.0" + sdk: ">=2.17.0 <3.0.0" dependencies: flutter: sdk: flutter alert_dialogs: - git: - url: https://github.com/bizz84/codewithandrea_flutter_packages - path: packages/alert_dialogs + path: packages/alert_dialogs custom_buttons: - git: - url: https://github.com/bizz84/codewithandrea_flutter_packages - path: packages/custom_buttons - email_password_sign_in_ui: - git: - url: https://github.com/bizz84/codewithandrea_flutter_packages - path: packages/email_password_sign_in_ui + path: packages/custom_buttons firestore_service: - git: - url: https://github.com/bizz84/codewithandrea_flutter_packages - path: packages/firestore_service - cloud_firestore: ^2.2.2 - cupertino_icons: ^1.0.2 - equatable: ^2.0.3 - firebase_auth: ^1.4.1 - firebase_core: ^1.3.0 - flutter_riverpod: ^1.0.0 - flutter_svg: ^0.22.0 - intl: ^0.17.0 - logger: ^1.0.0 - rxdart: ^0.27.1 - shared_preferences: ^2.0.6 - state_notifier: ^0.7.0 + path: packages/firestore_service + cloud_firestore: + cupertino_icons: + equatable: + firebase_auth: + firebase_core: + flutter_riverpod: 2.0.2 + flutter_svg: + intl: + logger: + rxdart: + shared_preferences: dev_dependencies: - flutter_driver: - sdk: flutter flutter_test: sdk: flutter - build_runner: ^1.11.5 - mocktail: ^0.1.4 - random_string: ^2.3.1 - test: ^1.16.5 - flutter_lints: ^1.0.4 + build_runner: + mocktail: + random_string: # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec From 117aa6fcc36b5ca40a0d19b47b5abc91e4771f73 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Fri, 28 Oct 2022 11:44:49 +0100 Subject: [PATCH 02/45] Update Android, iOS, Firebase project --- .gitignore | 4 +- .metadata | 24 ++- android/.gitignore | 6 + android/app/build.gradle | 33 ++-- android/app/src/debug/AndroidManifest.xml | 3 +- android/app/src/main/AndroidManifest.xml | 18 +- .../MainActivity.kt | 6 - .../res/drawable-v21/launch_background.xml | 12 ++ .../app/src/main/res/values-night/styles.xml | 18 ++ android/app/src/main/res/values/styles.xml | 14 +- android/app/src/profile/AndroidManifest.xml | 3 +- android/build.gradle | 12 +- android/gradle.properties | 1 - .../gradle/wrapper/gradle-wrapper.properties | 3 +- android/settings.gradle | 18 +- ios/.gitignore | 2 + ios/Flutter/AppFrameworkInfo.plist | 4 +- ios/Flutter/Debug.xcconfig | 2 +- ios/Flutter/Release.xcconfig | 2 +- ios/Podfile | 7 +- ios/Runner.xcodeproj/project.pbxproj | 184 ++++++++---------- .../xcshareddata/xcschemes/Runner.xcscheme | 10 +- ios/Runner/Info.plist | 6 + ios/Runner/Runner-Bridging-Header.h | 2 +- lib/main.dart | 5 +- pubspec.yaml | 44 +---- test/widget_test.dart | 30 +++ 27 files changed, 251 insertions(+), 222 deletions(-) create mode 100644 android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 android/app/src/main/res/values-night/styles.xml create mode 100644 test/widget_test.dart diff --git a/.gitignore b/.gitignore index e3947272..b2e058a0 100644 --- a/.gitignore +++ b/.gitignore @@ -97,4 +97,6 @@ web/firebase-config.js # Firebase configuration files ios/Runner/GoogleService-Info.plist -android/app/google-services.json \ No newline at end of file +android/app/google-services.json +lib/firebase_options.dart +ios/firebase_app_id_file.json diff --git a/.metadata b/.metadata index 1b5cec02..e773ea52 100644 --- a/.metadata +++ b/.metadata @@ -1,10 +1,30 @@ # This file tracks properties of this Flutter project. # Used by Flutter tool to assess capabilities and perform upgrades etc. # -# This file should be version controlled and should not be manually edited. +# This file should be version controlled. version: - revision: 27321ebbad34b0a3fafe99fac037102196d655ff + revision: e3c29ec00c9c825c891d75054c63fcc46454dca1 channel: stable project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: e3c29ec00c9c825c891d75054c63fcc46454dca1 + base_revision: e3c29ec00c9c825c891d75054c63fcc46454dca1 + - platform: ios + create_revision: e3c29ec00c9c825c891d75054c63fcc46454dca1 + base_revision: e3c29ec00c9c825c891d75054c63fcc46454dca1 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/android/.gitignore b/android/.gitignore index bc2100d8..6f568019 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -5,3 +5,9 @@ gradle-wrapper.jar /gradlew.bat /local.properties GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle b/android/app/build.gradle index 53a2c9e9..89300300 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -22,29 +22,38 @@ if (flutterVersionName == null) { } apply plugin: 'com.android.application' +// START: FlutterFire Configuration +apply plugin: 'com.google.gms.google-services' +// END: FlutterFire Configuration apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion 29 + compileSdkVersion flutter.compileSdkVersion + ndkVersion flutter.ndkVersion - sourceSets { - main.java.srcDirs += 'src/main/kotlin' + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 } - lintOptions { - disable 'InvalidPackage' + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' } defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.example.starter_architecture_flutter_firebase" - minSdkVersion 21 - targetSdkVersion 29 + // You can update the following values to match your application needs. + // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration. + minSdkVersion flutter.minSdkVersion + targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - multiDexEnabled true } buildTypes { @@ -62,10 +71,4 @@ flutter { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - testImplementation 'junit:junit:4.12' - androidTestImplementation 'androidx.test:runner:1.1.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' - implementation 'com.android.support:multidex:1.0.3' } - -apply plugin: 'com.google.gms.google-services' diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index b1cd231c..56ddc3b2 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -1,6 +1,7 @@ - diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4875fc57..dbb58b1a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,21 +1,25 @@ - - + + diff --git a/android/app/src/main/kotlin/com/example/starter_architecture_flutter_firebase/MainActivity.kt b/android/app/src/main/kotlin/com/example/starter_architecture_flutter_firebase/MainActivity.kt index 6c5f8a08..057eaaf7 100644 --- a/android/app/src/main/kotlin/com/example/starter_architecture_flutter_firebase/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/starter_architecture_flutter_firebase/MainActivity.kt @@ -1,12 +1,6 @@ package com.example.starter_architecture_flutter_firebase -import androidx.annotation.NonNull; import io.flutter.embedding.android.FlutterActivity -import io.flutter.embedding.engine.FlutterEngine -import io.flutter.plugins.GeneratedPluginRegistrant class MainActivity: FlutterActivity() { - override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { - GeneratedPluginRegistrant.registerWith(flutterEngine); - } } diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 00000000..f74085f3 --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000..06952be7 --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 00fa4417..cb1ef880 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -1,8 +1,18 @@ - + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml index b1cd231c..56ddc3b2 100644 --- a/android/app/src/profile/AndroidManifest.xml +++ b/android/app/src/profile/AndroidManifest.xml @@ -1,6 +1,7 @@ - diff --git a/android/build.gradle b/android/build.gradle index aafab52b..9192654d 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,13 +1,15 @@ buildscript { - ext.kotlin_version = '1.5.0' + ext.kotlin_version = '1.6.10' repositories { google() - jcenter() + mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:4.1.0' - classpath 'com.google.gms:google-services:4.0.1' + classpath 'com.android.tools.build:gradle:7.1.2' + // START: FlutterFire Configuration + classpath 'com.google.gms:google-services:4.3.10' + // END: FlutterFire Configuration classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -15,7 +17,7 @@ buildscript { allprojects { repositories { google() - jcenter() + mavenCentral() } } diff --git a/android/gradle.properties b/android/gradle.properties index 38c8d454..94adc3a3 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,3 @@ org.gradle.jvmargs=-Xmx1536M -android.enableR8=true android.useAndroidX=true android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 939efa29..cb24abda 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Fri Jun 23 08:50:38 CEST 2017 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index 5a2f14fb..44e62bcf 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,15 +1,11 @@ include ':app' -def flutterProjectRoot = rootProject.projectDir.parentFile.toPath() +def localPropertiesFile = new File(rootProject.projectDir, "local.properties") +def properties = new Properties() -def plugins = new Properties() -def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') -if (pluginsFile.exists()) { - pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) } -} +assert localPropertiesFile.exists() +localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } -plugins.each { name, path -> - def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() - include ":$name" - project(":$name").projectDir = pluginDirectory -} +def flutterSdkPath = properties.getProperty("flutter.sdk") +assert flutterSdkPath != null, "flutter.sdk not set in local.properties" +apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/ios/.gitignore b/ios/.gitignore index e96ef602..7a7f9873 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -1,3 +1,4 @@ +**/dgph *.mode1v3 *.mode2v3 *.moved-aside @@ -18,6 +19,7 @@ Flutter/App.framework Flutter/Flutter.framework Flutter/Flutter.podspec Flutter/Generated.xcconfig +Flutter/ephemeral/ Flutter/app.flx Flutter/app.zip Flutter/flutter_assets/ diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index f2872cf4..9625e105 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -3,7 +3,7 @@ CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) + en CFBundleExecutable App CFBundleIdentifier @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 9.0 + 11.0 diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index e8efba11..ec97fc6f 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1,2 +1,2 @@ -#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index 399e9340..c4855bfe 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1,2 +1,2 @@ -#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile index 6a824b1e..ee23e027 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '10.0' +platform :ios, '11.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -28,13 +28,10 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe flutter_ios_podfile_setup target 'Runner' do + pod 'FirebaseFirestore', :git => 'https://github.com/invertase/firestore-ios-sdk-frameworks.git', :tag => '9.6.0' use_frameworks! use_modular_headers! - # Use this to speed things up with precompiled binary. - # More info here: https://github.com/invertase/firestore-ios-sdk-frameworks - #pod 'FirebaseFirestore', :git => 'https://github.com/invertase/firestore-ios-sdk-frameworks.git', :tag => '7.3.0' - flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) end diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 453add67..2e9119d3 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,18 +3,18 @@ archiveVersion = 1; classes = { }; - objectVersion = 46; + objectVersion = 50; objects = { /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 6543D66123DC4D96003E597A /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 6543D66023DC4D96003E597A /* GoogleService-Info.plist */; }; - 6F5D4EBB0FC7186A260B9736 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92EBAE997B345C97CC736C63 /* Pods_Runner.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + FDFBBE3698D6DEE111B2295A /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BE5E729672CE82DA371BE171 /* Pods_Runner.framework */; }; + FEBDC9609C680EC02937034B /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = D4DF1EAB0E660ABB5816D63F /* GoogleService-Info.plist */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -34,12 +34,10 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 6543D66023DC4D96003E597A /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; + 4717B7480637AA09CAEB3FC0 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 74B49E421CD42F3C6750BD3F /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 92EBAE997B345C97CC736C63 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -47,8 +45,10 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - B806766DCEDA019CE6F53E9B /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - BF5302CB579B37E0835290F6 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + B1282A8CCA46FCB860DC6348 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + BE5E729672CE82DA371BE171 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D1CF56C09A84454F93ABC256 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + D4DF1EAB0E660ABB5816D63F /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -56,7 +56,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 6F5D4EBB0FC7186A260B9736 /* Pods_Runner.framework in Frameworks */, + FDFBBE3698D6DEE111B2295A /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -80,8 +80,9 @@ 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, - 9A5F0FFF436695CFCF6AB113 /* Pods */, - B63212B0E8BDEC5B71B9B990 /* Frameworks */, + D4DF1EAB0E660ABB5816D63F /* GoogleService-Info.plist */, + F90C4DD405ACFD15095CAEB7 /* Pods */, + BB5370A4696621AC47A50471 /* Frameworks */, ); sourceTree = ""; }; @@ -100,8 +101,6 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, - 6543D66023DC4D96003E597A /* GoogleService-Info.plist */, - 97C146F11CF9000F007C117D /* Supporting Files */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, @@ -110,31 +109,25 @@ path = Runner; sourceTree = ""; }; - 97C146F11CF9000F007C117D /* Supporting Files */ = { + BB5370A4696621AC47A50471 /* Frameworks */ = { isa = PBXGroup; children = ( + BE5E729672CE82DA371BE171 /* Pods_Runner.framework */, ); - name = "Supporting Files"; + name = Frameworks; sourceTree = ""; }; - 9A5F0FFF436695CFCF6AB113 /* Pods */ = { + F90C4DD405ACFD15095CAEB7 /* Pods */ = { isa = PBXGroup; children = ( - B806766DCEDA019CE6F53E9B /* Pods-Runner.debug.xcconfig */, - 74B49E421CD42F3C6750BD3F /* Pods-Runner.release.xcconfig */, - BF5302CB579B37E0835290F6 /* Pods-Runner.profile.xcconfig */, + B1282A8CCA46FCB860DC6348 /* Pods-Runner.debug.xcconfig */, + 4717B7480637AA09CAEB3FC0 /* Pods-Runner.release.xcconfig */, + D1CF56C09A84454F93ABC256 /* Pods-Runner.profile.xcconfig */, ); + name = Pods; path = Pods; sourceTree = ""; }; - B63212B0E8BDEC5B71B9B990 /* Frameworks */ = { - isa = PBXGroup; - children = ( - 92EBAE997B345C97CC736C63 /* Pods_Runner.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -142,14 +135,15 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - 2565013367BDC3DBCCAF82FD /* [CP] Check Pods Manifest.lock */, + 703CB65CA5A94374E71D4B9A /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 2B7B18995DEAAA40F7376ED3 /* [CP] Embed Pods Frameworks */, + 48CDBEA72553743FA74C2372 /* [CP] Embed Pods Frameworks */, + 3FB93D246001D3A79E3BB749 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -166,8 +160,8 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1020; - ORGANIZATIONNAME = "The Chromium Authors"; + LastUpgradeCheck = 1300; + ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; @@ -176,7 +170,7 @@ }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 3.2"; + compatibilityVersion = "Xcode 9.3"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -200,96 +194,84 @@ files = ( 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, - 6543D66123DC4D96003E597A /* GoogleService-Info.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + FEBDC9609C680EC02937034B /* GoogleService-Info.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 2565013367BDC3DBCCAF82FD /* [CP] Check Pods Manifest.lock */ = { + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); - inputFileListPaths = ( - ); inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( ); + name = "Thin Binary"; outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 3FB93D246001D3A79E3BB749 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; showEnvVarsInLog = 0; }; - 2B7B18995DEAAA40F7376ED3 /* [CP] Embed Pods Frameworks */ = { + 48CDBEA72553743FA74C2372 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh", - "${BUILT_PRODUCTS_DIR}/BoringSSL-GRPC/openssl_grpc.framework", - "${BUILT_PRODUCTS_DIR}/FirebaseAuth/FirebaseAuth.framework", - "${BUILT_PRODUCTS_DIR}/FirebaseCore/FirebaseCore.framework", - "${BUILT_PRODUCTS_DIR}/FirebaseCoreDiagnostics/FirebaseCoreDiagnostics.framework", - "${BUILT_PRODUCTS_DIR}/FirebaseFirestore/FirebaseFirestore.framework", - "${BUILT_PRODUCTS_DIR}/GTMSessionFetcher/GTMSessionFetcher.framework", - "${BUILT_PRODUCTS_DIR}/GoogleDataTransport/GoogleDataTransport.framework", - "${BUILT_PRODUCTS_DIR}/GoogleUtilities/GoogleUtilities.framework", - "${BUILT_PRODUCTS_DIR}/PromisesObjC/FBLPromises.framework", - "${BUILT_PRODUCTS_DIR}/abseil/absl.framework", - "${BUILT_PRODUCTS_DIR}/gRPC-C++/grpcpp.framework", - "${BUILT_PRODUCTS_DIR}/gRPC-Core/grpc.framework", - "${BUILT_PRODUCTS_DIR}/leveldb-library/leveldb.framework", - "${BUILT_PRODUCTS_DIR}/nanopb/nanopb.framework", - "${BUILT_PRODUCTS_DIR}/shared_preferences/shared_preferences.framework", + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); name = "[CP] Embed Pods Frameworks"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/openssl_grpc.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseAuth.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseCore.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseCoreDiagnostics.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseFirestore.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GTMSessionFetcher.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GoogleDataTransport.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GoogleUtilities.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FBLPromises.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/absl.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/grpcpp.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/grpc.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/leveldb.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/nanopb.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/shared_preferences.framework", + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + 703CB65CA5A94374E71D4B9A /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); + inputFileListPaths = ( + ); inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( ); - name = "Thin Binary"; outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; @@ -380,7 +362,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -396,17 +378,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = M54ZVB688G; ENABLE_BITCODE = NO; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Flutter", - ); INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - LIBRARY_SEARCH_PATHS = ( + LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", - "$(PROJECT_DIR)/Flutter", + "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.example.starterArchitectureFlutterFirebase; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -463,7 +440,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -512,11 +489,12 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; @@ -529,17 +507,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = M54ZVB688G; ENABLE_BITCODE = NO; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Flutter", - ); INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - LIBRARY_SEARCH_PATHS = ( + LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", - "$(PROJECT_DIR)/Flutter", + "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.example.starterArchitectureFlutterFirebase; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -557,17 +530,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = M54ZVB688G; ENABLE_BITCODE = NO; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Flutter", - ); INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - LIBRARY_SEARCH_PATHS = ( + LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", - "$(PROJECT_DIR)/Flutter", + "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.example.starterArchitectureFlutterFirebase; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index a28140cf..c87d15a3 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ - - - - + + - - CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Starter Architecture Flutter Firebase CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -41,5 +43,9 @@ UIViewControllerBasedStatusBarAppearance + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h index 7335fdf9..308a2a56 100644 --- a/ios/Runner/Runner-Bridging-Header.h +++ b/ios/Runner/Runner-Bridging-Header.h @@ -1 +1 @@ -#import "GeneratedPluginRegistrant.h" \ No newline at end of file +#import "GeneratedPluginRegistrant.h" diff --git a/lib/main.dart b/lib/main.dart index 3d54c7f6..7dfad40e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,12 +9,15 @@ import 'package:starter_architecture_flutter_firebase/app/onboarding/onboarding_ import 'package:starter_architecture_flutter_firebase/app/onboarding/onboarding_view_model.dart'; import 'package:starter_architecture_flutter_firebase/app/sign_in/sign_in_page.dart'; import 'package:starter_architecture_flutter_firebase/app/top_level_providers.dart'; +import 'package:starter_architecture_flutter_firebase/firebase_options.dart'; import 'package:starter_architecture_flutter_firebase/routing/app_router.dart'; import 'package:starter_architecture_flutter_firebase/services/shared_preferences_service.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - await Firebase.initializeApp(); + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); final sharedPreferences = await SharedPreferences.getInstance(); runApp(ProviderScope( overrides: [ diff --git a/pubspec.yaml b/pubspec.yaml index 4b4e4b60..08c31bd5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,17 +1,8 @@ name: starter_architecture_flutter_firebase description: A new Flutter project. -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.1.0+1 +publish_to: 'none' +version: 2.0.0 environment: sdk: ">=2.17.0 <3.0.0" @@ -44,38 +35,7 @@ dev_dependencies: mocktail: random_string: -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec -# The following section is specific to Flutter. flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. uses-material-design: true - # To add assets to your application, add an assets section, like this: assets: - assets/time-tracking.svg - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware. - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/assets-and-images/#from-packages - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/custom-fonts/#from-packages diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 00000000..35b2a9f9 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:starter_architecture_flutter_firebase/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} From 8fab65e3bda170d14c8c627bd65ea26691161a59 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Fri, 28 Oct 2022 13:27:18 +0100 Subject: [PATCH 03/45] Fix argument type --- lib/app/home/account/account_page.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/app/home/account/account_page.dart b/lib/app/home/account/account_page.dart index 34d32652..08dbc5fd 100644 --- a/lib/app/home/account/account_page.dart +++ b/lib/app/home/account/account_page.dart @@ -22,8 +22,7 @@ class AccountPage extends ConsumerWidget { } } - Future _confirmSignOut( - BuildContext context, FirebaseAuth firebaseAuth) async { + Future _confirmSignOut(BuildContext context, WidgetRef ref) async { final bool didRequestSignOut = await showAlertDialog( context: context, title: Strings.logout, @@ -33,7 +32,7 @@ class AccountPage extends ConsumerWidget { ) ?? false; if (didRequestSignOut == true) { - await _signOut(context, firebaseAuth); + await _signOut(context, ref); } } From 102d5fef22a28c90e9a9f1d9e4acd1b05778ca01 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Fri, 28 Oct 2022 13:37:13 +0100 Subject: [PATCH 04/45] Refactor folders --- lib/main.dart | 16 +++++++-------- lib/{app => src}/auth_widget.dart | 4 ++-- lib/{ => src}/common_widgets/avatar.dart | 0 .../common_widgets/custom_text_button.dart | 2 +- .../common_widgets/date_time_picker.dart | 4 ++-- .../common_widgets/input_dropdown.dart | 0 .../common_widgets/primary_button.dart | 2 +- .../common_widgets/responsive_center.dart | 2 +- .../responsive_scrollable_card.dart | 6 +++--- .../common_widgets/segmented_control.dart | 0 lib/{app => src}/constants/app_sizes.dart | 0 lib/{app => src}/constants/breakpoints.dart | 0 lib/{ => src}/constants/keys.dart | 0 lib/{ => src}/constants/strings.dart | 0 .../features}/account/account_page.dart | 8 ++++---- .../email_password_sign_in_controller.dart | 4 ++-- .../email_password_sign_in_form_type.dart | 2 +- .../email_password_sign_in_screen.dart | 20 +++++++++---------- .../email_password_sign_in_validators.dart | 6 +++--- .../email_password/string_validators.dart | 0 .../features}/entries/daily_jobs_details.dart | 2 +- .../features}/entries/entries_list_tile.dart | 0 .../features}/entries/entries_page.dart | 10 +++++----- .../features}/entries/entries_view_model.dart | 14 ++++++------- .../features}/entries/entry_job.dart | 4 ++-- .../home/cupertino_home_scaffold.dart | 6 +++--- lib/{app => src/features}/home/home_page.dart | 10 +++++----- .../features}/home/models/entry.dart | 0 .../features}/home/models/job.dart | 0 lib/{app => src/features}/home/tab_item.dart | 4 ++-- .../job_entries/entry_list_item.dart | 6 +++--- .../features}/job_entries/entry_page.dart | 14 ++++++------- .../features}/job_entries/format.dart | 0 .../job_entries/job_entries_page.dart | 16 +++++++-------- .../features}/jobs/edit_job_page.dart | 8 ++++---- .../features}/jobs/empty_content.dart | 0 .../features}/jobs/job_list_tile.dart | 2 +- .../home => src/features}/jobs/jobs_page.dart | 14 ++++++------- .../features}/jobs/list_items_builder.dart | 2 +- .../features}/onboarding/onboarding_page.dart | 2 +- .../onboarding/onboarding_view_model.dart | 3 +-- .../features}/sign_in/sign_in_button.dart | 0 .../features}/sign_in/sign_in_page.dart | 12 +++++------ .../features}/sign_in/sign_in_view_model.dart | 0 .../localization/string_hardcoded.dart | 0 lib/{app => src}/repositories/app_user.dart | 0 .../repositories/fake_app_user.dart | 2 +- .../repositories/fake_auth_repository.dart | 0 lib/{ => src}/routing/app_router.dart | 12 +++++------ .../routing/cupertino_tab_view_router.dart | 5 +++-- .../services/firestore_database.dart | 6 +++--- lib/{ => src}/services/firestore_path.dart | 0 .../services/shared_preferences_service.dart | 0 lib/{app => src}/top_level_providers.dart | 2 +- lib/{app => src}/utils/async_value_ui.dart | 2 +- test/job_test.dart | 2 +- 56 files changed, 118 insertions(+), 118 deletions(-) rename lib/{app => src}/auth_widget.dart (87%) rename lib/{ => src}/common_widgets/avatar.dart (100%) rename lib/{app => src}/common_widgets/custom_text_button.dart (90%) rename lib/{ => src}/common_widgets/date_time_picker.dart (90%) rename lib/{ => src}/common_widgets/input_dropdown.dart (100%) rename lib/{app => src}/common_widgets/primary_button.dart (94%) rename lib/{app => src}/common_widgets/responsive_center.dart (96%) rename lib/{app => src}/common_widgets/responsive_scrollable_card.dart (81%) rename lib/{ => src}/common_widgets/segmented_control.dart (100%) rename lib/{app => src}/constants/app_sizes.dart (100%) rename lib/{app => src}/constants/breakpoints.dart (100%) rename lib/{ => src}/constants/keys.dart (100%) rename lib/{ => src}/constants/strings.dart (100%) rename lib/{app/home => src/features}/account/account_page.dart (87%) rename lib/{app/sign_in => src/features}/email_password/email_password_sign_in_controller.dart (91%) rename lib/{app/sign_in => src/features}/email_password/email_password_sign_in_form_type.dart (95%) rename lib/{app/sign_in => src/features}/email_password/email_password_sign_in_screen.dart (91%) rename lib/{app/sign_in => src/features}/email_password/email_password_sign_in_validators.dart (88%) rename lib/{app/sign_in => src/features}/email_password/string_validators.dart (100%) rename lib/{app/home => src/features}/entries/daily_jobs_details.dart (96%) rename lib/{app/home => src/features}/entries/entries_list_tile.dart (100%) rename lib/{app/home => src/features}/entries/entries_page.dart (68%) rename lib/{app/home => src/features}/entries/entries_view_model.dart (78%) rename lib/{app/home => src/features}/entries/entry_job.dart (50%) rename lib/{app => src/features}/home/cupertino_home_scaffold.dart (87%) rename lib/{app => src/features}/home/home_page.dart (76%) rename lib/{app => src/features}/home/models/entry.dart (100%) rename lib/{app => src/features}/home/models/job.dart (100%) rename lib/{app => src/features}/home/tab_item.dart (80%) rename lib/{app/home => src/features}/job_entries/entry_list_item.dart (93%) rename lib/{app/home => src/features}/job_entries/entry_page.dart (89%) rename lib/{app/home => src/features}/job_entries/format.dart (100%) rename lib/{app/home => src/features}/job_entries/job_entries_page.dart (82%) rename lib/{app/home => src/features}/jobs/edit_job_page.dart (92%) rename lib/{app/home => src/features}/jobs/empty_content.dart (100%) rename lib/{app/home => src/features}/jobs/job_list_tile.dart (86%) rename lib/{app/home => src/features}/jobs/jobs_page.dart (74%) rename lib/{app/home => src/features}/jobs/list_items_builder.dart (92%) rename lib/{app => src/features}/onboarding/onboarding_page.dart (95%) rename lib/{app => src/features}/onboarding/onboarding_view_model.dart (82%) rename lib/{app => src/features}/sign_in/sign_in_button.dart (100%) rename lib/{app => src/features}/sign_in/sign_in_page.dart (89%) rename lib/{app => src/features}/sign_in/sign_in_view_model.dart (100%) rename lib/{app => src}/localization/string_hardcoded.dart (100%) rename lib/{app => src}/repositories/app_user.dart (100%) rename lib/{app => src}/repositories/fake_app_user.dart (79%) rename lib/{app => src}/repositories/fake_auth_repository.dart (100%) rename lib/{ => src}/routing/app_router.dart (72%) rename lib/{ => src}/routing/cupertino_tab_view_router.dart (71%) rename lib/{ => src}/services/firestore_database.dart (89%) rename lib/{ => src}/services/firestore_path.dart (100%) rename lib/{ => src}/services/shared_preferences_service.dart (100%) rename lib/{app => src}/top_level_providers.dart (89%) rename lib/{app => src}/utils/async_value_ui.dart (88%) diff --git a/lib/main.dart b/lib/main.dart index 7dfad40e..2f610724 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,15 +3,15 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:starter_architecture_flutter_firebase/app/auth_widget.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/home_page.dart'; -import 'package:starter_architecture_flutter_firebase/app/onboarding/onboarding_page.dart'; -import 'package:starter_architecture_flutter_firebase/app/onboarding/onboarding_view_model.dart'; -import 'package:starter_architecture_flutter_firebase/app/sign_in/sign_in_page.dart'; -import 'package:starter_architecture_flutter_firebase/app/top_level_providers.dart'; import 'package:starter_architecture_flutter_firebase/firebase_options.dart'; -import 'package:starter_architecture_flutter_firebase/routing/app_router.dart'; -import 'package:starter_architecture_flutter_firebase/services/shared_preferences_service.dart'; +import 'package:starter_architecture_flutter_firebase/src/auth_widget.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/sign_in/sign_in_page.dart'; +import 'package:starter_architecture_flutter_firebase/src/home/home_page.dart'; +import 'package:starter_architecture_flutter_firebase/src/onboarding/onboarding_page.dart'; +import 'package:starter_architecture_flutter_firebase/src/onboarding/onboarding_view_model.dart'; +import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; +import 'package:starter_architecture_flutter_firebase/src/services/shared_preferences_service.dart'; +import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/app/auth_widget.dart b/lib/src/auth_widget.dart similarity index 87% rename from lib/app/auth_widget.dart rename to lib/src/auth_widget.dart index 7aaeecd8..1cdf4887 100644 --- a/lib/app/auth_widget.dart +++ b/lib/src/auth_widget.dart @@ -1,8 +1,8 @@ import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/jobs/empty_content.dart'; -import 'package:starter_architecture_flutter_firebase/app/top_level_providers.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/empty_content.dart'; +import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; class AuthWidget extends ConsumerWidget { const AuthWidget({ diff --git a/lib/common_widgets/avatar.dart b/lib/src/common_widgets/avatar.dart similarity index 100% rename from lib/common_widgets/avatar.dart rename to lib/src/common_widgets/avatar.dart diff --git a/lib/app/common_widgets/custom_text_button.dart b/lib/src/common_widgets/custom_text_button.dart similarity index 90% rename from lib/app/common_widgets/custom_text_button.dart rename to lib/src/common_widgets/custom_text_button.dart index 42020740..f710ba3f 100644 --- a/lib/app/common_widgets/custom_text_button.dart +++ b/lib/src/common_widgets/custom_text_button.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:starter_architecture_flutter_firebase/app/constants/app_sizes.dart'; +import 'package:starter_architecture_flutter_firebase/src/constants/app_sizes.dart'; /// Custom text button with a fixed height class CustomTextButton extends StatelessWidget { diff --git a/lib/common_widgets/date_time_picker.dart b/lib/src/common_widgets/date_time_picker.dart similarity index 90% rename from lib/common_widgets/date_time_picker.dart rename to lib/src/common_widgets/date_time_picker.dart index 5cfd166f..fc7fce98 100644 --- a/lib/common_widgets/date_time_picker.dart +++ b/lib/src/common_widgets/date_time_picker.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/job_entries/format.dart'; -import 'package:starter_architecture_flutter_firebase/common_widgets/input_dropdown.dart'; +import 'package:starter_architecture_flutter_firebase/src/common_widgets/input_dropdown.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/job_entries/format.dart'; class DateTimePicker extends StatelessWidget { const DateTimePicker({ diff --git a/lib/common_widgets/input_dropdown.dart b/lib/src/common_widgets/input_dropdown.dart similarity index 100% rename from lib/common_widgets/input_dropdown.dart rename to lib/src/common_widgets/input_dropdown.dart diff --git a/lib/app/common_widgets/primary_button.dart b/lib/src/common_widgets/primary_button.dart similarity index 94% rename from lib/app/common_widgets/primary_button.dart rename to lib/src/common_widgets/primary_button.dart index 651f82f2..7e037778 100644 --- a/lib/app/common_widgets/primary_button.dart +++ b/lib/src/common_widgets/primary_button.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:starter_architecture_flutter_firebase/app/constants/app_sizes.dart'; +import 'package:starter_architecture_flutter_firebase/src/constants/app_sizes.dart'; /// Primary button based on [ElevatedButton]. /// Useful for CTAs in the app. diff --git a/lib/app/common_widgets/responsive_center.dart b/lib/src/common_widgets/responsive_center.dart similarity index 96% rename from lib/app/common_widgets/responsive_center.dart rename to lib/src/common_widgets/responsive_center.dart index 5e53b0de..95d4d5d4 100644 --- a/lib/app/common_widgets/responsive_center.dart +++ b/lib/src/common_widgets/responsive_center.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:starter_architecture_flutter_firebase/app/constants/breakpoints.dart'; +import 'package:starter_architecture_flutter_firebase/src/constants/breakpoints.dart'; /// Reusable widget for showing a child with a maximum content width constraint. /// If available width is larger than the maximum width, the child will be diff --git a/lib/app/common_widgets/responsive_scrollable_card.dart b/lib/src/common_widgets/responsive_scrollable_card.dart similarity index 81% rename from lib/app/common_widgets/responsive_scrollable_card.dart rename to lib/src/common_widgets/responsive_scrollable_card.dart index e1795520..cf2e90fa 100644 --- a/lib/app/common_widgets/responsive_scrollable_card.dart +++ b/lib/src/common_widgets/responsive_scrollable_card.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:starter_architecture_flutter_firebase/app/common_widgets/responsive_center.dart'; -import 'package:starter_architecture_flutter_firebase/app/constants/app_sizes.dart'; -import 'package:starter_architecture_flutter_firebase/app/constants/breakpoints.dart'; +import 'package:starter_architecture_flutter_firebase/src/common_widgets/responsive_center.dart'; +import 'package:starter_architecture_flutter_firebase/src/constants/app_sizes.dart'; +import 'package:starter_architecture_flutter_firebase/src/constants/breakpoints.dart'; /// Scrollable widget that shows a responsive card with a given child widget. /// Useful for displaying forms and other widgets that need to be scrollable. diff --git a/lib/common_widgets/segmented_control.dart b/lib/src/common_widgets/segmented_control.dart similarity index 100% rename from lib/common_widgets/segmented_control.dart rename to lib/src/common_widgets/segmented_control.dart diff --git a/lib/app/constants/app_sizes.dart b/lib/src/constants/app_sizes.dart similarity index 100% rename from lib/app/constants/app_sizes.dart rename to lib/src/constants/app_sizes.dart diff --git a/lib/app/constants/breakpoints.dart b/lib/src/constants/breakpoints.dart similarity index 100% rename from lib/app/constants/breakpoints.dart rename to lib/src/constants/breakpoints.dart diff --git a/lib/constants/keys.dart b/lib/src/constants/keys.dart similarity index 100% rename from lib/constants/keys.dart rename to lib/src/constants/keys.dart diff --git a/lib/constants/strings.dart b/lib/src/constants/strings.dart similarity index 100% rename from lib/constants/strings.dart rename to lib/src/constants/strings.dart diff --git a/lib/app/home/account/account_page.dart b/lib/src/features/account/account_page.dart similarity index 87% rename from lib/app/home/account/account_page.dart rename to lib/src/features/account/account_page.dart index 08dbc5fd..03031afa 100644 --- a/lib/app/home/account/account_page.dart +++ b/lib/src/features/account/account_page.dart @@ -4,10 +4,10 @@ import 'package:alert_dialogs/alert_dialogs.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/app/repositories/fake_auth_repository.dart'; -import 'package:starter_architecture_flutter_firebase/common_widgets/avatar.dart'; -import 'package:starter_architecture_flutter_firebase/constants/keys.dart'; -import 'package:starter_architecture_flutter_firebase/constants/strings.dart'; +import 'package:starter_architecture_flutter_firebase/src/common_widgets/avatar.dart'; +import 'package:starter_architecture_flutter_firebase/src/constants/keys.dart'; +import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; +import 'package:starter_architecture_flutter_firebase/src/repositories/fake_auth_repository.dart'; class AccountPage extends ConsumerWidget { Future _signOut(BuildContext context, WidgetRef ref) async { diff --git a/lib/app/sign_in/email_password/email_password_sign_in_controller.dart b/lib/src/features/email_password/email_password_sign_in_controller.dart similarity index 91% rename from lib/app/sign_in/email_password/email_password_sign_in_controller.dart rename to lib/src/features/email_password/email_password_sign_in_controller.dart index 587c5b92..5719ad72 100644 --- a/lib/app/sign_in/email_password/email_password_sign_in_controller.dart +++ b/lib/src/features/email_password/email_password_sign_in_controller.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/app/repositories/fake_auth_repository.dart'; -import 'package:starter_architecture_flutter_firebase/app/sign_in/email_password/email_password_sign_in_form_type.dart'; +import 'package:starter_architecture_flutter_firebase/src/repositories/fake_auth_repository.dart'; +import 'package:starter_architecture_flutter_firebase/src/sign_in/email_password/email_password_sign_in_form_type.dart'; class EmailPasswordSignInController extends AutoDisposeAsyncNotifier { @override diff --git a/lib/app/sign_in/email_password/email_password_sign_in_form_type.dart b/lib/src/features/email_password/email_password_sign_in_form_type.dart similarity index 95% rename from lib/app/sign_in/email_password/email_password_sign_in_form_type.dart rename to lib/src/features/email_password/email_password_sign_in_form_type.dart index aa0479f0..8a112d90 100644 --- a/lib/app/sign_in/email_password/email_password_sign_in_form_type.dart +++ b/lib/src/features/email_password/email_password_sign_in_form_type.dart @@ -1,4 +1,4 @@ -import 'package:starter_architecture_flutter_firebase/app/localization/string_hardcoded.dart'; +import 'package:starter_architecture_flutter_firebase/src/localization/string_hardcoded.dart'; /// Form type for email & password authentication enum EmailPasswordSignInFormType { signIn, register } diff --git a/lib/app/sign_in/email_password/email_password_sign_in_screen.dart b/lib/src/features/email_password/email_password_sign_in_screen.dart similarity index 91% rename from lib/app/sign_in/email_password/email_password_sign_in_screen.dart rename to lib/src/features/email_password/email_password_sign_in_screen.dart index 82701e0b..6239c86e 100644 --- a/lib/app/sign_in/email_password/email_password_sign_in_screen.dart +++ b/lib/src/features/email_password/email_password_sign_in_screen.dart @@ -1,16 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/app/common_widgets/custom_text_button.dart'; -import 'package:starter_architecture_flutter_firebase/app/common_widgets/primary_button.dart'; -import 'package:starter_architecture_flutter_firebase/app/common_widgets/responsive_scrollable_card.dart'; -import 'package:starter_architecture_flutter_firebase/app/constants/app_sizes.dart'; -import 'package:starter_architecture_flutter_firebase/app/localization/string_hardcoded.dart'; -import 'package:starter_architecture_flutter_firebase/app/sign_in/email_password/email_password_sign_in_controller.dart'; -import 'package:starter_architecture_flutter_firebase/app/sign_in/email_password/email_password_sign_in_form_type.dart'; -import 'package:starter_architecture_flutter_firebase/app/sign_in/email_password/email_password_sign_in_validators.dart'; -import 'package:starter_architecture_flutter_firebase/app/sign_in/email_password/string_validators.dart'; -import 'package:starter_architecture_flutter_firebase/app/utils/async_value_ui.dart'; +import 'package:starter_architecture_flutter_firebase/src/common_widgets/custom_text_button.dart'; +import 'package:starter_architecture_flutter_firebase/src/common_widgets/primary_button.dart'; +import 'package:starter_architecture_flutter_firebase/src/common_widgets/responsive_scrollable_card.dart'; +import 'package:starter_architecture_flutter_firebase/src/constants/app_sizes.dart'; +import 'package:starter_architecture_flutter_firebase/src/localization/string_hardcoded.dart'; +import 'package:starter_architecture_flutter_firebase/src/sign_in/email_password/email_password_sign_in_controller.dart'; +import 'package:starter_architecture_flutter_firebase/src/sign_in/email_password/email_password_sign_in_form_type.dart'; +import 'package:starter_architecture_flutter_firebase/src/sign_in/email_password/email_password_sign_in_validators.dart'; +import 'package:starter_architecture_flutter_firebase/src/sign_in/email_password/string_validators.dart'; +import 'package:starter_architecture_flutter_firebase/src/utils/async_value_ui.dart'; /// Email & password sign in screen. /// Wraps the [EmailPasswordSignInContents] widget below with a [Scaffold] and diff --git a/lib/app/sign_in/email_password/email_password_sign_in_validators.dart b/lib/src/features/email_password/email_password_sign_in_validators.dart similarity index 88% rename from lib/app/sign_in/email_password/email_password_sign_in_validators.dart rename to lib/src/features/email_password/email_password_sign_in_validators.dart index c2bac3a2..260561d0 100644 --- a/lib/app/sign_in/email_password/email_password_sign_in_validators.dart +++ b/lib/src/features/email_password/email_password_sign_in_validators.dart @@ -1,6 +1,6 @@ -import 'package:starter_architecture_flutter_firebase/app/localization/string_hardcoded.dart'; -import 'package:starter_architecture_flutter_firebase/app/sign_in/email_password/email_password_sign_in_form_type.dart'; -import 'package:starter_architecture_flutter_firebase/app/sign_in/email_password/string_validators.dart'; +import 'package:starter_architecture_flutter_firebase/src/localization/string_hardcoded.dart'; +import 'package:starter_architecture_flutter_firebase/src/sign_in/email_password/email_password_sign_in_form_type.dart'; +import 'package:starter_architecture_flutter_firebase/src/sign_in/email_password/string_validators.dart'; /// Mixin class to be used for client-side email & password validation mixin EmailAndPasswordValidators { diff --git a/lib/app/sign_in/email_password/string_validators.dart b/lib/src/features/email_password/string_validators.dart similarity index 100% rename from lib/app/sign_in/email_password/string_validators.dart rename to lib/src/features/email_password/string_validators.dart diff --git a/lib/app/home/entries/daily_jobs_details.dart b/lib/src/features/entries/daily_jobs_details.dart similarity index 96% rename from lib/app/home/entries/daily_jobs_details.dart rename to lib/src/features/entries/daily_jobs_details.dart index 51fbf223..359561f7 100644 --- a/lib/app/home/entries/daily_jobs_details.dart +++ b/lib/src/features/entries/daily_jobs_details.dart @@ -1,4 +1,4 @@ -import 'package:starter_architecture_flutter_firebase/app/home/entries/entry_job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/entries/entry_job.dart'; /// Temporary model class to store the time tracked and pay for a job class JobDetails { diff --git a/lib/app/home/entries/entries_list_tile.dart b/lib/src/features/entries/entries_list_tile.dart similarity index 100% rename from lib/app/home/entries/entries_list_tile.dart rename to lib/src/features/entries/entries_list_tile.dart diff --git a/lib/app/home/entries/entries_page.dart b/lib/src/features/entries/entries_page.dart similarity index 68% rename from lib/app/home/entries/entries_page.dart rename to lib/src/features/entries/entries_page.dart index fed06e28..29ac2469 100644 --- a/lib/app/home/entries/entries_page.dart +++ b/lib/src/features/entries/entries_page.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/entries/entries_list_tile.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/entries/entries_view_model.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/jobs/list_items_builder.dart'; -import 'package:starter_architecture_flutter_firebase/app/top_level_providers.dart'; -import 'package:starter_architecture_flutter_firebase/constants/strings.dart'; +import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/entries/entries_list_tile.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/entries/entries_view_model.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/list_items_builder.dart'; +import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; final entriesTileModelStreamProvider = StreamProvider.autoDispose>( diff --git a/lib/app/home/entries/entries_view_model.dart b/lib/src/features/entries/entries_view_model.dart similarity index 78% rename from lib/app/home/entries/entries_view_model.dart rename to lib/src/features/entries/entries_view_model.dart index 36c31a7c..1926a298 100644 --- a/lib/app/home/entries/entries_view_model.dart +++ b/lib/src/features/entries/entries_view_model.dart @@ -1,11 +1,11 @@ import 'package:rxdart/rxdart.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/entries/daily_jobs_details.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/entries/entries_list_tile.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/entries/entry_job.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/job_entries/format.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/models/job.dart'; -import 'package:starter_architecture_flutter_firebase/services/firestore_database.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/entries/daily_jobs_details.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/entries/entries_list_tile.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/entries/entry_job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/job_entries/format.dart'; +import 'package:starter_architecture_flutter_firebase/src/home/models/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/services/firestore_database.dart'; class EntriesViewModel { EntriesViewModel({required this.database}); diff --git a/lib/app/home/entries/entry_job.dart b/lib/src/features/entries/entry_job.dart similarity index 50% rename from lib/app/home/entries/entry_job.dart rename to lib/src/features/entries/entry_job.dart index 6ad7d19c..d51e3b9f 100644 --- a/lib/app/home/entries/entry_job.dart +++ b/lib/src/features/entries/entry_job.dart @@ -1,5 +1,5 @@ -import 'package:starter_architecture_flutter_firebase/app/home/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/home/models/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/home/models/job.dart'; class EntryJob { EntryJob(this.entry, this.job); diff --git a/lib/app/home/cupertino_home_scaffold.dart b/lib/src/features/home/cupertino_home_scaffold.dart similarity index 87% rename from lib/app/home/cupertino_home_scaffold.dart rename to lib/src/features/home/cupertino_home_scaffold.dart index 48323d52..b81d2602 100644 --- a/lib/app/home/cupertino_home_scaffold.dart +++ b/lib/src/features/home/cupertino_home_scaffold.dart @@ -1,8 +1,8 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/tab_item.dart'; -import 'package:starter_architecture_flutter_firebase/constants/keys.dart'; -import 'package:starter_architecture_flutter_firebase/routing/cupertino_tab_view_router.dart'; +import 'package:starter_architecture_flutter_firebase/src/constants/keys.dart'; +import 'package:starter_architecture_flutter_firebase/src/home/tab_item.dart'; +import 'package:starter_architecture_flutter_firebase/src/routing/cupertino_tab_view_router.dart'; @immutable class CupertinoHomeScaffold extends StatelessWidget { diff --git a/lib/app/home/home_page.dart b/lib/src/features/home/home_page.dart similarity index 76% rename from lib/app/home/home_page.dart rename to lib/src/features/home/home_page.dart index ce87865c..8ec33037 100644 --- a/lib/app/home/home_page.dart +++ b/lib/src/features/home/home_page.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/account/account_page.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/cupertino_home_scaffold.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/entries/entries_page.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/jobs/jobs_page.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/tab_item.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/account/account_page.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/entries/entries_page.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/jobs_page.dart'; +import 'package:starter_architecture_flutter_firebase/src/home/cupertino_home_scaffold.dart'; +import 'package:starter_architecture_flutter_firebase/src/home/tab_item.dart'; class HomePage extends StatefulWidget { @override diff --git a/lib/app/home/models/entry.dart b/lib/src/features/home/models/entry.dart similarity index 100% rename from lib/app/home/models/entry.dart rename to lib/src/features/home/models/entry.dart diff --git a/lib/app/home/models/job.dart b/lib/src/features/home/models/job.dart similarity index 100% rename from lib/app/home/models/job.dart rename to lib/src/features/home/models/job.dart diff --git a/lib/app/home/tab_item.dart b/lib/src/features/home/tab_item.dart similarity index 80% rename from lib/app/home/tab_item.dart rename to lib/src/features/home/tab_item.dart index 722410ad..256dfd1f 100644 --- a/lib/app/home/tab_item.dart +++ b/lib/src/features/home/tab_item.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:starter_architecture_flutter_firebase/constants/keys.dart'; -import 'package:starter_architecture_flutter_firebase/constants/strings.dart'; +import 'package:starter_architecture_flutter_firebase/src/constants/keys.dart'; +import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; enum TabItem { jobs, entries, account } diff --git a/lib/app/home/job_entries/entry_list_item.dart b/lib/src/features/job_entries/entry_list_item.dart similarity index 93% rename from lib/app/home/job_entries/entry_list_item.dart rename to lib/src/features/job_entries/entry_list_item.dart index a508a682..fb5e96fc 100644 --- a/lib/app/home/job_entries/entry_list_item.dart +++ b/lib/src/features/job_entries/entry_list_item.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/job_entries/format.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/job_entries/format.dart'; +import 'package:starter_architecture_flutter_firebase/src/home/models/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/home/models/job.dart'; class EntryListItem extends StatelessWidget { const EntryListItem({ diff --git a/lib/app/home/job_entries/entry_page.dart b/lib/src/features/job_entries/entry_page.dart similarity index 89% rename from lib/app/home/job_entries/entry_page.dart rename to lib/src/features/job_entries/entry_page.dart index 6f5b9cae..c705f502 100644 --- a/lib/app/home/job_entries/entry_page.dart +++ b/lib/src/features/job_entries/entry_page.dart @@ -1,13 +1,13 @@ import 'package:alert_dialogs/alert_dialogs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/job_entries/format.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/models/job.dart'; -import 'package:starter_architecture_flutter_firebase/app/top_level_providers.dart'; -import 'package:starter_architecture_flutter_firebase/common_widgets/date_time_picker.dart'; -import 'package:starter_architecture_flutter_firebase/routing/app_router.dart'; -import 'package:starter_architecture_flutter_firebase/services/firestore_database.dart'; +import 'package:starter_architecture_flutter_firebase/src/common_widgets/date_time_picker.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/job_entries/format.dart'; +import 'package:starter_architecture_flutter_firebase/src/home/models/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; +import 'package:starter_architecture_flutter_firebase/src/services/firestore_database.dart'; +import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; class EntryPage extends ConsumerStatefulWidget { const EntryPage({required this.job, this.entry}); diff --git a/lib/app/home/job_entries/format.dart b/lib/src/features/job_entries/format.dart similarity index 100% rename from lib/app/home/job_entries/format.dart rename to lib/src/features/job_entries/format.dart diff --git a/lib/app/home/job_entries/job_entries_page.dart b/lib/src/features/job_entries/job_entries_page.dart similarity index 82% rename from lib/app/home/job_entries/job_entries_page.dart rename to lib/src/features/job_entries/job_entries_page.dart index c6b6f107..733a8c09 100644 --- a/lib/app/home/job_entries/job_entries_page.dart +++ b/lib/src/features/job_entries/job_entries_page.dart @@ -3,14 +3,14 @@ import 'dart:async'; import 'package:alert_dialogs/alert_dialogs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/job_entries/entry_list_item.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/job_entries/entry_page.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/jobs/edit_job_page.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/jobs/list_items_builder.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/models/job.dart'; -import 'package:starter_architecture_flutter_firebase/app/top_level_providers.dart'; -import 'package:starter_architecture_flutter_firebase/routing/cupertino_tab_view_router.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/job_entries/entry_list_item.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/job_entries/entry_page.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/edit_job_page.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/list_items_builder.dart'; +import 'package:starter_architecture_flutter_firebase/src/home/models/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/routing/cupertino_tab_view_router.dart'; +import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; class JobEntriesPage extends StatelessWidget { const JobEntriesPage({required this.job}); diff --git a/lib/app/home/jobs/edit_job_page.dart b/lib/src/features/jobs/edit_job_page.dart similarity index 92% rename from lib/app/home/jobs/edit_job_page.dart rename to lib/src/features/jobs/edit_job_page.dart index 6e744cca..a842a839 100644 --- a/lib/app/home/jobs/edit_job_page.dart +++ b/lib/src/features/jobs/edit_job_page.dart @@ -1,10 +1,10 @@ import 'package:alert_dialogs/alert_dialogs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/models/job.dart'; -import 'package:starter_architecture_flutter_firebase/app/top_level_providers.dart'; -import 'package:starter_architecture_flutter_firebase/routing/app_router.dart'; -import 'package:starter_architecture_flutter_firebase/services/firestore_database.dart'; +import 'package:starter_architecture_flutter_firebase/src/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; +import 'package:starter_architecture_flutter_firebase/src/services/firestore_database.dart'; +import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; class EditJobPage extends ConsumerStatefulWidget { const EditJobPage({Key? key, this.job}) : super(key: key); diff --git a/lib/app/home/jobs/empty_content.dart b/lib/src/features/jobs/empty_content.dart similarity index 100% rename from lib/app/home/jobs/empty_content.dart rename to lib/src/features/jobs/empty_content.dart diff --git a/lib/app/home/jobs/job_list_tile.dart b/lib/src/features/jobs/job_list_tile.dart similarity index 86% rename from lib/app/home/jobs/job_list_tile.dart rename to lib/src/features/jobs/job_list_tile.dart index 577641e3..e347abbc 100644 --- a/lib/app/home/jobs/job_list_tile.dart +++ b/lib/src/features/jobs/job_list_tile.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/home/models/job.dart'; class JobListTile extends StatelessWidget { const JobListTile({Key? key, required this.job, this.onTap}) diff --git a/lib/app/home/jobs/jobs_page.dart b/lib/src/features/jobs/jobs_page.dart similarity index 74% rename from lib/app/home/jobs/jobs_page.dart rename to lib/src/features/jobs/jobs_page.dart index 178dcae8..e039f750 100644 --- a/lib/app/home/jobs/jobs_page.dart +++ b/lib/src/features/jobs/jobs_page.dart @@ -1,13 +1,13 @@ import 'package:alert_dialogs/alert_dialogs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/job_entries/job_entries_page.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/jobs/edit_job_page.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/jobs/job_list_tile.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/jobs/list_items_builder.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/models/job.dart'; -import 'package:starter_architecture_flutter_firebase/app/top_level_providers.dart'; -import 'package:starter_architecture_flutter_firebase/constants/strings.dart'; +import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/job_entries/job_entries_page.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/edit_job_page.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/job_list_tile.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/list_items_builder.dart'; +import 'package:starter_architecture_flutter_firebase/src/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; final jobsStreamProvider = StreamProvider.autoDispose>((ref) { final database = ref.watch(databaseProvider); diff --git a/lib/app/home/jobs/list_items_builder.dart b/lib/src/features/jobs/list_items_builder.dart similarity index 92% rename from lib/app/home/jobs/list_items_builder.dart rename to lib/src/features/jobs/list_items_builder.dart index f7182d2b..43aaa0cf 100644 --- a/lib/app/home/jobs/list_items_builder.dart +++ b/lib/src/features/jobs/list_items_builder.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/jobs/empty_content.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/empty_content.dart'; typedef ItemWidgetBuilder = Widget Function(BuildContext context, T item); diff --git a/lib/app/onboarding/onboarding_page.dart b/lib/src/features/onboarding/onboarding_page.dart similarity index 95% rename from lib/app/onboarding/onboarding_page.dart rename to lib/src/features/onboarding/onboarding_page.dart index 45fbbea9..2c4b5bdf 100644 --- a/lib/app/onboarding/onboarding_page.dart +++ b/lib/src/features/onboarding/onboarding_page.dart @@ -2,7 +2,7 @@ import 'package:custom_buttons/custom_buttons.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:starter_architecture_flutter_firebase/app/onboarding/onboarding_view_model.dart'; +import 'package:starter_architecture_flutter_firebase/src/onboarding/onboarding_view_model.dart'; class OnboardingPage extends ConsumerWidget { @override diff --git a/lib/app/onboarding/onboarding_view_model.dart b/lib/src/features/onboarding/onboarding_view_model.dart similarity index 82% rename from lib/app/onboarding/onboarding_view_model.dart rename to lib/src/features/onboarding/onboarding_view_model.dart index 6192fdee..4779442f 100644 --- a/lib/app/onboarding/onboarding_view_model.dart +++ b/lib/src/features/onboarding/onboarding_view_model.dart @@ -1,6 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/services/shared_preferences_service.dart'; -import 'package:state_notifier/state_notifier.dart'; +import 'package:starter_architecture_flutter_firebase/src/services/shared_preferences_service.dart'; final onboardingViewModelProvider = StateNotifierProvider((ref) { diff --git a/lib/app/sign_in/sign_in_button.dart b/lib/src/features/sign_in/sign_in_button.dart similarity index 100% rename from lib/app/sign_in/sign_in_button.dart rename to lib/src/features/sign_in/sign_in_button.dart diff --git a/lib/app/sign_in/sign_in_page.dart b/lib/src/features/sign_in/sign_in_page.dart similarity index 89% rename from lib/app/sign_in/sign_in_page.dart rename to lib/src/features/sign_in/sign_in_page.dart index c6baae8e..a921f73e 100644 --- a/lib/app/sign_in/sign_in_page.dart +++ b/lib/src/features/sign_in/sign_in_page.dart @@ -3,12 +3,12 @@ import 'dart:math'; import 'package:alert_dialogs/alert_dialogs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/app/sign_in/sign_in_button.dart'; -import 'package:starter_architecture_flutter_firebase/app/sign_in/sign_in_view_model.dart'; -import 'package:starter_architecture_flutter_firebase/app/top_level_providers.dart'; -import 'package:starter_architecture_flutter_firebase/constants/keys.dart'; -import 'package:starter_architecture_flutter_firebase/constants/strings.dart'; -import 'package:starter_architecture_flutter_firebase/routing/app_router.dart'; +import 'package:starter_architecture_flutter_firebase/src/constants/keys.dart'; +import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; +import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; +import 'package:starter_architecture_flutter_firebase/src/sign_in/sign_in_button.dart'; +import 'package:starter_architecture_flutter_firebase/src/sign_in/sign_in_view_model.dart'; +import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; final signInModelProvider = ChangeNotifierProvider( (ref) => SignInViewModel(auth: ref.watch(firebaseAuthProvider)), diff --git a/lib/app/sign_in/sign_in_view_model.dart b/lib/src/features/sign_in/sign_in_view_model.dart similarity index 100% rename from lib/app/sign_in/sign_in_view_model.dart rename to lib/src/features/sign_in/sign_in_view_model.dart diff --git a/lib/app/localization/string_hardcoded.dart b/lib/src/localization/string_hardcoded.dart similarity index 100% rename from lib/app/localization/string_hardcoded.dart rename to lib/src/localization/string_hardcoded.dart diff --git a/lib/app/repositories/app_user.dart b/lib/src/repositories/app_user.dart similarity index 100% rename from lib/app/repositories/app_user.dart rename to lib/src/repositories/app_user.dart diff --git a/lib/app/repositories/fake_app_user.dart b/lib/src/repositories/fake_app_user.dart similarity index 79% rename from lib/app/repositories/fake_app_user.dart rename to lib/src/repositories/fake_app_user.dart index d4f3e0e9..ca1a5362 100644 --- a/lib/app/repositories/fake_app_user.dart +++ b/lib/src/repositories/fake_app_user.dart @@ -1,4 +1,4 @@ -import 'package:starter_architecture_flutter_firebase/app/repositories/app_user.dart'; +import 'package:starter_architecture_flutter_firebase/src/repositories/app_user.dart'; /// Fake user class used to simulate a user account on the backend class FakeAppUser extends AppUser { diff --git a/lib/app/repositories/fake_auth_repository.dart b/lib/src/repositories/fake_auth_repository.dart similarity index 100% rename from lib/app/repositories/fake_auth_repository.dart rename to lib/src/repositories/fake_auth_repository.dart diff --git a/lib/routing/app_router.dart b/lib/src/routing/app_router.dart similarity index 72% rename from lib/routing/app_router.dart rename to lib/src/routing/app_router.dart index 4c8f1e5c..f2c2df76 100644 --- a/lib/routing/app_router.dart +++ b/lib/src/routing/app_router.dart @@ -1,11 +1,11 @@ import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; -import 'package:starter_architecture_flutter_firebase/app/sign_in/email_password/email_password_sign_in_form_type.dart'; -import 'package:starter_architecture_flutter_firebase/app/sign_in/email_password/email_password_sign_in_screen.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/job_entries/entry_page.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/jobs/edit_job_page.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/email_password/email_password_sign_in_form_type.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/email_password/email_password_sign_in_screen.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/job_entries/entry_page.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/edit_job_page.dart'; +import 'package:starter_architecture_flutter_firebase/src/home/models/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/home/models/job.dart'; class AppRoutes { static const emailPasswordSignInPage = '/email-password-sign-in-page'; diff --git a/lib/routing/cupertino_tab_view_router.dart b/lib/src/routing/cupertino_tab_view_router.dart similarity index 71% rename from lib/routing/cupertino_tab_view_router.dart rename to lib/src/routing/cupertino_tab_view_router.dart index c87fc644..e4a165f5 100644 --- a/lib/routing/cupertino_tab_view_router.dart +++ b/lib/src/routing/cupertino_tab_view_router.dart @@ -1,11 +1,12 @@ import 'package:flutter/cupertino.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/job_entries/job_entries_page.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/job_entries/job_entries_page.dart'; +import 'package:starter_architecture_flutter_firebase/src/home/models/job.dart'; class CupertinoTabViewRoutes { static const jobEntriesPage = '/job-entries-page'; } +// ignore:avoid_classes_with_only_static_members class CupertinoTabViewRouter { static Route? generateRoute(RouteSettings settings) { switch (settings.name) { diff --git a/lib/services/firestore_database.dart b/lib/src/services/firestore_database.dart similarity index 89% rename from lib/services/firestore_database.dart rename to lib/src/services/firestore_database.dart index 487ed267..4b835d7f 100644 --- a/lib/services/firestore_database.dart +++ b/lib/src/services/firestore_database.dart @@ -1,9 +1,9 @@ import 'dart:async'; import 'package:firestore_service/firestore_service.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/models/job.dart'; -import 'package:starter_architecture_flutter_firebase/services/firestore_path.dart'; +import 'package:starter_architecture_flutter_firebase/src/home/models/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/services/firestore_path.dart'; String documentIdFromCurrentDate() => DateTime.now().toIso8601String(); diff --git a/lib/services/firestore_path.dart b/lib/src/services/firestore_path.dart similarity index 100% rename from lib/services/firestore_path.dart rename to lib/src/services/firestore_path.dart diff --git a/lib/services/shared_preferences_service.dart b/lib/src/services/shared_preferences_service.dart similarity index 100% rename from lib/services/shared_preferences_service.dart rename to lib/src/services/shared_preferences_service.dart diff --git a/lib/app/top_level_providers.dart b/lib/src/top_level_providers.dart similarity index 89% rename from lib/app/top_level_providers.dart rename to lib/src/top_level_providers.dart index 025134c3..5c589314 100644 --- a/lib/app/top_level_providers.dart +++ b/lib/src/top_level_providers.dart @@ -1,7 +1,7 @@ import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logger/logger.dart'; -import 'package:starter_architecture_flutter_firebase/services/firestore_database.dart'; +import 'package:starter_architecture_flutter_firebase/src/services/firestore_database.dart'; final firebaseAuthProvider = Provider((ref) => FirebaseAuth.instance); diff --git a/lib/app/utils/async_value_ui.dart b/lib/src/utils/async_value_ui.dart similarity index 88% rename from lib/app/utils/async_value_ui.dart rename to lib/src/utils/async_value_ui.dart index 52ee06dc..745e4345 100644 --- a/lib/app/utils/async_value_ui.dart +++ b/lib/src/utils/async_value_ui.dart @@ -1,7 +1,7 @@ import 'package:alert_dialogs/alert_dialogs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/app/localization/string_hardcoded.dart'; +import 'package:starter_architecture_flutter_firebase/src/localization/string_hardcoded.dart'; extension AsyncValueUI on AsyncValue { void showAlertDialogOnError(BuildContext context) { diff --git a/test/job_test.dart b/test/job_test.dart index 0c1d3363..a85dca9e 100644 --- a/test/job_test.dart +++ b/test/job_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:starter_architecture_flutter_firebase/app/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/home/models/job.dart'; void main() { group('fromMap', () { From 4318fe313d209ae9b24704c62c55329ac82cc6e0 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Sat, 29 Oct 2022 20:35:42 +0100 Subject: [PATCH 05/45] Fix import --- lib/src/routing/cupertino_tab_view_router.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/routing/cupertino_tab_view_router.dart b/lib/src/routing/cupertino_tab_view_router.dart index e4a165f5..bb293341 100644 --- a/lib/src/routing/cupertino_tab_view_router.dart +++ b/lib/src/routing/cupertino_tab_view_router.dart @@ -1,6 +1,6 @@ import 'package:flutter/cupertino.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; import 'package:starter_architecture_flutter_firebase/src/features/job_entries/job_entries_page.dart'; -import 'package:starter_architecture_flutter_firebase/src/home/models/job.dart'; class CupertinoTabViewRoutes { static const jobEntriesPage = '/job-entries-page'; From bc651695aa816ada7b490198477d7ecb55a8171e Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Mon, 31 Oct 2022 08:56:30 +0000 Subject: [PATCH 06/45] Update folders --- lib/main.dart | 6 +++--- .../email_password/email_password_sign_in_controller.dart | 2 +- .../email_password/email_password_sign_in_screen.dart | 8 ++++---- .../email_password/email_password_sign_in_validators.dart | 4 ++-- lib/src/features/entries/entries_view_model.dart | 4 ++-- lib/src/features/entries/entry_job.dart | 4 ++-- lib/src/features/home/cupertino_home_scaffold.dart | 2 +- lib/src/features/home/home_page.dart | 4 ++-- lib/src/features/job_entries/entry_list_item.dart | 4 ++-- lib/src/features/job_entries/entry_page.dart | 4 ++-- lib/src/features/job_entries/job_entries_page.dart | 4 ++-- lib/src/features/jobs/edit_job_page.dart | 2 +- lib/src/features/jobs/job_list_tile.dart | 2 +- lib/src/features/jobs/jobs_page.dart | 2 +- lib/src/features/onboarding/onboarding_page.dart | 2 +- lib/src/features/sign_in/sign_in_page.dart | 4 ++-- lib/src/routing/app_router.dart | 4 ++-- lib/src/services/firestore_database.dart | 4 ++-- test/job_test.dart | 2 +- 19 files changed, 34 insertions(+), 34 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 2f610724..2731001c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,10 +5,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:starter_architecture_flutter_firebase/firebase_options.dart'; import 'package:starter_architecture_flutter_firebase/src/auth_widget.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/home_page.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/onboarding/onboarding_page.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/onboarding/onboarding_view_model.dart'; import 'package:starter_architecture_flutter_firebase/src/features/sign_in/sign_in_page.dart'; -import 'package:starter_architecture_flutter_firebase/src/home/home_page.dart'; -import 'package:starter_architecture_flutter_firebase/src/onboarding/onboarding_page.dart'; -import 'package:starter_architecture_flutter_firebase/src/onboarding/onboarding_view_model.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; import 'package:starter_architecture_flutter_firebase/src/services/shared_preferences_service.dart'; import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; diff --git a/lib/src/features/email_password/email_password_sign_in_controller.dart b/lib/src/features/email_password/email_password_sign_in_controller.dart index 5719ad72..a30a022a 100644 --- a/lib/src/features/email_password/email_password_sign_in_controller.dart +++ b/lib/src/features/email_password/email_password_sign_in_controller.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/repositories/fake_auth_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/sign_in/email_password/email_password_sign_in_form_type.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/email_password/email_password_sign_in_form_type.dart'; class EmailPasswordSignInController extends AutoDisposeAsyncNotifier { @override diff --git a/lib/src/features/email_password/email_password_sign_in_screen.dart b/lib/src/features/email_password/email_password_sign_in_screen.dart index 6239c86e..fe26245b 100644 --- a/lib/src/features/email_password/email_password_sign_in_screen.dart +++ b/lib/src/features/email_password/email_password_sign_in_screen.dart @@ -6,10 +6,10 @@ import 'package:starter_architecture_flutter_firebase/src/common_widgets/primary import 'package:starter_architecture_flutter_firebase/src/common_widgets/responsive_scrollable_card.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/app_sizes.dart'; import 'package:starter_architecture_flutter_firebase/src/localization/string_hardcoded.dart'; -import 'package:starter_architecture_flutter_firebase/src/sign_in/email_password/email_password_sign_in_controller.dart'; -import 'package:starter_architecture_flutter_firebase/src/sign_in/email_password/email_password_sign_in_form_type.dart'; -import 'package:starter_architecture_flutter_firebase/src/sign_in/email_password/email_password_sign_in_validators.dart'; -import 'package:starter_architecture_flutter_firebase/src/sign_in/email_password/string_validators.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/email_password/email_password_sign_in_controller.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/email_password/email_password_sign_in_form_type.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/email_password/email_password_sign_in_validators.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/email_password/string_validators.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/async_value_ui.dart'; /// Email & password sign in screen. diff --git a/lib/src/features/email_password/email_password_sign_in_validators.dart b/lib/src/features/email_password/email_password_sign_in_validators.dart index 260561d0..34332fb3 100644 --- a/lib/src/features/email_password/email_password_sign_in_validators.dart +++ b/lib/src/features/email_password/email_password_sign_in_validators.dart @@ -1,6 +1,6 @@ import 'package:starter_architecture_flutter_firebase/src/localization/string_hardcoded.dart'; -import 'package:starter_architecture_flutter_firebase/src/sign_in/email_password/email_password_sign_in_form_type.dart'; -import 'package:starter_architecture_flutter_firebase/src/sign_in/email_password/string_validators.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/email_password/email_password_sign_in_form_type.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/email_password/string_validators.dart'; /// Mixin class to be used for client-side email & password validation mixin EmailAndPasswordValidators { diff --git a/lib/src/features/entries/entries_view_model.dart b/lib/src/features/entries/entries_view_model.dart index 1926a298..ec90d545 100644 --- a/lib/src/features/entries/entries_view_model.dart +++ b/lib/src/features/entries/entries_view_model.dart @@ -3,8 +3,8 @@ import 'package:starter_architecture_flutter_firebase/src/features/entries/daily import 'package:starter_architecture_flutter_firebase/src/features/entries/entries_list_tile.dart'; import 'package:starter_architecture_flutter_firebase/src/features/entries/entry_job.dart'; import 'package:starter_architecture_flutter_firebase/src/features/job_entries/format.dart'; -import 'package:starter_architecture_flutter_firebase/src/home/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/src/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; import 'package:starter_architecture_flutter_firebase/src/services/firestore_database.dart'; class EntriesViewModel { diff --git a/lib/src/features/entries/entry_job.dart b/lib/src/features/entries/entry_job.dart index d51e3b9f..1ca4c81e 100644 --- a/lib/src/features/entries/entry_job.dart +++ b/lib/src/features/entries/entry_job.dart @@ -1,5 +1,5 @@ -import 'package:starter_architecture_flutter_firebase/src/home/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/src/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; class EntryJob { EntryJob(this.entry, this.job); diff --git a/lib/src/features/home/cupertino_home_scaffold.dart b/lib/src/features/home/cupertino_home_scaffold.dart index b81d2602..a01097b7 100644 --- a/lib/src/features/home/cupertino_home_scaffold.dart +++ b/lib/src/features/home/cupertino_home_scaffold.dart @@ -1,7 +1,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/keys.dart'; -import 'package:starter_architecture_flutter_firebase/src/home/tab_item.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/tab_item.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/cupertino_tab_view_router.dart'; @immutable diff --git a/lib/src/features/home/home_page.dart b/lib/src/features/home/home_page.dart index 8ec33037..1e81472e 100644 --- a/lib/src/features/home/home_page.dart +++ b/lib/src/features/home/home_page.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:starter_architecture_flutter_firebase/src/features/account/account_page.dart'; import 'package:starter_architecture_flutter_firebase/src/features/entries/entries_page.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/cupertino_home_scaffold.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/tab_item.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/jobs_page.dart'; -import 'package:starter_architecture_flutter_firebase/src/home/cupertino_home_scaffold.dart'; -import 'package:starter_architecture_flutter_firebase/src/home/tab_item.dart'; class HomePage extends StatefulWidget { @override diff --git a/lib/src/features/job_entries/entry_list_item.dart b/lib/src/features/job_entries/entry_list_item.dart index fb5e96fc..bbf1b12a 100644 --- a/lib/src/features/job_entries/entry_list_item.dart +++ b/lib/src/features/job_entries/entry_list_item.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:starter_architecture_flutter_firebase/src/features/job_entries/format.dart'; -import 'package:starter_architecture_flutter_firebase/src/home/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/src/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; class EntryListItem extends StatelessWidget { const EntryListItem({ diff --git a/lib/src/features/job_entries/entry_page.dart b/lib/src/features/job_entries/entry_page.dart index c705f502..054a9c71 100644 --- a/lib/src/features/job_entries/entry_page.dart +++ b/lib/src/features/job_entries/entry_page.dart @@ -3,8 +3,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/common_widgets/date_time_picker.dart'; import 'package:starter_architecture_flutter_firebase/src/features/job_entries/format.dart'; -import 'package:starter_architecture_flutter_firebase/src/home/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/src/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; import 'package:starter_architecture_flutter_firebase/src/services/firestore_database.dart'; import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; diff --git a/lib/src/features/job_entries/job_entries_page.dart b/lib/src/features/job_entries/job_entries_page.dart index 733a8c09..6ee8eefc 100644 --- a/lib/src/features/job_entries/job_entries_page.dart +++ b/lib/src/features/job_entries/job_entries_page.dart @@ -7,8 +7,8 @@ import 'package:starter_architecture_flutter_firebase/src/features/job_entries/e import 'package:starter_architecture_flutter_firebase/src/features/job_entries/entry_page.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/edit_job_page.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/list_items_builder.dart'; -import 'package:starter_architecture_flutter_firebase/src/home/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/src/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/cupertino_tab_view_router.dart'; import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; diff --git a/lib/src/features/jobs/edit_job_page.dart b/lib/src/features/jobs/edit_job_page.dart index a842a839..c2b404dd 100644 --- a/lib/src/features/jobs/edit_job_page.dart +++ b/lib/src/features/jobs/edit_job_page.dart @@ -1,7 +1,7 @@ import 'package:alert_dialogs/alert_dialogs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/src/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; import 'package:starter_architecture_flutter_firebase/src/services/firestore_database.dart'; import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; diff --git a/lib/src/features/jobs/job_list_tile.dart b/lib/src/features/jobs/job_list_tile.dart index e347abbc..017b00cb 100644 --- a/lib/src/features/jobs/job_list_tile.dart +++ b/lib/src/features/jobs/job_list_tile.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:starter_architecture_flutter_firebase/src/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; class JobListTile extends StatelessWidget { const JobListTile({Key? key, required this.job, this.onTap}) diff --git a/lib/src/features/jobs/jobs_page.dart b/lib/src/features/jobs/jobs_page.dart index e039f750..e8120bcc 100644 --- a/lib/src/features/jobs/jobs_page.dart +++ b/lib/src/features/jobs/jobs_page.dart @@ -6,7 +6,7 @@ import 'package:starter_architecture_flutter_firebase/src/features/job_entries/j import 'package:starter_architecture_flutter_firebase/src/features/jobs/edit_job_page.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/job_list_tile.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/list_items_builder.dart'; -import 'package:starter_architecture_flutter_firebase/src/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; final jobsStreamProvider = StreamProvider.autoDispose>((ref) { diff --git a/lib/src/features/onboarding/onboarding_page.dart b/lib/src/features/onboarding/onboarding_page.dart index 2c4b5bdf..793145d3 100644 --- a/lib/src/features/onboarding/onboarding_page.dart +++ b/lib/src/features/onboarding/onboarding_page.dart @@ -2,7 +2,7 @@ import 'package:custom_buttons/custom_buttons.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:starter_architecture_flutter_firebase/src/onboarding/onboarding_view_model.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/onboarding/onboarding_view_model.dart'; class OnboardingPage extends ConsumerWidget { @override diff --git a/lib/src/features/sign_in/sign_in_page.dart b/lib/src/features/sign_in/sign_in_page.dart index a921f73e..e122e8c5 100644 --- a/lib/src/features/sign_in/sign_in_page.dart +++ b/lib/src/features/sign_in/sign_in_page.dart @@ -6,8 +6,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/keys.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; -import 'package:starter_architecture_flutter_firebase/src/sign_in/sign_in_button.dart'; -import 'package:starter_architecture_flutter_firebase/src/sign_in/sign_in_view_model.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/sign_in/sign_in_button.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/sign_in/sign_in_view_model.dart'; import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; final signInModelProvider = ChangeNotifierProvider( diff --git a/lib/src/routing/app_router.dart b/lib/src/routing/app_router.dart index f2c2df76..8c6b140d 100644 --- a/lib/src/routing/app_router.dart +++ b/lib/src/routing/app_router.dart @@ -2,10 +2,10 @@ import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; import 'package:starter_architecture_flutter_firebase/src/features/email_password/email_password_sign_in_form_type.dart'; import 'package:starter_architecture_flutter_firebase/src/features/email_password/email_password_sign_in_screen.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; import 'package:starter_architecture_flutter_firebase/src/features/job_entries/entry_page.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/edit_job_page.dart'; -import 'package:starter_architecture_flutter_firebase/src/home/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/src/home/models/job.dart'; class AppRoutes { static const emailPasswordSignInPage = '/email-password-sign-in-page'; diff --git a/lib/src/services/firestore_database.dart b/lib/src/services/firestore_database.dart index 4b835d7f..28a46259 100644 --- a/lib/src/services/firestore_database.dart +++ b/lib/src/services/firestore_database.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'package:firestore_service/firestore_service.dart'; -import 'package:starter_architecture_flutter_firebase/src/home/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/src/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; import 'package:starter_architecture_flutter_firebase/src/services/firestore_path.dart'; String documentIdFromCurrentDate() => DateTime.now().toIso8601String(); diff --git a/test/job_test.dart b/test/job_test.dart index a85dca9e..5414e257 100644 --- a/test/job_test.dart +++ b/test/job_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:starter_architecture_flutter_firebase/src/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; void main() { group('fromMap', () { From 3c19b7a4932062be9eaee3b1667dd5425becb071 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Mon, 31 Oct 2022 09:07:02 +0000 Subject: [PATCH 07/45] Add pubspec.lock and Podfile.lock to git --- .gitignore | 2 +- ios/Podfile.lock | 137 ++++++++ pubspec.lock | 810 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 948 insertions(+), 1 deletion(-) create mode 100644 ios/Podfile.lock create mode 100644 pubspec.lock diff --git a/.gitignore b/.gitignore index b2e058a0..ae6f670b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # Miscellaneous *.class -*.lock +#*.lock *.log *.pyc *.swp diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 00000000..60b8d265 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,137 @@ +PODS: + - cloud_firestore (2.5.4): + - Firebase/Firestore (= 9.6.0) + - firebase_core + - Flutter + - Firebase/Auth (9.6.0): + - Firebase/CoreOnly + - FirebaseAuth (~> 9.6.0) + - Firebase/CoreOnly (9.6.0): + - FirebaseCore (= 9.6.0) + - Firebase/Firestore (9.6.0): + - Firebase/CoreOnly + - FirebaseFirestore (~> 9.6.0) + - firebase_auth (3.11.2): + - Firebase/Auth (= 9.6.0) + - firebase_core + - Flutter + - firebase_core (1.24.0): + - Firebase/CoreOnly (= 9.6.0) + - Flutter + - FirebaseAuth (9.6.0): + - FirebaseCore (~> 9.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.7) + - GoogleUtilities/Environment (~> 7.7) + - GTMSessionFetcher/Core (< 3.0, >= 1.7) + - FirebaseCore (9.6.0): + - FirebaseCoreDiagnostics (~> 9.0) + - FirebaseCoreInternal (~> 9.0) + - GoogleUtilities/Environment (~> 7.7) + - GoogleUtilities/Logger (~> 7.7) + - FirebaseCoreDiagnostics (9.6.0): + - GoogleDataTransport (< 10.0.0, >= 9.1.4) + - GoogleUtilities/Environment (~> 7.7) + - GoogleUtilities/Logger (~> 7.7) + - nanopb (< 2.30910.0, >= 2.30908.0) + - FirebaseCoreInternal (9.6.0): + - "GoogleUtilities/NSData+zlib (~> 7.7)" + - FirebaseFirestore (9.6.0): + - FirebaseFirestore/AutodetectLeveldb (= 9.6.0) + - FirebaseFirestore/AutodetectLeveldb (9.6.0): + - FirebaseFirestore/Base + - FirebaseFirestore/WithLeveldb + - FirebaseFirestore/Base (9.6.0) + - FirebaseFirestore/WithLeveldb (9.6.0): + - FirebaseFirestore/Base + - Flutter (1.0.0) + - GoogleDataTransport (9.2.0): + - GoogleUtilities/Environment (~> 7.7) + - nanopb (< 2.30910.0, >= 2.30908.0) + - PromisesObjC (< 3.0, >= 1.2) + - GoogleUtilities/AppDelegateSwizzler (7.8.0): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Environment (7.8.0): + - PromisesObjC (< 3.0, >= 1.2) + - GoogleUtilities/Logger (7.8.0): + - GoogleUtilities/Environment + - GoogleUtilities/Network (7.8.0): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (7.8.0)" + - GoogleUtilities/Reachability (7.8.0): + - GoogleUtilities/Logger + - GTMSessionFetcher/Core (2.1.0) + - nanopb (2.30909.0): + - nanopb/decode (= 2.30909.0) + - nanopb/encode (= 2.30909.0) + - nanopb/decode (2.30909.0) + - nanopb/encode (2.30909.0) + - PromisesObjC (2.1.1) + - shared_preferences_ios (0.0.1): + - Flutter + +DEPENDENCIES: + - cloud_firestore (from `.symlinks/plugins/cloud_firestore/ios`) + - firebase_auth (from `.symlinks/plugins/firebase_auth/ios`) + - firebase_core (from `.symlinks/plugins/firebase_core/ios`) + - FirebaseFirestore (from `https://github.com/invertase/firestore-ios-sdk-frameworks.git`, tag `9.6.0`) + - Flutter (from `Flutter`) + - shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`) + +SPEC REPOS: + trunk: + - Firebase + - FirebaseAuth + - FirebaseCore + - FirebaseCoreDiagnostics + - FirebaseCoreInternal + - GoogleDataTransport + - GoogleUtilities + - GTMSessionFetcher + - nanopb + - PromisesObjC + +EXTERNAL SOURCES: + cloud_firestore: + :path: ".symlinks/plugins/cloud_firestore/ios" + firebase_auth: + :path: ".symlinks/plugins/firebase_auth/ios" + firebase_core: + :path: ".symlinks/plugins/firebase_core/ios" + FirebaseFirestore: + :git: https://github.com/invertase/firestore-ios-sdk-frameworks.git + :tag: 9.6.0 + Flutter: + :path: Flutter + shared_preferences_ios: + :path: ".symlinks/plugins/shared_preferences_ios/ios" + +CHECKOUT OPTIONS: + FirebaseFirestore: + :git: https://github.com/invertase/firestore-ios-sdk-frameworks.git + :tag: 9.6.0 + +SPEC CHECKSUMS: + cloud_firestore: bc2bc8456db5f8067349de0cbd9fb36b0747c258 + Firebase: 5ae8b7cf8efce559a653aef0ad95bab3f427c351 + firebase_auth: 07a4db69cfa447ac42cb7faa560fc100708b707c + firebase_core: 7c28ecc1e5dd74e03829ac3e9ff5ba3314e737a9 + FirebaseAuth: e4a5d3c36e778e41141b91cc861103a441d80bcc + FirebaseCore: 2082fffcd855f95f883c0a1641133eb9bbe76d40 + FirebaseCoreDiagnostics: 99a495094b10a57eeb3ae8efa1665700ad0bdaa6 + FirebaseCoreInternal: bca76517fe1ed381e989f5e7d8abb0da8d85bed3 + FirebaseFirestore: 9bfe2e814686fb3ba2495b97618b38987e4c8526 + Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 + GoogleDataTransport: 1c8145da7117bd68bbbed00cf304edb6a24de00f + GoogleUtilities: 1d20a6ad97ef46f67bbdec158ce00563a671ebb7 + GTMSessionFetcher: ffbb25ec00ebcb5201adab0a56d808f6f1902d9f + nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431 + PromisesObjC: ab77feca74fa2823e7af4249b8326368e61014cb + shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad + +PODFILE CHECKSUM: c7b786c998c1be38c733a9c60df638a81f694b95 + +COCOAPODS: 1.11.3 diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 00000000..26c45936 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,810 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + url: "https://pub.dartlang.org" + source: hosted + version: "47.0.0" + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + alert_dialogs: + dependency: "direct main" + description: + path: "packages/alert_dialogs" + relative: true + source: path + version: "0.2.0" + analyzer: + dependency: transitive + description: + name: analyzer + url: "https://pub.dartlang.org" + source: hosted + version: "4.7.0" + args: + dependency: transitive + description: + name: args + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.1" + async: + dependency: transitive + description: + name: async + url: "https://pub.dartlang.org" + source: hosted + version: "2.9.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + build: + dependency: transitive + description: + name: build + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.1" + build_config: + dependency: transitive + description: + name: build_config + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + build_daemon: + dependency: transitive + description: + name: build_daemon + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.0" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.10" + build_runner: + dependency: "direct dev" + description: + name: build_runner + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.0" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + url: "https://pub.dartlang.org" + source: hosted + version: "7.2.7" + built_collection: + dependency: transitive + description: + name: built_collection + url: "https://pub.dartlang.org" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + url: "https://pub.dartlang.org" + source: hosted + version: "8.4.2" + characters: + dependency: transitive + description: + name: characters + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + clock: + dependency: transitive + description: + name: clock + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + cloud_firestore: + dependency: "direct main" + description: + name: cloud_firestore + url: "https://pub.dartlang.org" + source: hosted + version: "2.5.4" + cloud_firestore_platform_interface: + dependency: transitive + description: + name: cloud_firestore_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "5.7.7" + cloud_firestore_web: + dependency: transitive + description: + name: cloud_firestore_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.8.10" + code_builder: + dependency: transitive + description: + name: code_builder + url: "https://pub.dartlang.org" + source: hosted + version: "4.3.0" + collection: + dependency: transitive + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.16.0" + convert: + dependency: transitive + description: + name: convert + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.1" + coverage: + dependency: transitive + description: + name: coverage + url: "https://pub.dartlang.org" + source: hosted + version: "1.6.1" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.5" + custom_buttons: + dependency: "direct main" + description: + path: "packages/custom_buttons" + relative: true + source: path + version: "0.1.0" + dart_style: + dependency: transitive + description: + name: dart_style + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.4" + equatable: + dependency: "direct main" + description: + name: equatable + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.5" + fake_async: + dependency: transitive + description: + name: fake_async + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + file: + dependency: transitive + description: + name: file + url: "https://pub.dartlang.org" + source: hosted + version: "6.1.4" + firebase_auth: + dependency: "direct main" + description: + name: firebase_auth + url: "https://pub.dartlang.org" + source: hosted + version: "3.11.2" + firebase_auth_platform_interface: + dependency: transitive + description: + name: firebase_auth_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "6.10.1" + firebase_auth_web: + dependency: transitive + description: + name: firebase_auth_web + url: "https://pub.dartlang.org" + source: hosted + version: "4.6.1" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + url: "https://pub.dartlang.org" + source: hosted + version: "1.24.0" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "4.5.2" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + url: "https://pub.dartlang.org" + source: hosted + version: "1.7.3" + firestore_service: + dependency: "direct main" + description: + path: "packages/firestore_service" + relative: true + source: path + version: "0.2.1" + fixnum: + dependency: transitive + description: + name: fixnum + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.6" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.3" + glob: + dependency: transitive + description: + name: glob + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + graphs: + dependency: transitive + description: + name: graphs + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.2" + intl: + dependency: "direct main" + description: + name: intl + url: "https://pub.dartlang.org" + source: hosted + version: "0.17.0" + io: + dependency: transitive + description: + name: io + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.3" + js: + dependency: transitive + description: + name: js + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.4" + json_annotation: + dependency: transitive + description: + name: json_annotation + url: "https://pub.dartlang.org" + source: hosted + version: "4.7.0" + logger: + dependency: "direct main" + description: + name: logger + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + logging: + dependency: transitive + description: + name: logging + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + matcher: + dependency: transitive + description: + name: matcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.12" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.5" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.0" + mime: + dependency: transitive + description: + name: mime + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + mocktail: + dependency: "direct dev" + description: + name: mocktail + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + package_config: + dependency: transitive + description: + name: package_config + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + path: + dependency: transitive + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.2" + path_drawing: + dependency: transitive + description: + name: path_drawing + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.7" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.5" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.3" + petitparser: + dependency: transitive + description: + name: petitparser + url: "https://pub.dartlang.org" + source: hosted + version: "5.1.0" + platform: + dependency: transitive + description: + name: platform + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.3" + pool: + dependency: transitive + description: + name: pool + url: "https://pub.dartlang.org" + source: hosted + version: "1.5.1" + process: + dependency: transitive + description: + name: process + url: "https://pub.dartlang.org" + source: hosted + version: "4.2.4" + pub_semver: + dependency: transitive + description: + name: pub_semver + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.2" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.1" + random_string: + dependency: "direct dev" + description: + name: random_string + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.1" + riverpod: + dependency: transitive + description: + name: riverpod + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" + rxdart: + dependency: "direct main" + description: + name: rxdart + url: "https://pub.dartlang.org" + source: hosted + version: "0.27.5" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.15" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.14" + shared_preferences_ios: + dependency: transitive + description: + name: shared_preferences_ios + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + shared_preferences_macos: + dependency: transitive + description: + name: shared_preferences_macos + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.4" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.4" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + shelf: + dependency: transitive + description: + name: shelf + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.0" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + shelf_static: + dependency: transitive + description: + name: shelf_static + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + source_maps: + dependency: transitive + description: + name: source_maps + url: "https://pub.dartlang.org" + source: hosted + version: "0.10.11" + source_span: + dependency: transitive + description: + name: source_span + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.10.0" + state_notifier: + dependency: transitive + description: + name: state_notifier + url: "https://pub.dartlang.org" + source: hosted + version: "0.7.2+1" + stream_channel: + dependency: transitive + description: + name: stream_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + stream_transform: + dependency: transitive + description: + name: stream_transform + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.1" + test: + dependency: transitive + description: + name: test + url: "https://pub.dartlang.org" + source: hosted + version: "1.21.4" + test_api: + dependency: transitive + description: + name: test_api + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.12" + test_core: + dependency: transitive + description: + name: test_core + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.16" + timing: + dependency: transitive + description: + name: timing + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.1" + vector_math: + dependency: transitive + description: + name: vector_math + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.2" + vm_service: + dependency: transitive + description: + name: vm_service + url: "https://pub.dartlang.org" + source: hosted + version: "9.4.0" + watcher: + dependency: transitive + description: + name: watcher + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + win32: + dependency: transitive + description: + name: win32 + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0+2" + xml: + dependency: transitive + description: + name: xml + url: "https://pub.dartlang.org" + source: hosted + version: "6.1.0" + yaml: + dependency: transitive + description: + name: yaml + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.1" +sdks: + dart: ">=2.18.0 <3.0.0" + flutter: ">=3.0.0" From e639a0b897ef6c4d37af262d822cf1220c7e8e66 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Mon, 31 Oct 2022 09:20:19 +0000 Subject: [PATCH 08/45] Customise Podfile with minimum_deployment_target for all pod libraries https://stackoverflow.com/questions/61823044/flutter-the-ios-simulator-deployment-target-iphoneos-deployment-target-is-se --- ios/Podfile | 17 +++++++++++++++++ ios/Podfile.lock | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/ios/Podfile b/ios/Podfile index ee23e027..c3d0f275 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -36,7 +36,24 @@ target 'Runner' do end post_install do |installer| + # Ensure pods also use the minimum deployment target set above + # https://stackoverflow.com/a/64385584/436422 + puts 'Determining pod project minimum deployment target' + + pods_project = installer.pods_project + deployment_target_key = 'IPHONEOS_DEPLOYMENT_TARGET' + deployment_targets = pods_project.build_configurations.map{ |config| config.build_settings[deployment_target_key] } + minimum_deployment_target = deployment_targets.min_by{ |version| Gem::Version.new(version) } + + puts 'Minimal deployment target is ' + minimum_deployment_target + puts 'Setting each pod deployment target to ' + minimum_deployment_target + installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) + target.build_configurations.each do |config| + #config.build_settings['ENABLE_BITCODE'] = 'NO' + config.build_settings[deployment_target_key] = minimum_deployment_target + #config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '11.0' + end end end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 60b8d265..4764a7fd 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -132,6 +132,6 @@ SPEC CHECKSUMS: PromisesObjC: ab77feca74fa2823e7af4249b8326368e61014cb shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad -PODFILE CHECKSUM: c7b786c998c1be38c733a9c60df638a81f694b95 +PODFILE CHECKSUM: 1e33735680063b481bf2b25dcaf31a75039cc996 COCOAPODS: 1.11.3 From 43d4da7aef53236628ab83dbe0e2f35403e8c9c1 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Mon, 31 Oct 2022 09:23:56 +0000 Subject: [PATCH 09/45] Move FirestoreService to main app --- .../src/repositories}/firestore_service.dart | 2 - lib/src/services/firestore_database.dart | 2 +- packages/alert_dialogs/pubspec.lock | 180 ++++++++++++++++++ packages/custom_buttons/pubspec.lock | 139 ++++++++++++++ packages/firestore_service/.gitignore | 75 -------- packages/firestore_service/.metadata | 10 - packages/firestore_service/CHANGELOG.md | 12 -- packages/firestore_service/LICENSE | 7 - packages/firestore_service/README.md | 13 -- packages/firestore_service/pubspec.yaml | 53 ------ pubspec.lock | 9 +- pubspec.yaml | 6 +- 12 files changed, 325 insertions(+), 183 deletions(-) rename {packages/firestore_service/lib => lib/src/repositories}/firestore_service.dart (98%) create mode 100644 packages/alert_dialogs/pubspec.lock create mode 100644 packages/custom_buttons/pubspec.lock delete mode 100644 packages/firestore_service/.gitignore delete mode 100644 packages/firestore_service/.metadata delete mode 100644 packages/firestore_service/CHANGELOG.md delete mode 100644 packages/firestore_service/LICENSE delete mode 100644 packages/firestore_service/README.md delete mode 100644 packages/firestore_service/pubspec.yaml diff --git a/packages/firestore_service/lib/firestore_service.dart b/lib/src/repositories/firestore_service.dart similarity index 98% rename from packages/firestore_service/lib/firestore_service.dart rename to lib/src/repositories/firestore_service.dart index f7bd83c8..614ccb0b 100644 --- a/packages/firestore_service/lib/firestore_service.dart +++ b/lib/src/repositories/firestore_service.dart @@ -1,5 +1,3 @@ -library firestore_service; - import 'package:cloud_firestore/cloud_firestore.dart'; class FirestoreService { diff --git a/lib/src/services/firestore_database.dart b/lib/src/services/firestore_database.dart index 28a46259..b304c50a 100644 --- a/lib/src/services/firestore_database.dart +++ b/lib/src/services/firestore_database.dart @@ -1,8 +1,8 @@ import 'dart:async'; -import 'package:firestore_service/firestore_service.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/repositories/firestore_service.dart'; import 'package:starter_architecture_flutter_firebase/src/services/firestore_path.dart'; String documentIdFromCurrentDate() => DateTime.now().toIso8601String(); diff --git a/packages/alert_dialogs/pubspec.lock b/packages/alert_dialogs/pubspec.lock new file mode 100644 index 00000000..686d9281 --- /dev/null +++ b/packages/alert_dialogs/pubspec.lock @@ -0,0 +1,180 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + url: "https://pub.dartlang.org" + source: hosted + version: "2.9.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + characters: + dependency: transitive + description: + name: characters + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.1" + clock: + dependency: transitive + description: + name: clock + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + collection: + dependency: transitive + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.16.0" + fake_async: + dependency: transitive + description: + name: fake_async + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.1" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "4.5.2" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + js: + dependency: transitive + description: + name: js + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.4" + matcher: + dependency: transitive + description: + name: matcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.12" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.5" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.0" + path: + dependency: transitive + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.2" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.3" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_span: + dependency: transitive + description: + name: source_span + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.10.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.12" + vector_math: + dependency: transitive + description: + name: vector_math + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.2" +sdks: + dart: ">=2.17.0 <3.0.0" + flutter: ">=1.20.0" diff --git a/packages/custom_buttons/pubspec.lock b/packages/custom_buttons/pubspec.lock new file mode 100644 index 00000000..757cf34c --- /dev/null +++ b/packages/custom_buttons/pubspec.lock @@ -0,0 +1,139 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + url: "https://pub.dartlang.org" + source: hosted + version: "2.9.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + characters: + dependency: transitive + description: + name: characters + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.1" + clock: + dependency: transitive + description: + name: clock + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + collection: + dependency: transitive + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.16.0" + fake_async: + dependency: transitive + description: + name: fake_async + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + matcher: + dependency: transitive + description: + name: matcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.12" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.5" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.0" + path: + dependency: transitive + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.2" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_span: + dependency: transitive + description: + name: source_span + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.10.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.12" + vector_math: + dependency: transitive + description: + name: vector_math + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.2" +sdks: + dart: ">=2.17.0 <3.0.0" diff --git a/packages/firestore_service/.gitignore b/packages/firestore_service/.gitignore deleted file mode 100644 index bb431f0d..00000000 --- a/packages/firestore_service/.gitignore +++ /dev/null @@ -1,75 +0,0 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.buildlog/ -.history -.svn/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -.dart_tool/ -.flutter-plugins -.flutter-plugins-dependencies -.packages -.pub-cache/ -.pub/ -build/ - -# Android related -**/android/**/gradle-wrapper.jar -**/android/.gradle -**/android/captures/ -**/android/gradlew -**/android/gradlew.bat -**/android/local.properties -**/android/**/GeneratedPluginRegistrant.java - -# iOS/XCode related -**/ios/**/*.mode1v3 -**/ios/**/*.mode2v3 -**/ios/**/*.moved-aside -**/ios/**/*.pbxuser -**/ios/**/*.perspectivev3 -**/ios/**/*sync/ -**/ios/**/.sconsign.dblite -**/ios/**/.tags* -**/ios/**/.vagrant/ -**/ios/**/DerivedData/ -**/ios/**/Icon? -**/ios/**/Pods/ -**/ios/**/.symlinks/ -**/ios/**/profile -**/ios/**/xcuserdata -**/ios/.generated/ -**/ios/Flutter/App.framework -**/ios/Flutter/Flutter.framework -**/ios/Flutter/Flutter.podspec -**/ios/Flutter/Generated.xcconfig -**/ios/Flutter/app.flx -**/ios/Flutter/app.zip -**/ios/Flutter/flutter_assets/ -**/ios/Flutter/flutter_export_environment.sh -**/ios/ServiceDefinitions.json -**/ios/Runner/GeneratedPluginRegistrant.* - -# Exceptions to above rules. -!**/ios/**/default.mode1v3 -!**/ios/**/default.mode2v3 -!**/ios/**/default.pbxuser -!**/ios/**/default.perspectivev3 -!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages diff --git a/packages/firestore_service/.metadata b/packages/firestore_service/.metadata deleted file mode 100644 index 616b0463..00000000 --- a/packages/firestore_service/.metadata +++ /dev/null @@ -1,10 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: e6b34c2b5c96bb95325269a29a84e83ed8909b5f - channel: stable - -project_type: package diff --git a/packages/firestore_service/CHANGELOG.md b/packages/firestore_service/CHANGELOG.md deleted file mode 100644 index 38398ba0..00000000 --- a/packages/firestore_service/CHANGELOG.md +++ /dev/null @@ -1,12 +0,0 @@ -## [0.2.1] - -* Update to `cloud_firestore` 2.2.0 - -## [0.1.0] - -* Port to Null Safety and `cloud_firestore` 1.0.0 - -## [0.0.1] - 2020-05-16 - -* Initial release -* Add `FirestoreService` class. Supports `setData`, `deleteData`, `collectionStream`, `documentStream` methods. diff --git a/packages/firestore_service/LICENSE b/packages/firestore_service/LICENSE deleted file mode 100644 index 6dbdfbbc..00000000 --- a/packages/firestore_service/LICENSE +++ /dev/null @@ -1,7 +0,0 @@ -Copyright (c) 2020 Andrea Bizzotto [bizz84@gmail.com](mailto:bizz84@gmail.com) - -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: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -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. \ No newline at end of file diff --git a/packages/firestore_service/README.md b/packages/firestore_service/README.md deleted file mode 100644 index ae18cb8a..00000000 --- a/packages/firestore_service/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# firestore_service - -This package includes `FirestoreService`, a wrapper class for the `cloud_firestore` APIs. - -`FirestoreService` uses generics and the builder pattern to provide a type-based abstraction on top of `cloud_firestore`. - -It covers only a very limited subset of APIs from `cloud_firestore`. - -NOTE: The author will only maintain this package for his own internal projects. It will **not** be published on [pub.dev](https://pub.dev) and, while you're free to use it, it's not meant to be a community project. - -Breaking changes may be introduced at any time. - -[LICENSE: MIT](LICENSE) \ No newline at end of file diff --git a/packages/firestore_service/pubspec.yaml b/packages/firestore_service/pubspec.yaml deleted file mode 100644 index 76e4631b..00000000 --- a/packages/firestore_service/pubspec.yaml +++ /dev/null @@ -1,53 +0,0 @@ -name: firestore_service -description: Wrapper for Cloud Firestore -version: 0.2.1 -author: Andrea Bizzotto -homepage: https://www.codewithandrea.com - -environment: - sdk: ">=2.12.0 <3.0.0" - -dependencies: - flutter: - sdk: flutter - cloud_firestore: ^2.2.0 - -dev_dependencies: - flutter_test: - sdk: flutter - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec -# The following section is specific to Flutter. -flutter: null - -# To add assets to your package, add an assets section, like this: -# assets: -# - images/a_dot_burr.jpeg -# - images/a_dot_ham.jpeg -# -# For details regarding assets in packages, see -# https://flutter.dev/assets-and-images/#from-packages -# -# An image asset can refer to one or more resolution-specific "variants", see -# https://flutter.dev/assets-and-images/#resolution-aware. -# To add custom fonts to your package, add a fonts section here, -# in this "flutter" section. Each entry in this list should have a -# "family" key with the font family name, and a "fonts" key with a -# list giving the asset and other descriptors for the font. For -# example: -# fonts: -# - family: Schyler -# fonts: -# - asset: fonts/Schyler-Regular.ttf -# - asset: fonts/Schyler-Italic.ttf -# style: italic -# - family: Trajan Pro -# fonts: -# - asset: fonts/TrajanPro.ttf -# - asset: fonts/TrajanPro_Bold.ttf -# weight: 700 -# -# For details regarding fonts in packages, see -# https://flutter.dev/custom-fonts/#from-packages - diff --git a/pubspec.lock b/pubspec.lock index 26c45936..36cf4edc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -266,7 +266,7 @@ packages: name: firebase_core_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "4.5.2" + version: "4.5.1" firebase_core_web: dependency: transitive description: @@ -274,13 +274,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.7.3" - firestore_service: - dependency: "direct main" - description: - path: "packages/firestore_service" - relative: true - source: path - version: "0.2.1" fixnum: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 08c31bd5..6dead87e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,8 +14,6 @@ dependencies: path: packages/alert_dialogs custom_buttons: path: packages/custom_buttons - firestore_service: - path: packages/firestore_service cloud_firestore: cupertino_icons: equatable: @@ -35,6 +33,10 @@ dev_dependencies: mocktail: random_string: +# https://stackoverflow.com/a/74234079/436422 +#dependency_overrides: +# firebase_core_platform_interface: 4.5.1 + flutter: uses-material-design: true assets: From 2979edf3bb94863ecd785886a4af5595c14935e2 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Mon, 31 Oct 2022 09:26:53 +0000 Subject: [PATCH 10/45] Remove custom packages --- lib/src/common_widgets/avatar.dart | 1 - .../src/common_widgets}/custom_buttons.dart | 0 .../common_widgets}/custom_raised_button.dart | 0 .../common_widgets}/form_submit_button.dart | 0 lib/src/features/account/account_page.dart | 2 +- lib/src/features/job_entries/entry_page.dart | 4 +- .../job_entries/job_entries_page.dart | 6 +- lib/src/features/jobs/edit_job_page.dart | 2 +- lib/src/features/jobs/jobs_page.dart | 4 +- .../features/onboarding/onboarding_page.dart | 4 +- lib/src/features/sign_in/sign_in_button.dart | 2 +- lib/src/features/sign_in/sign_in_page.dart | 4 +- .../lib => lib/src/utils}/alert_dialogs.dart | 0 lib/src/utils/async_value_ui.dart | 2 +- .../src/utils}/show_alert_dialog.dart | 0 .../utils}/show_exception_alert_dialog.dart | 0 packages/alert_dialogs/.gitignore | 75 -------- packages/alert_dialogs/.metadata | 10 - packages/alert_dialogs/CHANGELOG.md | 5 - packages/alert_dialogs/LICENSE | 7 - packages/alert_dialogs/README.md | 15 -- packages/alert_dialogs/pubspec.lock | 180 ------------------ packages/alert_dialogs/pubspec.yaml | 53 ------ packages/custom_buttons/.gitignore | 75 -------- packages/custom_buttons/.metadata | 10 - packages/custom_buttons/CHANGELOG.md | 4 - packages/custom_buttons/LICENSE | 7 - packages/custom_buttons/README.md | 9 - packages/custom_buttons/pubspec.lock | 139 -------------- packages/custom_buttons/pubspec.yaml | 53 ------ pubspec.lock | 14 -- pubspec.yaml | 10 +- 32 files changed, 18 insertions(+), 679 deletions(-) rename {packages/custom_buttons/lib => lib/src/common_widgets}/custom_buttons.dart (100%) rename {packages/custom_buttons/lib => lib/src/common_widgets}/custom_raised_button.dart (100%) rename {packages/custom_buttons/lib => lib/src/common_widgets}/form_submit_button.dart (100%) rename {packages/alert_dialogs/lib => lib/src/utils}/alert_dialogs.dart (100%) rename {packages/alert_dialogs/lib => lib/src/utils}/show_alert_dialog.dart (100%) rename {packages/alert_dialogs/lib => lib/src/utils}/show_exception_alert_dialog.dart (100%) delete mode 100644 packages/alert_dialogs/.gitignore delete mode 100644 packages/alert_dialogs/.metadata delete mode 100644 packages/alert_dialogs/CHANGELOG.md delete mode 100644 packages/alert_dialogs/LICENSE delete mode 100644 packages/alert_dialogs/README.md delete mode 100644 packages/alert_dialogs/pubspec.lock delete mode 100644 packages/alert_dialogs/pubspec.yaml delete mode 100644 packages/custom_buttons/.gitignore delete mode 100644 packages/custom_buttons/.metadata delete mode 100644 packages/custom_buttons/CHANGELOG.md delete mode 100644 packages/custom_buttons/LICENSE delete mode 100644 packages/custom_buttons/README.md delete mode 100644 packages/custom_buttons/pubspec.lock delete mode 100644 packages/custom_buttons/pubspec.yaml diff --git a/lib/src/common_widgets/avatar.dart b/lib/src/common_widgets/avatar.dart index 02ad230e..91dcac1f 100644 --- a/lib/src/common_widgets/avatar.dart +++ b/lib/src/common_widgets/avatar.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; class Avatar extends StatelessWidget { const Avatar({ diff --git a/packages/custom_buttons/lib/custom_buttons.dart b/lib/src/common_widgets/custom_buttons.dart similarity index 100% rename from packages/custom_buttons/lib/custom_buttons.dart rename to lib/src/common_widgets/custom_buttons.dart diff --git a/packages/custom_buttons/lib/custom_raised_button.dart b/lib/src/common_widgets/custom_raised_button.dart similarity index 100% rename from packages/custom_buttons/lib/custom_raised_button.dart rename to lib/src/common_widgets/custom_raised_button.dart diff --git a/packages/custom_buttons/lib/form_submit_button.dart b/lib/src/common_widgets/form_submit_button.dart similarity index 100% rename from packages/custom_buttons/lib/form_submit_button.dart rename to lib/src/common_widgets/form_submit_button.dart diff --git a/lib/src/features/account/account_page.dart b/lib/src/features/account/account_page.dart index 03031afa..02177662 100644 --- a/lib/src/features/account/account_page.dart +++ b/lib/src/features/account/account_page.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:alert_dialogs/alert_dialogs.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -8,6 +7,7 @@ import 'package:starter_architecture_flutter_firebase/src/common_widgets/avatar. import 'package:starter_architecture_flutter_firebase/src/constants/keys.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; import 'package:starter_architecture_flutter_firebase/src/repositories/fake_auth_repository.dart'; +import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; class AccountPage extends ConsumerWidget { Future _signOut(BuildContext context, WidgetRef ref) async { diff --git a/lib/src/features/job_entries/entry_page.dart b/lib/src/features/job_entries/entry_page.dart index 054a9c71..c383d18a 100644 --- a/lib/src/features/job_entries/entry_page.dart +++ b/lib/src/features/job_entries/entry_page.dart @@ -1,13 +1,13 @@ -import 'package:alert_dialogs/alert_dialogs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/common_widgets/date_time_picker.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/job_entries/format.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/job_entries/format.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; import 'package:starter_architecture_flutter_firebase/src/services/firestore_database.dart'; import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; +import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; class EntryPage extends ConsumerStatefulWidget { const EntryPage({required this.job, this.entry}); diff --git a/lib/src/features/job_entries/job_entries_page.dart b/lib/src/features/job_entries/job_entries_page.dart index 6ee8eefc..43c7adc0 100644 --- a/lib/src/features/job_entries/job_entries_page.dart +++ b/lib/src/features/job_entries/job_entries_page.dart @@ -1,16 +1,16 @@ import 'dart:async'; -import 'package:alert_dialogs/alert_dialogs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; import 'package:starter_architecture_flutter_firebase/src/features/job_entries/entry_list_item.dart'; import 'package:starter_architecture_flutter_firebase/src/features/job_entries/entry_page.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/edit_job_page.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/list_items_builder.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/cupertino_tab_view_router.dart'; import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; +import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; class JobEntriesPage extends StatelessWidget { const JobEntriesPage({required this.job}); diff --git a/lib/src/features/jobs/edit_job_page.dart b/lib/src/features/jobs/edit_job_page.dart index c2b404dd..638f6e0b 100644 --- a/lib/src/features/jobs/edit_job_page.dart +++ b/lib/src/features/jobs/edit_job_page.dart @@ -1,10 +1,10 @@ -import 'package:alert_dialogs/alert_dialogs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; import 'package:starter_architecture_flutter_firebase/src/services/firestore_database.dart'; import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; +import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; class EditJobPage extends ConsumerStatefulWidget { const EditJobPage({Key? key, this.job}) : super(key: key); diff --git a/lib/src/features/jobs/jobs_page.dart b/lib/src/features/jobs/jobs_page.dart index e8120bcc..ae2da99b 100644 --- a/lib/src/features/jobs/jobs_page.dart +++ b/lib/src/features/jobs/jobs_page.dart @@ -1,13 +1,13 @@ -import 'package:alert_dialogs/alert_dialogs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; import 'package:starter_architecture_flutter_firebase/src/features/job_entries/job_entries_page.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/edit_job_page.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/job_list_tile.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/list_items_builder.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; +import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; final jobsStreamProvider = StreamProvider.autoDispose>((ref) { final database = ref.watch(databaseProvider); diff --git a/lib/src/features/onboarding/onboarding_page.dart b/lib/src/features/onboarding/onboarding_page.dart index 793145d3..cee4b42b 100644 --- a/lib/src/features/onboarding/onboarding_page.dart +++ b/lib/src/features/onboarding/onboarding_page.dart @@ -1,7 +1,7 @@ -import 'package:custom_buttons/custom_buttons.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:starter_architecture_flutter_firebase/src/common_widgets/custom_buttons.dart'; import 'package:starter_architecture_flutter_firebase/src/features/onboarding/onboarding_view_model.dart'; class OnboardingPage extends ConsumerWidget { @@ -16,7 +16,7 @@ class OnboardingPage extends ConsumerWidget { children: [ Text( 'Track your time.\nBecause time counts.', - style: Theme.of(context).textTheme.headline4, + style: Theme.of(context).textTheme.headline5, textAlign: TextAlign.center, ), FractionallySizedBox( diff --git a/lib/src/features/sign_in/sign_in_button.dart b/lib/src/features/sign_in/sign_in_button.dart index eee9636a..cf0470d8 100644 --- a/lib/src/features/sign_in/sign_in_button.dart +++ b/lib/src/features/sign_in/sign_in_button.dart @@ -1,5 +1,5 @@ -import 'package:custom_buttons/custom_buttons.dart'; import 'package:flutter/material.dart'; +import 'package:starter_architecture_flutter_firebase/src/common_widgets/custom_buttons.dart'; class SignInButton extends CustomRaisedButton { SignInButton({ diff --git a/lib/src/features/sign_in/sign_in_page.dart b/lib/src/features/sign_in/sign_in_page.dart index e122e8c5..3403ef5b 100644 --- a/lib/src/features/sign_in/sign_in_page.dart +++ b/lib/src/features/sign_in/sign_in_page.dart @@ -1,14 +1,14 @@ import 'dart:math'; -import 'package:alert_dialogs/alert_dialogs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/keys.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; -import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; import 'package:starter_architecture_flutter_firebase/src/features/sign_in/sign_in_button.dart'; import 'package:starter_architecture_flutter_firebase/src/features/sign_in/sign_in_view_model.dart'; +import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; +import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; final signInModelProvider = ChangeNotifierProvider( (ref) => SignInViewModel(auth: ref.watch(firebaseAuthProvider)), diff --git a/packages/alert_dialogs/lib/alert_dialogs.dart b/lib/src/utils/alert_dialogs.dart similarity index 100% rename from packages/alert_dialogs/lib/alert_dialogs.dart rename to lib/src/utils/alert_dialogs.dart diff --git a/lib/src/utils/async_value_ui.dart b/lib/src/utils/async_value_ui.dart index 745e4345..b47b14fa 100644 --- a/lib/src/utils/async_value_ui.dart +++ b/lib/src/utils/async_value_ui.dart @@ -1,7 +1,7 @@ -import 'package:alert_dialogs/alert_dialogs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/localization/string_hardcoded.dart'; +import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; extension AsyncValueUI on AsyncValue { void showAlertDialogOnError(BuildContext context) { diff --git a/packages/alert_dialogs/lib/show_alert_dialog.dart b/lib/src/utils/show_alert_dialog.dart similarity index 100% rename from packages/alert_dialogs/lib/show_alert_dialog.dart rename to lib/src/utils/show_alert_dialog.dart diff --git a/packages/alert_dialogs/lib/show_exception_alert_dialog.dart b/lib/src/utils/show_exception_alert_dialog.dart similarity index 100% rename from packages/alert_dialogs/lib/show_exception_alert_dialog.dart rename to lib/src/utils/show_exception_alert_dialog.dart diff --git a/packages/alert_dialogs/.gitignore b/packages/alert_dialogs/.gitignore deleted file mode 100644 index bb431f0d..00000000 --- a/packages/alert_dialogs/.gitignore +++ /dev/null @@ -1,75 +0,0 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.buildlog/ -.history -.svn/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -.dart_tool/ -.flutter-plugins -.flutter-plugins-dependencies -.packages -.pub-cache/ -.pub/ -build/ - -# Android related -**/android/**/gradle-wrapper.jar -**/android/.gradle -**/android/captures/ -**/android/gradlew -**/android/gradlew.bat -**/android/local.properties -**/android/**/GeneratedPluginRegistrant.java - -# iOS/XCode related -**/ios/**/*.mode1v3 -**/ios/**/*.mode2v3 -**/ios/**/*.moved-aside -**/ios/**/*.pbxuser -**/ios/**/*.perspectivev3 -**/ios/**/*sync/ -**/ios/**/.sconsign.dblite -**/ios/**/.tags* -**/ios/**/.vagrant/ -**/ios/**/DerivedData/ -**/ios/**/Icon? -**/ios/**/Pods/ -**/ios/**/.symlinks/ -**/ios/**/profile -**/ios/**/xcuserdata -**/ios/.generated/ -**/ios/Flutter/App.framework -**/ios/Flutter/Flutter.framework -**/ios/Flutter/Flutter.podspec -**/ios/Flutter/Generated.xcconfig -**/ios/Flutter/app.flx -**/ios/Flutter/app.zip -**/ios/Flutter/flutter_assets/ -**/ios/Flutter/flutter_export_environment.sh -**/ios/ServiceDefinitions.json -**/ios/Runner/GeneratedPluginRegistrant.* - -# Exceptions to above rules. -!**/ios/**/default.mode1v3 -!**/ios/**/default.mode2v3 -!**/ios/**/default.pbxuser -!**/ios/**/default.perspectivev3 -!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages diff --git a/packages/alert_dialogs/.metadata b/packages/alert_dialogs/.metadata deleted file mode 100644 index 616b0463..00000000 --- a/packages/alert_dialogs/.metadata +++ /dev/null @@ -1,10 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: e6b34c2b5c96bb95325269a29a84e83ed8909b5f - channel: stable - -project_type: package diff --git a/packages/alert_dialogs/CHANGELOG.md b/packages/alert_dialogs/CHANGELOG.md deleted file mode 100644 index 1b493db4..00000000 --- a/packages/alert_dialogs/CHANGELOG.md +++ /dev/null @@ -1,5 +0,0 @@ -## [0.0.1] - 2020-05-16 - -* Initial release -* Add `showAlertDialog`, `showExceptionAlertDialog` methods -* Add `PlatformWeb` extension on `Platform` \ No newline at end of file diff --git a/packages/alert_dialogs/LICENSE b/packages/alert_dialogs/LICENSE deleted file mode 100644 index 6dbdfbbc..00000000 --- a/packages/alert_dialogs/LICENSE +++ /dev/null @@ -1,7 +0,0 @@ -Copyright (c) 2020 Andrea Bizzotto [bizz84@gmail.com](mailto:bizz84@gmail.com) - -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: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -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. \ No newline at end of file diff --git a/packages/alert_dialogs/README.md b/packages/alert_dialogs/README.md deleted file mode 100644 index 596d939f..00000000 --- a/packages/alert_dialogs/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# alert_dialogs - -Platform-aware alert dialogs. - -Supported: -- title -- content (message) -- (optional) cancel action button -- default action button - -NOTE: The author will only maintain this package for his own internal projects. It will **not** be published on [pub.dev](https://pub.dev) and, while you're free to use it, it's not meant to be a community project. - -Breaking changes may be introduced at any time. - -[LICENSE: MIT](LICENSE) \ No newline at end of file diff --git a/packages/alert_dialogs/pubspec.lock b/packages/alert_dialogs/pubspec.lock deleted file mode 100644 index 686d9281..00000000 --- a/packages/alert_dialogs/pubspec.lock +++ /dev/null @@ -1,180 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - async: - dependency: transitive - description: - name: async - url: "https://pub.dartlang.org" - source: hosted - version: "2.9.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - characters: - dependency: transitive - description: - name: characters - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.1" - clock: - dependency: transitive - description: - name: clock - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.1" - collection: - dependency: transitive - description: - name: collection - url: "https://pub.dartlang.org" - source: hosted - version: "1.16.0" - fake_async: - dependency: transitive - description: - name: fake_async - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.1" - firebase_core: - dependency: "direct main" - description: - name: firebase_core - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.1" - firebase_core_platform_interface: - dependency: transitive - description: - name: firebase_core_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "4.5.2" - firebase_core_web: - dependency: transitive - description: - name: firebase_core_web - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - js: - dependency: transitive - description: - name: js - url: "https://pub.dartlang.org" - source: hosted - version: "0.6.4" - matcher: - dependency: transitive - description: - name: matcher - url: "https://pub.dartlang.org" - source: hosted - version: "0.12.12" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - url: "https://pub.dartlang.org" - source: hosted - version: "0.1.5" - meta: - dependency: transitive - description: - name: meta - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.0" - path: - dependency: transitive - description: - name: path - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.2" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.3" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_span: - dependency: transitive - description: - name: source_span - url: "https://pub.dartlang.org" - source: hosted - version: "1.9.0" - stack_trace: - dependency: transitive - description: - name: stack_trace - url: "https://pub.dartlang.org" - source: hosted - version: "1.10.0" - stream_channel: - dependency: transitive - description: - name: stream_channel - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - string_scanner: - dependency: transitive - description: - name: string_scanner - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.1" - term_glyph: - dependency: transitive - description: - name: term_glyph - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.1" - test_api: - dependency: transitive - description: - name: test_api - url: "https://pub.dartlang.org" - source: hosted - version: "0.4.12" - vector_math: - dependency: transitive - description: - name: vector_math - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.2" -sdks: - dart: ">=2.17.0 <3.0.0" - flutter: ">=1.20.0" diff --git a/packages/alert_dialogs/pubspec.yaml b/packages/alert_dialogs/pubspec.yaml deleted file mode 100644 index 6c90ffad..00000000 --- a/packages/alert_dialogs/pubspec.yaml +++ /dev/null @@ -1,53 +0,0 @@ -name: alert_dialogs -description: Helper methods for showing alert dialogs -version: 0.2.0 -homepage: https://www.codewithandrea.com - -environment: - sdk: ">=2.17.0 <3.0.0" - -dependencies: - flutter: - sdk: flutter - firebase_core: - -dev_dependencies: - flutter_test: - sdk: flutter - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter. -flutter: - - # To add assets to your package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.dev/assets-and-images/#from-packages - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware. - - # To add custom fonts to your package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/custom-fonts/#from-packages diff --git a/packages/custom_buttons/.gitignore b/packages/custom_buttons/.gitignore deleted file mode 100644 index bb431f0d..00000000 --- a/packages/custom_buttons/.gitignore +++ /dev/null @@ -1,75 +0,0 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.buildlog/ -.history -.svn/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -.dart_tool/ -.flutter-plugins -.flutter-plugins-dependencies -.packages -.pub-cache/ -.pub/ -build/ - -# Android related -**/android/**/gradle-wrapper.jar -**/android/.gradle -**/android/captures/ -**/android/gradlew -**/android/gradlew.bat -**/android/local.properties -**/android/**/GeneratedPluginRegistrant.java - -# iOS/XCode related -**/ios/**/*.mode1v3 -**/ios/**/*.mode2v3 -**/ios/**/*.moved-aside -**/ios/**/*.pbxuser -**/ios/**/*.perspectivev3 -**/ios/**/*sync/ -**/ios/**/.sconsign.dblite -**/ios/**/.tags* -**/ios/**/.vagrant/ -**/ios/**/DerivedData/ -**/ios/**/Icon? -**/ios/**/Pods/ -**/ios/**/.symlinks/ -**/ios/**/profile -**/ios/**/xcuserdata -**/ios/.generated/ -**/ios/Flutter/App.framework -**/ios/Flutter/Flutter.framework -**/ios/Flutter/Flutter.podspec -**/ios/Flutter/Generated.xcconfig -**/ios/Flutter/app.flx -**/ios/Flutter/app.zip -**/ios/Flutter/flutter_assets/ -**/ios/Flutter/flutter_export_environment.sh -**/ios/ServiceDefinitions.json -**/ios/Runner/GeneratedPluginRegistrant.* - -# Exceptions to above rules. -!**/ios/**/default.mode1v3 -!**/ios/**/default.mode2v3 -!**/ios/**/default.pbxuser -!**/ios/**/default.perspectivev3 -!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages diff --git a/packages/custom_buttons/.metadata b/packages/custom_buttons/.metadata deleted file mode 100644 index 616b0463..00000000 --- a/packages/custom_buttons/.metadata +++ /dev/null @@ -1,10 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: e6b34c2b5c96bb95325269a29a84e83ed8909b5f - channel: stable - -project_type: package diff --git a/packages/custom_buttons/CHANGELOG.md b/packages/custom_buttons/CHANGELOG.md deleted file mode 100644 index 14316fa5..00000000 --- a/packages/custom_buttons/CHANGELOG.md +++ /dev/null @@ -1,4 +0,0 @@ -## [0.0.1] - 2020-05-16 - -* Initial release -* Add `CustomRaisedButton`, `FormSubmitButton` classes diff --git a/packages/custom_buttons/LICENSE b/packages/custom_buttons/LICENSE deleted file mode 100644 index 6dbdfbbc..00000000 --- a/packages/custom_buttons/LICENSE +++ /dev/null @@ -1,7 +0,0 @@ -Copyright (c) 2020 Andrea Bizzotto [bizz84@gmail.com](mailto:bizz84@gmail.com) - -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: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -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. \ No newline at end of file diff --git a/packages/custom_buttons/README.md b/packages/custom_buttons/README.md deleted file mode 100644 index ffcc9aa4..00000000 --- a/packages/custom_buttons/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# custom_buttons - -Some custom reusable button widget classes. - -NOTE: The author will only maintain this package for his own internal projects. It will **not** be published on [pub.dev](https://pub.dev) and, while you're free to use it, it's not meant to be a community project. - -Breaking changes may be introduced at any time. - -[LICENSE: MIT](LICENSE) \ No newline at end of file diff --git a/packages/custom_buttons/pubspec.lock b/packages/custom_buttons/pubspec.lock deleted file mode 100644 index 757cf34c..00000000 --- a/packages/custom_buttons/pubspec.lock +++ /dev/null @@ -1,139 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - async: - dependency: transitive - description: - name: async - url: "https://pub.dartlang.org" - source: hosted - version: "2.9.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - characters: - dependency: transitive - description: - name: characters - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.1" - clock: - dependency: transitive - description: - name: clock - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.1" - collection: - dependency: transitive - description: - name: collection - url: "https://pub.dartlang.org" - source: hosted - version: "1.16.0" - fake_async: - dependency: transitive - description: - name: fake_async - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - matcher: - dependency: transitive - description: - name: matcher - url: "https://pub.dartlang.org" - source: hosted - version: "0.12.12" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - url: "https://pub.dartlang.org" - source: hosted - version: "0.1.5" - meta: - dependency: transitive - description: - name: meta - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.0" - path: - dependency: transitive - description: - name: path - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.2" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_span: - dependency: transitive - description: - name: source_span - url: "https://pub.dartlang.org" - source: hosted - version: "1.9.0" - stack_trace: - dependency: transitive - description: - name: stack_trace - url: "https://pub.dartlang.org" - source: hosted - version: "1.10.0" - stream_channel: - dependency: transitive - description: - name: stream_channel - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - string_scanner: - dependency: transitive - description: - name: string_scanner - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.1" - term_glyph: - dependency: transitive - description: - name: term_glyph - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.1" - test_api: - dependency: transitive - description: - name: test_api - url: "https://pub.dartlang.org" - source: hosted - version: "0.4.12" - vector_math: - dependency: transitive - description: - name: vector_math - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.2" -sdks: - dart: ">=2.17.0 <3.0.0" diff --git a/packages/custom_buttons/pubspec.yaml b/packages/custom_buttons/pubspec.yaml deleted file mode 100644 index f626e2a8..00000000 --- a/packages/custom_buttons/pubspec.yaml +++ /dev/null @@ -1,53 +0,0 @@ -name: custom_buttons -description: A new Flutter package project. -version: 0.1.0 -author: -homepage: - -environment: - sdk: ">=2.17.0 <3.0.0" - -dependencies: - flutter: - sdk: flutter - -dev_dependencies: - flutter_test: - sdk: flutter - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter. -flutter: - - # To add assets to your package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.dev/assets-and-images/#from-packages - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware. - - # To add custom fonts to your package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/custom-fonts/#from-packages diff --git a/pubspec.lock b/pubspec.lock index 36cf4edc..5bbae1ba 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -15,13 +15,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.2" - alert_dialogs: - dependency: "direct main" - description: - path: "packages/alert_dialogs" - relative: true - source: path - version: "0.2.0" analyzer: dependency: transitive description: @@ -190,13 +183,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.5" - custom_buttons: - dependency: "direct main" - description: - path: "packages/custom_buttons" - relative: true - source: path - version: "0.1.0" dart_style: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6dead87e..c3a8250f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,17 +8,13 @@ environment: sdk: ">=2.17.0 <3.0.0" dependencies: - flutter: - sdk: flutter - alert_dialogs: - path: packages/alert_dialogs - custom_buttons: - path: packages/custom_buttons cloud_firestore: cupertino_icons: equatable: firebase_auth: firebase_core: + flutter: + sdk: flutter flutter_riverpod: 2.0.2 flutter_svg: intl: @@ -27,9 +23,9 @@ dependencies: shared_preferences: dev_dependencies: + build_runner: flutter_test: sdk: flutter - build_runner: mocktail: random_string: From a8f3d65efd64f5b50145114811f7d38b8dd55349 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Mon, 31 Oct 2022 09:37:44 +0000 Subject: [PATCH 11/45] Restore unawaited calls --- lib/main.dart | 2 +- lib/src/features/account/account_page.dart | 4 ++-- lib/src/features/job_entries/entry_page.dart | 6 ++++-- lib/src/features/jobs/edit_job_page.dart | 6 ++++-- lib/src/features/jobs/jobs_page.dart | 6 ++++-- lib/src/features/sign_in/sign_in_page.dart | 5 +++-- 6 files changed, 18 insertions(+), 11 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 2731001c..c4ca5136 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -38,7 +38,7 @@ class MyApp extends ConsumerWidget { debugShowCheckedModeBanner: false, home: AuthWidget( nonSignedInBuilder: (_) => Consumer( - builder: (context, watch, _) { + builder: (context, ref, _) { final didCompleteOnboarding = ref.watch(onboardingViewModelProvider); return didCompleteOnboarding ? SignInPage() : OnboardingPage(); diff --git a/lib/src/features/account/account_page.dart b/lib/src/features/account/account_page.dart index 02177662..9503bc34 100644 --- a/lib/src/features/account/account_page.dart +++ b/lib/src/features/account/account_page.dart @@ -14,11 +14,11 @@ class AccountPage extends ConsumerWidget { try { await ref.read(authRepositoryProvider).signOut(); } catch (e) { - await showExceptionAlertDialog( + unawaited(showExceptionAlertDialog( context: context, title: Strings.logoutFailed, exception: e, - ); + )); } } diff --git a/lib/src/features/job_entries/entry_page.dart b/lib/src/features/job_entries/entry_page.dart index c383d18a..5ba9a302 100644 --- a/lib/src/features/job_entries/entry_page.dart +++ b/lib/src/features/job_entries/entry_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/common_widgets/date_time_picker.dart'; @@ -72,11 +74,11 @@ class _EntryPageState extends ConsumerState { await database.setEntry(entry); Navigator.of(context).pop(); } catch (e) { - await showExceptionAlertDialog( + unawaited(showExceptionAlertDialog( context: context, title: 'Operation failed', exception: e, - ); + )); } } diff --git a/lib/src/features/jobs/edit_job_page.dart b/lib/src/features/jobs/edit_job_page.dart index 638f6e0b..765ab08e 100644 --- a/lib/src/features/jobs/edit_job_page.dart +++ b/lib/src/features/jobs/edit_job_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; @@ -70,11 +72,11 @@ class _EditJobPageState extends ConsumerState { Navigator.of(context).pop(); } } catch (e) { - await showExceptionAlertDialog( + unawaited(showExceptionAlertDialog( context: context, title: 'Operation failed', exception: e, - ); + )); } } } diff --git a/lib/src/features/jobs/jobs_page.dart b/lib/src/features/jobs/jobs_page.dart index ae2da99b..eb611913 100644 --- a/lib/src/features/jobs/jobs_page.dart +++ b/lib/src/features/jobs/jobs_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; @@ -20,11 +22,11 @@ class JobsPage extends ConsumerWidget { try { await ref.read(databaseProvider).deleteJob(job); } catch (e) { - await showExceptionAlertDialog( + unawaited(showExceptionAlertDialog( context: context, title: 'Operation failed', exception: e, - ); + )); } } diff --git a/lib/src/features/sign_in/sign_in_page.dart b/lib/src/features/sign_in/sign_in_page.dart index 3403ef5b..bab55798 100644 --- a/lib/src/features/sign_in/sign_in_page.dart +++ b/lib/src/features/sign_in/sign_in_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; @@ -19,11 +20,11 @@ class SignInPage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { ref.listen(signInModelProvider, (prev, model) async { if (model.error != null) { - await showExceptionAlertDialog( + unawaited(showExceptionAlertDialog( context: context, title: Strings.signInFailed, exception: model.error, - ); + )); } }); final signInModel = ref.watch(signInModelProvider); From a9b3ba8c3da4f191fc021b2a865a2517165afb29 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Mon, 31 Oct 2022 09:46:23 +0000 Subject: [PATCH 12/45] Start cleaning up buttons code --- lib/src/common_widgets/custom_buttons.dart | 6 ------ lib/src/common_widgets/custom_raised_button.dart | 11 ++++++----- lib/src/common_widgets/form_submit_button.dart | 6 +++--- lib/src/features/onboarding/onboarding_page.dart | 2 +- lib/src/features/sign_in/sign_in_button.dart | 2 +- 5 files changed, 11 insertions(+), 16 deletions(-) delete mode 100644 lib/src/common_widgets/custom_buttons.dart diff --git a/lib/src/common_widgets/custom_buttons.dart b/lib/src/common_widgets/custom_buttons.dart deleted file mode 100644 index fc268dde..00000000 --- a/lib/src/common_widgets/custom_buttons.dart +++ /dev/null @@ -1,6 +0,0 @@ -library custom_buttons; - -import 'package:flutter/material.dart'; - -part 'custom_raised_button.dart'; -part 'form_submit_button.dart'; diff --git a/lib/src/common_widgets/custom_raised_button.dart b/lib/src/common_widgets/custom_raised_button.dart index f0c04265..537402bf 100644 --- a/lib/src/common_widgets/custom_raised_button.dart +++ b/lib/src/common_widgets/custom_raised_button.dart @@ -1,9 +1,8 @@ -part of custom_buttons; +import 'package:flutter/material.dart'; -@immutable class CustomRaisedButton extends StatelessWidget { const CustomRaisedButton({ - Key? key, + super.key, required this.child, this.color, this.textColor, @@ -11,7 +10,7 @@ class CustomRaisedButton extends StatelessWidget { this.borderRadius = 4.0, this.loading = false, this.onPressed, - }) : super(key: key); + }); final Widget child; final Color? color; final Color? textColor; @@ -23,7 +22,9 @@ class CustomRaisedButton extends StatelessWidget { Widget buildSpinner(BuildContext context) { final ThemeData data = Theme.of(context); return Theme( - data: data.copyWith(accentColor: Colors.white70), + data: data.copyWith( + colorScheme: + ColorScheme.fromSwatch().copyWith(secondary: Colors.white70)), child: const SizedBox( width: 28, height: 28, diff --git a/lib/src/common_widgets/form_submit_button.dart b/lib/src/common_widgets/form_submit_button.dart index 14ba2aac..87dfa520 100644 --- a/lib/src/common_widgets/form_submit_button.dart +++ b/lib/src/common_widgets/form_submit_button.dart @@ -1,13 +1,13 @@ -part of custom_buttons; +import 'package:flutter/material.dart'; +import 'package:starter_architecture_flutter_firebase/src/common_widgets/custom_raised_button.dart'; class FormSubmitButton extends CustomRaisedButton { FormSubmitButton({ - Key? key, + super.key, required String text, bool loading = false, VoidCallback? onPressed, }) : super( - key: key, child: Text( text, style: const TextStyle(color: Colors.white, fontSize: 20.0), diff --git a/lib/src/features/onboarding/onboarding_page.dart b/lib/src/features/onboarding/onboarding_page.dart index cee4b42b..866fa14c 100644 --- a/lib/src/features/onboarding/onboarding_page.dart +++ b/lib/src/features/onboarding/onboarding_page.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:starter_architecture_flutter_firebase/src/common_widgets/custom_buttons.dart'; +import 'package:starter_architecture_flutter_firebase/src/common_widgets/custom_raised_button.dart'; import 'package:starter_architecture_flutter_firebase/src/features/onboarding/onboarding_view_model.dart'; class OnboardingPage extends ConsumerWidget { diff --git a/lib/src/features/sign_in/sign_in_button.dart b/lib/src/features/sign_in/sign_in_button.dart index cf0470d8..c00d6a4a 100644 --- a/lib/src/features/sign_in/sign_in_button.dart +++ b/lib/src/features/sign_in/sign_in_button.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:starter_architecture_flutter_firebase/src/common_widgets/custom_buttons.dart'; +import 'package:starter_architecture_flutter_firebase/src/common_widgets/custom_raised_button.dart'; class SignInButton extends CustomRaisedButton { SignInButton({ From 8650494a76066fcb168209d8753e4dcb30295cb8 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Mon, 31 Oct 2022 09:53:12 +0000 Subject: [PATCH 13/45] Create "authentication" feature --- lib/main.dart | 4 +- .../data/firebase_auth_repository.dart} | 0 .../presentation/account/account_screen.dart} | 44 +++++++++---------- .../email_password_sign_in_controller.dart | 4 +- .../email_password_sign_in_form_type.dart | 0 .../email_password_sign_in_screen.dart | 8 ++-- .../email_password_sign_in_validators.dart | 4 +- .../email_password/string_validators.dart | 0 .../presentation}/sign_in/sign_in_button.dart | 0 .../presentation/sign_in/sign_in_screen.dart} | 6 +-- .../sign_in/sign_in_view_model.dart | 0 lib/src/features/home/home_page.dart | 4 +- lib/src/routing/app_router.dart | 4 +- 13 files changed, 37 insertions(+), 41 deletions(-) rename lib/src/{repositories/fake_auth_repository.dart => features/authentication/data/firebase_auth_repository.dart} (100%) rename lib/src/features/{account/account_page.dart => authentication/presentation/account/account_screen.dart} (72%) rename lib/src/features/{ => authentication/presentation}/email_password/email_password_sign_in_controller.dart (86%) rename lib/src/features/{ => authentication/presentation}/email_password/email_password_sign_in_form_type.dart (100%) rename lib/src/features/{ => authentication/presentation}/email_password/email_password_sign_in_screen.dart (95%) rename lib/src/features/{ => authentication/presentation}/email_password/email_password_sign_in_validators.dart (90%) rename lib/src/features/{ => authentication/presentation}/email_password/string_validators.dart (100%) rename lib/src/features/{ => authentication/presentation}/sign_in/sign_in_button.dart (100%) rename lib/src/features/{sign_in/sign_in_page.dart => authentication/presentation/sign_in/sign_in_screen.dart} (96%) rename lib/src/features/{ => authentication/presentation}/sign_in/sign_in_view_model.dart (100%) diff --git a/lib/main.dart b/lib/main.dart index c4ca5136..51a8c6c3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,7 +8,7 @@ import 'package:starter_architecture_flutter_firebase/src/auth_widget.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/home_page.dart'; import 'package:starter_architecture_flutter_firebase/src/features/onboarding/onboarding_page.dart'; import 'package:starter_architecture_flutter_firebase/src/features/onboarding/onboarding_view_model.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/sign_in/sign_in_page.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/sign_in/sign_in_screen.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; import 'package:starter_architecture_flutter_firebase/src/services/shared_preferences_service.dart'; import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; @@ -41,7 +41,7 @@ class MyApp extends ConsumerWidget { builder: (context, ref, _) { final didCompleteOnboarding = ref.watch(onboardingViewModelProvider); - return didCompleteOnboarding ? SignInPage() : OnboardingPage(); + return didCompleteOnboarding ? SignInScreen() : OnboardingPage(); }, ), signedInBuilder: (_) => HomePage(), diff --git a/lib/src/repositories/fake_auth_repository.dart b/lib/src/features/authentication/data/firebase_auth_repository.dart similarity index 100% rename from lib/src/repositories/fake_auth_repository.dart rename to lib/src/features/authentication/data/firebase_auth_repository.dart diff --git a/lib/src/features/account/account_page.dart b/lib/src/features/authentication/presentation/account/account_screen.dart similarity index 72% rename from lib/src/features/account/account_page.dart rename to lib/src/features/authentication/presentation/account/account_screen.dart index 9503bc34..a3906bfb 100644 --- a/lib/src/features/account/account_page.dart +++ b/lib/src/features/authentication/presentation/account/account_screen.dart @@ -1,15 +1,15 @@ import 'dart:async'; -import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/common_widgets/avatar.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/keys.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; -import 'package:starter_architecture_flutter_firebase/src/repositories/fake_auth_repository.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; -class AccountPage extends ConsumerWidget { +// TODO create corresponding notifier +class AccountScreen extends ConsumerWidget { Future _signOut(BuildContext context, WidgetRef ref) async { try { await ref.read(authRepositoryProvider).signOut(); @@ -57,29 +57,25 @@ class AccountPage extends ConsumerWidget { ], bottom: PreferredSize( preferredSize: const Size.fromHeight(130.0), - child: _buildUserInfo(user), + child: Column( + children: [ + Avatar( + photoUrl: user.photoURL, + radius: 50, + borderColor: Colors.black54, + borderWidth: 2.0, + ), + const SizedBox(height: 8), + if (user.displayName != null) + Text( + user.displayName!, + style: const TextStyle(color: Colors.white), + ), + const SizedBox(height: 8), + ], + ), ), ), ); } - - Widget _buildUserInfo(User user) { - return Column( - children: [ - Avatar( - photoUrl: user.photoURL, - radius: 50, - borderColor: Colors.black54, - borderWidth: 2.0, - ), - const SizedBox(height: 8), - if (user.displayName != null) - Text( - user.displayName!, - style: const TextStyle(color: Colors.white), - ), - const SizedBox(height: 8), - ], - ); - } } diff --git a/lib/src/features/email_password/email_password_sign_in_controller.dart b/lib/src/features/authentication/presentation/email_password/email_password_sign_in_controller.dart similarity index 86% rename from lib/src/features/email_password/email_password_sign_in_controller.dart rename to lib/src/features/authentication/presentation/email_password/email_password_sign_in_controller.dart index a30a022a..0da0bad2 100644 --- a/lib/src/features/email_password/email_password_sign_in_controller.dart +++ b/lib/src/features/authentication/presentation/email_password/email_password_sign_in_controller.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/src/repositories/fake_auth_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/email_password/email_password_sign_in_form_type.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/email_password/email_password_sign_in_form_type.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; class EmailPasswordSignInController extends AutoDisposeAsyncNotifier { @override diff --git a/lib/src/features/email_password/email_password_sign_in_form_type.dart b/lib/src/features/authentication/presentation/email_password/email_password_sign_in_form_type.dart similarity index 100% rename from lib/src/features/email_password/email_password_sign_in_form_type.dart rename to lib/src/features/authentication/presentation/email_password/email_password_sign_in_form_type.dart diff --git a/lib/src/features/email_password/email_password_sign_in_screen.dart b/lib/src/features/authentication/presentation/email_password/email_password_sign_in_screen.dart similarity index 95% rename from lib/src/features/email_password/email_password_sign_in_screen.dart rename to lib/src/features/authentication/presentation/email_password/email_password_sign_in_screen.dart index fe26245b..5621d8a0 100644 --- a/lib/src/features/email_password/email_password_sign_in_screen.dart +++ b/lib/src/features/authentication/presentation/email_password/email_password_sign_in_screen.dart @@ -5,11 +5,11 @@ import 'package:starter_architecture_flutter_firebase/src/common_widgets/custom_ import 'package:starter_architecture_flutter_firebase/src/common_widgets/primary_button.dart'; import 'package:starter_architecture_flutter_firebase/src/common_widgets/responsive_scrollable_card.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/app_sizes.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/email_password/email_password_sign_in_controller.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/email_password/email_password_sign_in_form_type.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/email_password/email_password_sign_in_validators.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/email_password/string_validators.dart'; import 'package:starter_architecture_flutter_firebase/src/localization/string_hardcoded.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/email_password/email_password_sign_in_controller.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/email_password/email_password_sign_in_form_type.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/email_password/email_password_sign_in_validators.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/email_password/string_validators.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/async_value_ui.dart'; /// Email & password sign in screen. diff --git a/lib/src/features/email_password/email_password_sign_in_validators.dart b/lib/src/features/authentication/presentation/email_password/email_password_sign_in_validators.dart similarity index 90% rename from lib/src/features/email_password/email_password_sign_in_validators.dart rename to lib/src/features/authentication/presentation/email_password/email_password_sign_in_validators.dart index 34332fb3..82f82291 100644 --- a/lib/src/features/email_password/email_password_sign_in_validators.dart +++ b/lib/src/features/authentication/presentation/email_password/email_password_sign_in_validators.dart @@ -1,6 +1,6 @@ +import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/email_password/email_password_sign_in_form_type.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/email_password/string_validators.dart'; import 'package:starter_architecture_flutter_firebase/src/localization/string_hardcoded.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/email_password/email_password_sign_in_form_type.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/email_password/string_validators.dart'; /// Mixin class to be used for client-side email & password validation mixin EmailAndPasswordValidators { diff --git a/lib/src/features/email_password/string_validators.dart b/lib/src/features/authentication/presentation/email_password/string_validators.dart similarity index 100% rename from lib/src/features/email_password/string_validators.dart rename to lib/src/features/authentication/presentation/email_password/string_validators.dart diff --git a/lib/src/features/sign_in/sign_in_button.dart b/lib/src/features/authentication/presentation/sign_in/sign_in_button.dart similarity index 100% rename from lib/src/features/sign_in/sign_in_button.dart rename to lib/src/features/authentication/presentation/sign_in/sign_in_button.dart diff --git a/lib/src/features/sign_in/sign_in_page.dart b/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart similarity index 96% rename from lib/src/features/sign_in/sign_in_page.dart rename to lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart index bab55798..c694e00b 100644 --- a/lib/src/features/sign_in/sign_in_page.dart +++ b/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart @@ -5,8 +5,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/keys.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/sign_in/sign_in_button.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/sign_in/sign_in_view_model.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/sign_in/sign_in_button.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/sign_in/sign_in_view_model.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; @@ -15,7 +15,7 @@ final signInModelProvider = ChangeNotifierProvider( (ref) => SignInViewModel(auth: ref.watch(firebaseAuthProvider)), ); -class SignInPage extends ConsumerWidget { +class SignInScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { ref.listen(signInModelProvider, (prev, model) async { diff --git a/lib/src/features/sign_in/sign_in_view_model.dart b/lib/src/features/authentication/presentation/sign_in/sign_in_view_model.dart similarity index 100% rename from lib/src/features/sign_in/sign_in_view_model.dart rename to lib/src/features/authentication/presentation/sign_in/sign_in_view_model.dart diff --git a/lib/src/features/home/home_page.dart b/lib/src/features/home/home_page.dart index 1e81472e..97105967 100644 --- a/lib/src/features/home/home_page.dart +++ b/lib/src/features/home/home_page.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/account/account_page.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/account/account_screen.dart'; import 'package:starter_architecture_flutter_firebase/src/features/entries/entries_page.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/cupertino_home_scaffold.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/tab_item.dart'; @@ -23,7 +23,7 @@ class _HomePageState extends State { return { TabItem.jobs: (_) => JobsPage(), TabItem.entries: (_) => EntriesPage(), - TabItem.account: (_) => AccountPage(), + TabItem.account: (_) => AccountScreen(), }; } diff --git a/lib/src/routing/app_router.dart b/lib/src/routing/app_router.dart index 8c6b140d..d14a015a 100644 --- a/lib/src/routing/app_router.dart +++ b/lib/src/routing/app_router.dart @@ -1,7 +1,7 @@ import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/email_password/email_password_sign_in_form_type.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/email_password/email_password_sign_in_screen.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/email_password/email_password_sign_in_form_type.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/email_password/email_password_sign_in_screen.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; import 'package:starter_architecture_flutter_firebase/src/features/job_entries/entry_page.dart'; From aeff5a868b5503aab53fd0f7dc9efcbc9dd0ac32 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Mon, 31 Oct 2022 09:55:45 +0000 Subject: [PATCH 14/45] Rename onboarding files --- lib/main.dart | 15 ++++++------- .../data/onboarding_repository.dart} | 10 ++++----- .../onboarding/onboarding_view_model.dart | 21 ------------------- .../presentation/onboarding_controller.dart | 21 +++++++++++++++++++ .../onboarding_screen.dart} | 6 +++--- 5 files changed, 37 insertions(+), 36 deletions(-) rename lib/src/{services/shared_preferences_service.dart => features/onboarding/data/onboarding_repository.dart} (68%) delete mode 100644 lib/src/features/onboarding/onboarding_view_model.dart create mode 100644 lib/src/features/onboarding/presentation/onboarding_controller.dart rename lib/src/features/onboarding/{onboarding_page.dart => presentation/onboarding_screen.dart} (89%) diff --git a/lib/main.dart b/lib/main.dart index 51a8c6c3..eb3a9f36 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,11 +6,11 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:starter_architecture_flutter_firebase/firebase_options.dart'; import 'package:starter_architecture_flutter_firebase/src/auth_widget.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/home_page.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/onboarding/onboarding_page.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/onboarding/onboarding_view_model.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/sign_in/sign_in_screen.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/onboarding/presentation/onboarding_controller.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/onboarding/presentation/onboarding_screen.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; -import 'package:starter_architecture_flutter_firebase/src/services/shared_preferences_service.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/onboarding/data/onboarding_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; Future main() async { @@ -21,8 +21,8 @@ Future main() async { final sharedPreferences = await SharedPreferences.getInstance(); runApp(ProviderScope( overrides: [ - sharedPreferencesServiceProvider.overrideWithValue( - SharedPreferencesService(sharedPreferences), + onboardingRepositoryProvider.overrideWithValue( + OnboardingRepository(sharedPreferences), ), ], child: MyApp(), @@ -36,12 +36,13 @@ class MyApp extends ConsumerWidget { return MaterialApp( theme: ThemeData(primarySwatch: Colors.indigo), debugShowCheckedModeBanner: false, + // TODO: Implement all this with GoRouter home: AuthWidget( nonSignedInBuilder: (_) => Consumer( builder: (context, ref, _) { final didCompleteOnboarding = - ref.watch(onboardingViewModelProvider); - return didCompleteOnboarding ? SignInScreen() : OnboardingPage(); + ref.watch(onboardingControllerProvider); + return didCompleteOnboarding ? SignInScreen() : OnboardingScreen(); }, ), signedInBuilder: (_) => HomePage(), diff --git a/lib/src/services/shared_preferences_service.dart b/lib/src/features/onboarding/data/onboarding_repository.dart similarity index 68% rename from lib/src/services/shared_preferences_service.dart rename to lib/src/features/onboarding/data/onboarding_repository.dart index a90fae8b..87e7f01e 100644 --- a/lib/src/services/shared_preferences_service.dart +++ b/lib/src/features/onboarding/data/onboarding_repository.dart @@ -1,11 +1,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; -final sharedPreferencesServiceProvider = - Provider((ref) => throw UnimplementedError()); - -class SharedPreferencesService { - SharedPreferencesService(this.sharedPreferences); +class OnboardingRepository { + OnboardingRepository(this.sharedPreferences); final SharedPreferences sharedPreferences; static const onboardingCompleteKey = 'onboardingComplete'; @@ -17,3 +14,6 @@ class SharedPreferencesService { bool isOnboardingComplete() => sharedPreferences.getBool(onboardingCompleteKey) ?? false; } + +final onboardingRepositoryProvider = + Provider((ref) => throw UnimplementedError()); diff --git a/lib/src/features/onboarding/onboarding_view_model.dart b/lib/src/features/onboarding/onboarding_view_model.dart deleted file mode 100644 index 4779442f..00000000 --- a/lib/src/features/onboarding/onboarding_view_model.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/src/services/shared_preferences_service.dart'; - -final onboardingViewModelProvider = - StateNotifierProvider((ref) { - final sharedPreferencesService = ref.watch(sharedPreferencesServiceProvider); - return OnboardingViewModel(sharedPreferencesService); -}); - -class OnboardingViewModel extends StateNotifier { - OnboardingViewModel(this.sharedPreferencesService) - : super(sharedPreferencesService.isOnboardingComplete()); - final SharedPreferencesService sharedPreferencesService; - - Future completeOnboarding() async { - await sharedPreferencesService.setOnboardingComplete(); - state = true; - } - - bool get isOnboardingComplete => state; -} diff --git a/lib/src/features/onboarding/presentation/onboarding_controller.dart b/lib/src/features/onboarding/presentation/onboarding_controller.dart new file mode 100644 index 00000000..72440911 --- /dev/null +++ b/lib/src/features/onboarding/presentation/onboarding_controller.dart @@ -0,0 +1,21 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/onboarding/data/onboarding_repository.dart'; + +class OnboardingController extends StateNotifier { + OnboardingController(this.sharedPreferencesService) + : super(sharedPreferencesService.isOnboardingComplete()); + final OnboardingRepository sharedPreferencesService; + + Future completeOnboarding() async { + await sharedPreferencesService.setOnboardingComplete(); + state = true; + } + + bool get isOnboardingComplete => state; +} + +final onboardingControllerProvider = + StateNotifierProvider((ref) { + final sharedPreferencesService = ref.watch(onboardingRepositoryProvider); + return OnboardingController(sharedPreferencesService); +}); diff --git a/lib/src/features/onboarding/onboarding_page.dart b/lib/src/features/onboarding/presentation/onboarding_screen.dart similarity index 89% rename from lib/src/features/onboarding/onboarding_page.dart rename to lib/src/features/onboarding/presentation/onboarding_screen.dart index 866fa14c..e64d7839 100644 --- a/lib/src/features/onboarding/onboarding_page.dart +++ b/lib/src/features/onboarding/presentation/onboarding_screen.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:starter_architecture_flutter_firebase/src/common_widgets/custom_raised_button.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/onboarding/onboarding_view_model.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/onboarding/presentation/onboarding_controller.dart'; -class OnboardingPage extends ConsumerWidget { +class OnboardingScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return Scaffold( @@ -26,7 +26,7 @@ class OnboardingPage extends ConsumerWidget { ), CustomRaisedButton( onPressed: () => ref - .read(onboardingViewModelProvider.notifier) + .read(onboardingControllerProvider.notifier) .completeOnboarding(), color: Colors.indigo, borderRadius: 30, From 880b7f170cb4041f8d41adae1f4ff0ff06710a10 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Mon, 31 Oct 2022 10:03:07 +0000 Subject: [PATCH 15/45] Cleanup folders and repositories --- lib/main.dart | 2 +- lib/src/auth_widget.dart | 2 +- .../data/firebase_auth_repository.dart | 5 ++- .../authentication/domain}/app_user.dart | 1 + .../authentication/domain}/fake_app_user.dart | 2 +- .../presentation/sign_in/sign_in_screen.dart | 5 --- .../sign_in/sign_in_view_model.dart | 6 ++++ lib/src/features/entries/entries_page.dart | 2 +- .../features/entries/entries_view_model.dart | 4 +-- .../home/data/firestore_data_source.dart} | 6 ++-- .../home/data/firestore_repository.dart} | 33 ++++++++++++++++--- lib/src/features/job_entries/entry_page.dart | 3 +- .../job_entries/job_entries_page.dart | 2 +- lib/src/features/jobs/edit_job_page.dart | 3 +- lib/src/features/jobs/jobs_page.dart | 7 +--- lib/src/services/firestore_path.dart | 7 ---- lib/src/top_level_providers.dart | 26 --------------- lib/src/utils/logger_provider.dart | 9 +++++ 18 files changed, 61 insertions(+), 64 deletions(-) rename lib/src/{repositories => features/authentication/domain}/app_user.dart (95%) rename lib/src/{repositories => features/authentication/domain}/fake_app_user.dart (68%) rename lib/src/{repositories/firestore_service.dart => features/home/data/firestore_data_source.dart} (94%) rename lib/src/{services/firestore_database.dart => features/home/data/firestore_repository.dart} (61%) delete mode 100644 lib/src/services/firestore_path.dart delete mode 100644 lib/src/top_level_providers.dart create mode 100644 lib/src/utils/logger_provider.dart diff --git a/lib/main.dart b/lib/main.dart index eb3a9f36..c2b7e389 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,13 +5,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:starter_architecture_flutter_firebase/firebase_options.dart'; import 'package:starter_architecture_flutter_firebase/src/auth_widget.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/home_page.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/sign_in/sign_in_screen.dart'; import 'package:starter_architecture_flutter_firebase/src/features/onboarding/presentation/onboarding_controller.dart'; import 'package:starter_architecture_flutter_firebase/src/features/onboarding/presentation/onboarding_screen.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; import 'package:starter_architecture_flutter_firebase/src/features/onboarding/data/onboarding_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/src/auth_widget.dart b/lib/src/auth_widget.dart index 1cdf4887..f85d8138 100644 --- a/lib/src/auth_widget.dart +++ b/lib/src/auth_widget.dart @@ -1,8 +1,8 @@ import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/empty_content.dart'; -import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; class AuthWidget extends ConsumerWidget { const AuthWidget({ diff --git a/lib/src/features/authentication/data/firebase_auth_repository.dart b/lib/src/features/authentication/data/firebase_auth_repository.dart index 1b5f3e52..27ab79a6 100644 --- a/lib/src/features/authentication/data/firebase_auth_repository.dart +++ b/lib/src/features/authentication/data/firebase_auth_repository.dart @@ -22,8 +22,11 @@ class AuthRepository { } } +final firebaseAuthProvider = + Provider((ref) => FirebaseAuth.instance); + final authRepositoryProvider = Provider((ref) { - return AuthRepository(FirebaseAuth.instance); + return AuthRepository(ref.watch(firebaseAuthProvider)); }); final authStateChangesProvider = StreamProvider((ref) { diff --git a/lib/src/repositories/app_user.dart b/lib/src/features/authentication/domain/app_user.dart similarity index 95% rename from lib/src/repositories/app_user.dart rename to lib/src/features/authentication/domain/app_user.dart index ba81b44c..f3d07cd2 100644 --- a/lib/src/repositories/app_user.dart +++ b/lib/src/features/authentication/domain/app_user.dart @@ -1,3 +1,4 @@ +// TODO: FirebaseAppUser? /// Simple class representing the user UID and email. class AppUser { const AppUser({ diff --git a/lib/src/repositories/fake_app_user.dart b/lib/src/features/authentication/domain/fake_app_user.dart similarity index 68% rename from lib/src/repositories/fake_app_user.dart rename to lib/src/features/authentication/domain/fake_app_user.dart index ca1a5362..fc35a6ef 100644 --- a/lib/src/repositories/fake_app_user.dart +++ b/lib/src/features/authentication/domain/fake_app_user.dart @@ -1,4 +1,4 @@ -import 'package:starter_architecture_flutter_firebase/src/repositories/app_user.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/domain/app_user.dart'; /// Fake user class used to simulate a user account on the backend class FakeAppUser extends AppUser { diff --git a/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart b/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart index c694e00b..544d0602 100644 --- a/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart +++ b/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart @@ -8,13 +8,8 @@ import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/sign_in/sign_in_button.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/sign_in/sign_in_view_model.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; -import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; -final signInModelProvider = ChangeNotifierProvider( - (ref) => SignInViewModel(auth: ref.watch(firebaseAuthProvider)), -); - class SignInScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { diff --git a/lib/src/features/authentication/presentation/sign_in/sign_in_view_model.dart b/lib/src/features/authentication/presentation/sign_in/sign_in_view_model.dart index b340e72d..5fc58d92 100644 --- a/lib/src/features/authentication/presentation/sign_in/sign_in_view_model.dart +++ b/lib/src/features/authentication/presentation/sign_in/sign_in_view_model.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; class SignInViewModel with ChangeNotifier { SignInViewModel({required this.auth}); @@ -28,3 +30,7 @@ class SignInViewModel with ChangeNotifier { await _signIn(auth.signInAnonymously); } } + +final signInModelProvider = ChangeNotifierProvider( + (ref) => SignInViewModel(auth: ref.watch(firebaseAuthProvider)), +); diff --git a/lib/src/features/entries/entries_page.dart b/lib/src/features/entries/entries_page.dart index 29ac2469..c3d156cd 100644 --- a/lib/src/features/entries/entries_page.dart +++ b/lib/src/features/entries/entries_page.dart @@ -3,8 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; import 'package:starter_architecture_flutter_firebase/src/features/entries/entries_list_tile.dart'; import 'package:starter_architecture_flutter_firebase/src/features/entries/entries_view_model.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/list_items_builder.dart'; -import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; final entriesTileModelStreamProvider = StreamProvider.autoDispose>( diff --git a/lib/src/features/entries/entries_view_model.dart b/lib/src/features/entries/entries_view_model.dart index ec90d545..a2ae0d5d 100644 --- a/lib/src/features/entries/entries_view_model.dart +++ b/lib/src/features/entries/entries_view_model.dart @@ -2,14 +2,14 @@ import 'package:rxdart/rxdart.dart'; import 'package:starter_architecture_flutter_firebase/src/features/entries/daily_jobs_details.dart'; import 'package:starter_architecture_flutter_firebase/src/features/entries/entries_list_tile.dart'; import 'package:starter_architecture_flutter_firebase/src/features/entries/entry_job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/job_entries/format.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; -import 'package:starter_architecture_flutter_firebase/src/services/firestore_database.dart'; class EntriesViewModel { EntriesViewModel({required this.database}); - final FirestoreDatabase database; + final FirestoreRepository database; /// combine List, List into List Stream> get _allEntriesStream => CombineLatestStream.combine2( diff --git a/lib/src/repositories/firestore_service.dart b/lib/src/features/home/data/firestore_data_source.dart similarity index 94% rename from lib/src/repositories/firestore_service.dart rename to lib/src/features/home/data/firestore_data_source.dart index 614ccb0b..10784ed2 100644 --- a/lib/src/repositories/firestore_service.dart +++ b/lib/src/features/home/data/firestore_data_source.dart @@ -1,8 +1,8 @@ import 'package:cloud_firestore/cloud_firestore.dart'; -class FirestoreService { - FirestoreService._(); - static final instance = FirestoreService._(); +class FirestoreDataSource { + FirestoreDataSource._(); + static final instance = FirestoreDataSource._(); Future setData({ required String path, diff --git a/lib/src/services/firestore_database.dart b/lib/src/features/home/data/firestore_repository.dart similarity index 61% rename from lib/src/services/firestore_database.dart rename to lib/src/features/home/data/firestore_repository.dart index b304c50a..a51e587e 100644 --- a/lib/src/services/firestore_database.dart +++ b/lib/src/features/home/data/firestore_repository.dart @@ -1,17 +1,26 @@ import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; -import 'package:starter_architecture_flutter_firebase/src/repositories/firestore_service.dart'; -import 'package:starter_architecture_flutter_firebase/src/services/firestore_path.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_data_source.dart'; String documentIdFromCurrentDate() => DateTime.now().toIso8601String(); -class FirestoreDatabase { - FirestoreDatabase({required this.uid}); +class FirestorePath { + static String job(String uid, String jobId) => 'users/$uid/jobs/$jobId'; + static String jobs(String uid) => 'users/$uid/jobs'; + static String entry(String uid, String entryId) => + 'users/$uid/entries/$entryId'; + static String entries(String uid) => 'users/$uid/entries'; +} + +class FirestoreRepository { + FirestoreRepository({required this.uid}); final String uid; - final _service = FirestoreService.instance; + final _service = FirestoreDataSource.instance; Future setJob(Job job) => _service.setData( path: FirestorePath.job(uid, job.id), @@ -58,3 +67,17 @@ class FirestoreDatabase { sort: (lhs, rhs) => rhs.start.compareTo(lhs.start), ); } + +final databaseProvider = Provider((ref) { + final authStateAsync = ref.watch(authStateChangesProvider); + + if (authStateAsync.value?.uid != null) { + return FirestoreRepository(uid: authStateAsync.value!.uid); + } + throw UnimplementedError(); +}); + +final jobsStreamProvider = StreamProvider.autoDispose>((ref) { + final database = ref.watch(databaseProvider); + return database.jobsStream(); +}); diff --git a/lib/src/features/job_entries/entry_page.dart b/lib/src/features/job_entries/entry_page.dart index 5ba9a302..65423421 100644 --- a/lib/src/features/job_entries/entry_page.dart +++ b/lib/src/features/job_entries/entry_page.dart @@ -3,12 +3,11 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/common_widgets/date_time_picker.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; import 'package:starter_architecture_flutter_firebase/src/features/job_entries/format.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; -import 'package:starter_architecture_flutter_firebase/src/services/firestore_database.dart'; -import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; class EntryPage extends ConsumerStatefulWidget { diff --git a/lib/src/features/job_entries/job_entries_page.dart b/lib/src/features/job_entries/job_entries_page.dart index 43c7adc0..580bbf0f 100644 --- a/lib/src/features/job_entries/job_entries_page.dart +++ b/lib/src/features/job_entries/job_entries_page.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; import 'package:starter_architecture_flutter_firebase/src/features/job_entries/entry_list_item.dart'; @@ -9,7 +10,6 @@ import 'package:starter_architecture_flutter_firebase/src/features/job_entries/e import 'package:starter_architecture_flutter_firebase/src/features/jobs/edit_job_page.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/list_items_builder.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/cupertino_tab_view_router.dart'; -import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; class JobEntriesPage extends StatelessWidget { diff --git a/lib/src/features/jobs/edit_job_page.dart b/lib/src/features/jobs/edit_job_page.dart index 765ab08e..4b97f334 100644 --- a/lib/src/features/jobs/edit_job_page.dart +++ b/lib/src/features/jobs/edit_job_page.dart @@ -2,10 +2,9 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; -import 'package:starter_architecture_flutter_firebase/src/services/firestore_database.dart'; -import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; class EditJobPage extends ConsumerStatefulWidget { diff --git a/lib/src/features/jobs/jobs_page.dart b/lib/src/features/jobs/jobs_page.dart index eb611913..f47d9bfd 100644 --- a/lib/src/features/jobs/jobs_page.dart +++ b/lib/src/features/jobs/jobs_page.dart @@ -3,19 +3,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; import 'package:starter_architecture_flutter_firebase/src/features/job_entries/job_entries_page.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/edit_job_page.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/job_list_tile.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/list_items_builder.dart'; -import 'package:starter_architecture_flutter_firebase/src/top_level_providers.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; -final jobsStreamProvider = StreamProvider.autoDispose>((ref) { - final database = ref.watch(databaseProvider); - return database.jobsStream(); -}); - // watch database class JobsPage extends ConsumerWidget { Future _delete(BuildContext context, WidgetRef ref, Job job) async { diff --git a/lib/src/services/firestore_path.dart b/lib/src/services/firestore_path.dart deleted file mode 100644 index 367a28ea..00000000 --- a/lib/src/services/firestore_path.dart +++ /dev/null @@ -1,7 +0,0 @@ -class FirestorePath { - static String job(String uid, String jobId) => 'users/$uid/jobs/$jobId'; - static String jobs(String uid) => 'users/$uid/jobs'; - static String entry(String uid, String entryId) => - 'users/$uid/entries/$entryId'; - static String entries(String uid) => 'users/$uid/entries'; -} diff --git a/lib/src/top_level_providers.dart b/lib/src/top_level_providers.dart deleted file mode 100644 index 5c589314..00000000 --- a/lib/src/top_level_providers.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:firebase_auth/firebase_auth.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:logger/logger.dart'; -import 'package:starter_architecture_flutter_firebase/src/services/firestore_database.dart'; - -final firebaseAuthProvider = - Provider((ref) => FirebaseAuth.instance); - -final authStateChangesProvider = StreamProvider( - (ref) => ref.watch(firebaseAuthProvider).authStateChanges()); - -final databaseProvider = Provider((ref) { - final authStateAsync = ref.watch(authStateChangesProvider); - - if (authStateAsync.value?.uid != null) { - return FirestoreDatabase(uid: authStateAsync.value!.uid); - } - throw UnimplementedError(); -}); - -final loggerProvider = Provider((ref) => Logger( - printer: PrettyPrinter( - methodCount: 1, - printEmojis: false, - ), - )); diff --git a/lib/src/utils/logger_provider.dart b/lib/src/utils/logger_provider.dart new file mode 100644 index 00000000..d7c7c6d5 --- /dev/null +++ b/lib/src/utils/logger_provider.dart @@ -0,0 +1,9 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logger/logger.dart'; + +final loggerProvider = Provider((ref) => Logger( + printer: PrettyPrinter( + methodCount: 1, + printEmojis: false, + ), + )); From ac47248bba0537844dc05407bb8f331d35f532ba Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Mon, 31 Oct 2022 10:15:22 +0000 Subject: [PATCH 16/45] Pass UserID to all DB methods that require it --- .../authentication/domain/app_user.dart | 3 ++ lib/src/features/entries/entries_page.dart | 7 ++- .../features/entries/entries_view_model.dart | 12 +++-- .../home/data/firestore_data_source.dart | 8 +++- .../home/data/firestore_repository.dart | 48 +++++++++---------- lib/src/features/job_entries/entry_page.dart | 4 +- .../job_entries/job_entries_page.dart | 17 +++++-- lib/src/features/jobs/edit_job_page.dart | 6 ++- lib/src/features/jobs/jobs_page.dart | 9 +++- 9 files changed, 73 insertions(+), 41 deletions(-) diff --git a/lib/src/features/authentication/domain/app_user.dart b/lib/src/features/authentication/domain/app_user.dart index f3d07cd2..87d1d59e 100644 --- a/lib/src/features/authentication/domain/app_user.dart +++ b/lib/src/features/authentication/domain/app_user.dart @@ -1,3 +1,6 @@ +/// Type defining a user ID from Firebase. +typedef UserID = String; + // TODO: FirebaseAppUser? /// Simple class representing the user UID and email. class AppUser { diff --git a/lib/src/features/entries/entries_page.dart b/lib/src/features/entries/entries_page.dart index c3d156cd..f04ca6ba 100644 --- a/lib/src/features/entries/entries_page.dart +++ b/lib/src/features/entries/entries_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/entries/entries_list_tile.dart'; import 'package:starter_architecture_flutter_firebase/src/features/entries/entries_view_model.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; @@ -9,9 +10,13 @@ import 'package:starter_architecture_flutter_firebase/src/features/jobs/list_ite final entriesTileModelStreamProvider = StreamProvider.autoDispose>( (ref) { + final user = ref.watch(authStateChangesProvider).value; + if (user == null) { + throw AssertionError('User can\'t be null when fetching entries'); + } final database = ref.watch(databaseProvider); final vm = EntriesViewModel(database: database); - return vm.entriesTileModelStream; + return vm.entriesTileModelStream(user.uid); }, ); diff --git a/lib/src/features/entries/entries_view_model.dart b/lib/src/features/entries/entries_view_model.dart index a2ae0d5d..0cf9d7f3 100644 --- a/lib/src/features/entries/entries_view_model.dart +++ b/lib/src/features/entries/entries_view_model.dart @@ -1,4 +1,5 @@ import 'package:rxdart/rxdart.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/domain/app_user.dart'; import 'package:starter_architecture_flutter_firebase/src/features/entries/daily_jobs_details.dart'; import 'package:starter_architecture_flutter_firebase/src/features/entries/entries_list_tile.dart'; import 'package:starter_architecture_flutter_firebase/src/features/entries/entry_job.dart'; @@ -12,9 +13,10 @@ class EntriesViewModel { final FirestoreRepository database; /// combine List, List into List - Stream> get _allEntriesStream => CombineLatestStream.combine2( - database.entriesStream(), - database.jobsStream(), + Stream> _allEntriesStream(UserID uid) => + CombineLatestStream.combine2( + database.entriesStream(uid: uid), + database.jobsStream(uid: uid), _entriesJobsCombiner, ); @@ -27,8 +29,8 @@ class EntriesViewModel { } /// Output stream - Stream> get entriesTileModelStream => - _allEntriesStream.map(_createModels); + Stream> entriesTileModelStream(UserID uid) => + _allEntriesStream(uid).map(_createModels); static List _createModels(List allEntries) { if (allEntries.isEmpty) { diff --git a/lib/src/features/home/data/firestore_data_source.dart b/lib/src/features/home/data/firestore_data_source.dart index 10784ed2..ca9526a6 100644 --- a/lib/src/features/home/data/firestore_data_source.dart +++ b/lib/src/features/home/data/firestore_data_source.dart @@ -1,8 +1,8 @@ import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; class FirestoreDataSource { - FirestoreDataSource._(); - static final instance = FirestoreDataSource._(); + const FirestoreDataSource._(); Future setData({ required String path, @@ -57,3 +57,7 @@ class FirestoreDataSource { return snapshots.map((snapshot) => builder(snapshot.data(), snapshot.id)); } } + +final firestoreDataSourceProvider = Provider((ref) { + return FirestoreDataSource._(); +}); diff --git a/lib/src/features/home/data/firestore_repository.dart b/lib/src/features/home/data/firestore_repository.dart index a51e587e..8f06b41e 100644 --- a/lib/src/features/home/data/firestore_repository.dart +++ b/lib/src/features/home/data/firestore_repository.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/domain/app_user.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_data_source.dart'; @@ -17,48 +17,50 @@ class FirestorePath { } class FirestoreRepository { - FirestoreRepository({required this.uid}); - final String uid; + const FirestoreRepository(this._dataSource); + final FirestoreDataSource _dataSource; - final _service = FirestoreDataSource.instance; - - Future setJob(Job job) => _service.setData( + Future setJob({required UserID uid, required Job job}) => + _dataSource.setData( path: FirestorePath.job(uid, job.id), data: job.toMap(), ); - Future deleteJob(Job job) async { + Future deleteJob({required UserID uid, required Job job}) async { // delete where entry.jobId == job.jobId - final allEntries = await entriesStream(job: job).first; + final allEntries = await entriesStream(uid: uid, job: job).first; for (final entry in allEntries) { if (entry.jobId == job.id) { - await deleteEntry(entry); + await deleteEntry(uid: uid, entry: entry); } } // delete job - await _service.deleteData(path: FirestorePath.job(uid, job.id)); + await _dataSource.deleteData(path: FirestorePath.job(uid, job.id)); } - Stream jobStream({required String jobId}) => _service.documentStream( + Stream jobStream({required UserID uid, required String jobId}) => + _dataSource.documentStream( path: FirestorePath.job(uid, jobId), builder: (data, documentId) => Job.fromMap(data, documentId), ); - Stream> jobsStream() => _service.collectionStream( + Stream> jobsStream({required UserID uid}) => + _dataSource.collectionStream( path: FirestorePath.jobs(uid), builder: (data, documentId) => Job.fromMap(data, documentId), ); - Future setEntry(Entry entry) => _service.setData( + Future setEntry({required UserID uid, required Entry entry}) => + _dataSource.setData( path: FirestorePath.entry(uid, entry.id), data: entry.toMap(), ); - Future deleteEntry(Entry entry) => - _service.deleteData(path: FirestorePath.entry(uid, entry.id)); + Future deleteEntry({required UserID uid, required Entry entry}) => + _dataSource.deleteData(path: FirestorePath.entry(uid, entry.id)); - Stream> entriesStream({Job? job}) => - _service.collectionStream( + Stream> entriesStream({required UserID uid, Job? job}) => + _dataSource.collectionStream( path: FirestorePath.entries(uid), queryBuilder: job != null ? (query) => query.where('jobId', isEqualTo: job.id) @@ -69,15 +71,11 @@ class FirestoreRepository { } final databaseProvider = Provider((ref) { - final authStateAsync = ref.watch(authStateChangesProvider); - - if (authStateAsync.value?.uid != null) { - return FirestoreRepository(uid: authStateAsync.value!.uid); - } - throw UnimplementedError(); + return FirestoreRepository(ref.watch(firestoreDataSourceProvider)); }); -final jobsStreamProvider = StreamProvider.autoDispose>((ref) { +final jobsStreamProvider = + StreamProvider.autoDispose.family, UserID>((ref, uid) { final database = ref.watch(databaseProvider); - return database.jobsStream(); + return database.jobsStream(uid: uid); }); diff --git a/lib/src/features/job_entries/entry_page.dart b/lib/src/features/job_entries/entry_page.dart index 65423421..4e3426b9 100644 --- a/lib/src/features/job_entries/entry_page.dart +++ b/lib/src/features/job_entries/entry_page.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/common_widgets/date_time_picker.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; @@ -68,9 +69,10 @@ class _EntryPageState extends ConsumerState { Future _setEntryAndDismiss() async { try { + final currentUser = ref.read(authRepositoryProvider).currentUser!; final database = ref.read(databaseProvider); final entry = _entryFromState(); - await database.setEntry(entry); + await database.setEntry(uid: currentUser.uid, entry: entry); Navigator.of(context).pop(); } catch (e) { unawaited(showExceptionAlertDialog( diff --git a/lib/src/features/job_entries/job_entries_page.dart b/lib/src/features/job_entries/job_entries_page.dart index 580bbf0f..475ae5a3 100644 --- a/lib/src/features/job_entries/job_entries_page.dart +++ b/lib/src/features/job_entries/job_entries_page.dart @@ -12,6 +12,8 @@ import 'package:starter_architecture_flutter_firebase/src/features/jobs/list_ite import 'package:starter_architecture_flutter_firebase/src/routing/cupertino_tab_view_router.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; +import '../authentication/data/firebase_auth_repository.dart'; + class JobEntriesPage extends StatelessWidget { const JobEntriesPage({required this.job}); final Job job; @@ -54,8 +56,12 @@ class JobEntriesPage extends StatelessWidget { final jobStreamProvider = StreamProvider.autoDispose.family((ref, jobId) { + final user = ref.watch(authStateChangesProvider).value; + if (user == null) { + throw AssertionError('User can\'t be null when fetching jobs'); + } final database = ref.watch(databaseProvider); - return database.jobStream(jobId: jobId); + return database.jobStream(uid: user.uid, jobId: jobId); }); class JobEntriesAppBarTitle extends ConsumerWidget { @@ -75,8 +81,12 @@ class JobEntriesAppBarTitle extends ConsumerWidget { final jobEntriesStreamProvider = StreamProvider.autoDispose.family, Job>((ref, job) { + final user = ref.watch(authStateChangesProvider).value; + if (user == null) { + throw AssertionError('User can\'t be null when fetching jobs'); + } final database = ref.watch(databaseProvider); - return database.entriesStream(job: job); + return database.entriesStream(uid: user.uid, job: job); }); class JobEntriesContents extends ConsumerWidget { @@ -86,8 +96,9 @@ class JobEntriesContents extends ConsumerWidget { Future _deleteEntry( BuildContext context, WidgetRef ref, Entry entry) async { try { + final currentUser = ref.read(authRepositoryProvider).currentUser!; final database = ref.read(databaseProvider); - await database.deleteEntry(entry); + await database.deleteEntry(uid: currentUser.uid, entry: entry); } catch (e) { unawaited(showExceptionAlertDialog( context: context, diff --git a/lib/src/features/jobs/edit_job_page.dart b/lib/src/features/jobs/edit_job_page.dart index 4b97f334..7e95e983 100644 --- a/lib/src/features/jobs/edit_job_page.dart +++ b/lib/src/features/jobs/edit_job_page.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; @@ -49,8 +50,9 @@ class _EditJobPageState extends ConsumerState { Future _submit() async { if (_validateAndSaveForm()) { try { + final currentUser = ref.read(authRepositoryProvider).currentUser!; final database = ref.read(databaseProvider); - final jobs = await database.jobsStream().first; + final jobs = await database.jobsStream(uid: currentUser.uid).first; final allLowerCaseNames = jobs.map((job) => job.name.toLowerCase()).toList(); if (widget.job != null) { @@ -67,7 +69,7 @@ class _EditJobPageState extends ConsumerState { final id = widget.job?.id ?? documentIdFromCurrentDate(); final job = Job(id: id, name: _name ?? '', ratePerHour: _ratePerHour ?? 0); - await database.setJob(job); + await database.setJob(uid: currentUser.uid, job: job); Navigator.of(context).pop(); } } catch (e) { diff --git a/lib/src/features/jobs/jobs_page.dart b/lib/src/features/jobs/jobs_page.dart index f47d9bfd..7af09dcb 100644 --- a/lib/src/features/jobs/jobs_page.dart +++ b/lib/src/features/jobs/jobs_page.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; import 'package:starter_architecture_flutter_firebase/src/features/job_entries/job_entries_page.dart'; @@ -15,7 +16,10 @@ import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.da class JobsPage extends ConsumerWidget { Future _delete(BuildContext context, WidgetRef ref, Job job) async { try { - await ref.read(databaseProvider).deleteJob(job); + final currentUser = ref.read(authRepositoryProvider).currentUser!; + await ref + .read(databaseProvider) + .deleteJob(uid: currentUser.uid, job: job); } catch (e) { unawaited(showExceptionAlertDialog( context: context, @@ -39,7 +43,8 @@ class JobsPage extends ConsumerWidget { ), body: Consumer( builder: (context, ref, child) { - final jobsAsyncValue = ref.watch(jobsStreamProvider); + final user = ref.watch(authStateChangesProvider).value!; + final jobsAsyncValue = ref.watch(jobsStreamProvider(user.uid)); return ListItemsBuilder( data: jobsAsyncValue, itemBuilder: (context, job) => Dismissible( From 036045bd230289bf9f3e3d41e2d5ce28d3553f83 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Mon, 31 Oct 2022 14:59:08 +0000 Subject: [PATCH 17/45] Start implementing GoRouter --- .../presentation/sign_in/sign_in_screen.dart | 12 +- .../home/cupertino_home_scaffold.dart | 110 +++++----- .../home/data/firestore_repository.dart | 32 ++- lib/src/features/home/home_page.dart | 94 ++++---- lib/src/features/home/models/job.dart | 4 +- lib/src/features/home/tab_item.dart | 56 ++--- .../job_entries/job_entries_page.dart | 75 ++++--- lib/src/features/jobs/edit_job_page.dart | 11 +- lib/src/features/jobs/jobs_page.dart | 16 +- lib/src/routing/app_router.dart | 203 ++++++++++++++---- .../routing/cupertino_tab_view_router.dart | 23 -- lib/src/routing/go_router_refresh_stream.dart | 21 ++ lib/src/routing/not_found_screen.dart | 18 ++ .../routing/scaffold_with_bottom_nav_bar.dart | 70 ++++++ pubspec.lock | 9 +- pubspec.yaml | 1 + 16 files changed, 497 insertions(+), 258 deletions(-) delete mode 100644 lib/src/routing/cupertino_tab_view_router.dart create mode 100644 lib/src/routing/go_router_refresh_stream.dart create mode 100644 lib/src/routing/not_found_screen.dart create mode 100644 lib/src/routing/scaffold_with_bottom_nav_bar.dart diff --git a/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart b/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart index 544d0602..68f52ae9 100644 --- a/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart +++ b/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/keys.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/sign_in/sign_in_button.dart'; @@ -11,6 +12,7 @@ import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dar import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; class SignInScreen extends ConsumerWidget { + const SignInScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { ref.listen(signInModelProvider, (prev, model) async { @@ -40,14 +42,6 @@ class SignInPageContents extends StatelessWidget { static const Key emailPasswordButtonKey = Key(Keys.emailPassword); static const Key anonymousButtonKey = Key(Keys.anonymous); - Future _showEmailPasswordSignInPage(BuildContext context) async { - final navigator = Navigator.of(context); - await navigator.pushNamed( - AppRoutes.emailPasswordSignInPage, - arguments: () => navigator.pop(), - ); - } - @override Widget build(BuildContext context) { return Scaffold( @@ -94,7 +88,7 @@ class SignInPageContents extends StatelessWidget { text: Strings.signInWithEmailPassword, onPressed: viewModel.isLoading ? null - : () => _showEmailPasswordSignInPage(context), + : () => context.goNamed(AppRoute.emailPassword.name), textColor: Colors.white, color: Theme.of(context).primaryColor, ), diff --git a/lib/src/features/home/cupertino_home_scaffold.dart b/lib/src/features/home/cupertino_home_scaffold.dart index a01097b7..20dd7fbd 100644 --- a/lib/src/features/home/cupertino_home_scaffold.dart +++ b/lib/src/features/home/cupertino_home_scaffold.dart @@ -1,59 +1,59 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:starter_architecture_flutter_firebase/src/constants/keys.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/tab_item.dart'; -import 'package:starter_architecture_flutter_firebase/src/routing/cupertino_tab_view_router.dart'; +// import 'package:flutter/cupertino.dart'; +// import 'package:flutter/material.dart'; +// import 'package:starter_architecture_flutter_firebase/src/constants/keys.dart'; +// import 'package:starter_architecture_flutter_firebase/src/features/home/tab_item.dart'; +// import 'package:starter_architecture_flutter_firebase/src/routing/cupertino_tab_view_router.dart'; -@immutable -class CupertinoHomeScaffold extends StatelessWidget { - const CupertinoHomeScaffold({ - Key? key, - required this.currentTab, - required this.onSelectTab, - required this.widgetBuilders, - required this.navigatorKeys, - }) : super(key: key); +// @immutable +// class CupertinoHomeScaffold extends StatelessWidget { +// const CupertinoHomeScaffold({ +// Key? key, +// required this.currentTab, +// required this.onSelectTab, +// required this.widgetBuilders, +// required this.navigatorKeys, +// }) : super(key: key); - final TabItem currentTab; - final ValueChanged onSelectTab; - final Map widgetBuilders; - final Map> navigatorKeys; +// final TabItem currentTab; +// final ValueChanged onSelectTab; +// final Map widgetBuilders; +// final Map> navigatorKeys; - @override - Widget build(BuildContext context) { - return CupertinoTabScaffold( - tabBar: CupertinoTabBar( - key: const Key(Keys.tabBar), - currentIndex: currentTab.index, - activeColor: Colors.indigo, - items: [ - _buildItem(TabItem.jobs), - _buildItem(TabItem.entries), - _buildItem(TabItem.account), - ], - onTap: (index) => onSelectTab(TabItem.values[index]), - ), - tabBuilder: (context, index) { - final item = TabItem.values[index]; - return CupertinoTabView( - navigatorKey: navigatorKeys[item], - builder: (context) => widgetBuilders[item]!(context), - onGenerateRoute: CupertinoTabViewRouter.generateRoute, - ); - }, - ); - } +// @override +// Widget build(BuildContext context) { +// return CupertinoTabScaffold( +// tabBar: CupertinoTabBar( +// key: const Key(Keys.tabBar), +// currentIndex: currentTab.index, +// activeColor: Colors.indigo, +// items: [ +// _buildItem(TabItem.jobs), +// _buildItem(TabItem.entries), +// _buildItem(TabItem.account), +// ], +// onTap: (index) => onSelectTab(TabItem.values[index]), +// ), +// tabBuilder: (context, index) { +// final item = TabItem.values[index]; +// return CupertinoTabView( +// navigatorKey: navigatorKeys[item], +// builder: (context) => widgetBuilders[item]!(context), +// onGenerateRoute: CupertinoTabViewRouter.generateRoute, +// ); +// }, +// ); +// } - BottomNavigationBarItem _buildItem(TabItem tabItem) { - final itemData = TabItemData.allTabs[tabItem]!; - final color = currentTab == tabItem ? Colors.indigo : Colors.grey; - return BottomNavigationBarItem( - icon: Icon( - itemData.icon, - key: Key(itemData.key), - color: color, - ), - label: itemData.title, - ); - } -} +// BottomNavigationBarItem _buildItem(TabItem tabItem) { +// final itemData = TabItemData.allTabs[tabItem]!; +// final color = currentTab == tabItem ? Colors.indigo : Colors.grey; +// return BottomNavigationBarItem( +// icon: Icon( +// itemData.icon, +// key: Key(itemData.key), +// color: color, +// ), +// label: itemData.title, +// ); +// } +// } diff --git a/lib/src/features/home/data/firestore_repository.dart b/lib/src/features/home/data/firestore_repository.dart index 8f06b41e..7adec6d7 100644 --- a/lib/src/features/home/data/firestore_repository.dart +++ b/lib/src/features/home/data/firestore_repository.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/domain/app_user.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; @@ -38,7 +39,7 @@ class FirestoreRepository { await _dataSource.deleteData(path: FirestorePath.job(uid, job.id)); } - Stream jobStream({required UserID uid, required String jobId}) => + Stream jobStream({required UserID uid, required JobID jobId}) => _dataSource.documentStream( path: FirestorePath.job(uid, jobId), builder: (data, documentId) => Job.fromMap(data, documentId), @@ -74,8 +75,31 @@ final databaseProvider = Provider((ref) { return FirestoreRepository(ref.watch(firestoreDataSourceProvider)); }); -final jobsStreamProvider = - StreamProvider.autoDispose.family, UserID>((ref, uid) { +final jobsStreamProvider = StreamProvider.autoDispose>((ref) { + final user = ref.watch(authStateChangesProvider).value; + if (user == null) { + throw AssertionError('User can\'t be null'); + } + final database = ref.watch(databaseProvider); + return database.jobsStream(uid: user.uid); +}); + +final jobStreamProvider = + StreamProvider.autoDispose.family((ref, jobId) { + final user = ref.watch(authStateChangesProvider).value; + if (user == null) { + throw AssertionError('User can\'t be null'); + } + final database = ref.watch(databaseProvider); + return database.jobStream(uid: user.uid, jobId: jobId); +}); + +final jobEntriesStreamProvider = + StreamProvider.autoDispose.family, Job>((ref, job) { + final user = ref.watch(authStateChangesProvider).value; + if (user == null) { + throw AssertionError('User can\'t be null when fetching jobs'); + } final database = ref.watch(databaseProvider); - return database.jobsStream(uid: uid); + return database.entriesStream(uid: user.uid, job: job); }); diff --git a/lib/src/features/home/home_page.dart b/lib/src/features/home/home_page.dart index 97105967..beec97f8 100644 --- a/lib/src/features/home/home_page.dart +++ b/lib/src/features/home/home_page.dart @@ -1,53 +1,53 @@ -import 'package:flutter/material.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/account/account_screen.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/entries/entries_page.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/cupertino_home_scaffold.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/tab_item.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/jobs_page.dart'; +// import 'package:flutter/material.dart'; +// import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/account/account_screen.dart'; +// import 'package:starter_architecture_flutter_firebase/src/features/entries/entries_page.dart'; +// import 'package:starter_architecture_flutter_firebase/src/features/home/cupertino_home_scaffold.dart'; +// import 'package:starter_architecture_flutter_firebase/src/features/home/tab_item.dart'; +// import 'package:starter_architecture_flutter_firebase/src/features/jobs/jobs_page.dart'; -class HomePage extends StatefulWidget { - @override - _HomePageState createState() => _HomePageState(); -} +// class HomePage extends StatefulWidget { +// @override +// _HomePageState createState() => _HomePageState(); +// } -class _HomePageState extends State { - TabItem _currentTab = TabItem.jobs; +// class _HomePageState extends State { +// TabItem _currentTab = TabItem.jobs; - final Map> navigatorKeys = { - TabItem.jobs: GlobalKey(), - TabItem.entries: GlobalKey(), - TabItem.account: GlobalKey(), - }; +// final Map> navigatorKeys = { +// TabItem.jobs: GlobalKey(), +// TabItem.entries: GlobalKey(), +// TabItem.account: GlobalKey(), +// }; - Map get widgetBuilders { - return { - TabItem.jobs: (_) => JobsPage(), - TabItem.entries: (_) => EntriesPage(), - TabItem.account: (_) => AccountScreen(), - }; - } +// Map get widgetBuilders { +// return { +// TabItem.jobs: (_) => JobsPage(), +// TabItem.entries: (_) => EntriesPage(), +// TabItem.account: (_) => AccountScreen(), +// }; +// } - void _select(TabItem tabItem) { - if (tabItem == _currentTab) { - // pop to first route - navigatorKeys[tabItem]!.currentState?.popUntil((route) => route.isFirst); - } else { - setState(() => _currentTab = tabItem); - } - } +// void _select(TabItem tabItem) { +// if (tabItem == _currentTab) { +// // pop to first route +// navigatorKeys[tabItem]!.currentState?.popUntil((route) => route.isFirst); +// } else { +// setState(() => _currentTab = tabItem); +// } +// } - @override - Widget build(BuildContext context) { - return WillPopScope( - onWillPop: () async => - !(await navigatorKeys[_currentTab]!.currentState?.maybePop() ?? - false), - child: CupertinoHomeScaffold( - currentTab: _currentTab, - onSelectTab: _select, - widgetBuilders: widgetBuilders, - navigatorKeys: navigatorKeys, - ), - ); - } -} +// @override +// Widget build(BuildContext context) { +// return WillPopScope( +// onWillPop: () async => +// !(await navigatorKeys[_currentTab]!.currentState?.maybePop() ?? +// false), +// child: CupertinoHomeScaffold( +// currentTab: _currentTab, +// onSelectTab: _select, +// widgetBuilders: widgetBuilders, +// navigatorKeys: navigatorKeys, +// ), +// ); +// } +// } diff --git a/lib/src/features/home/models/job.dart b/lib/src/features/home/models/job.dart index 3f2a0fac..8350b3b2 100644 --- a/lib/src/features/home/models/job.dart +++ b/lib/src/features/home/models/job.dart @@ -1,10 +1,12 @@ import 'package:equatable/equatable.dart'; import 'package:meta/meta.dart'; +typedef JobID = String; + @immutable class Job extends Equatable { const Job({required this.id, required this.name, required this.ratePerHour}); - final String id; + final JobID id; final String name; final int ratePerHour; diff --git a/lib/src/features/home/tab_item.dart b/lib/src/features/home/tab_item.dart index 256dfd1f..ec92a8bd 100644 --- a/lib/src/features/home/tab_item.dart +++ b/lib/src/features/home/tab_item.dart @@ -1,32 +1,32 @@ -import 'package:flutter/material.dart'; -import 'package:starter_architecture_flutter_firebase/src/constants/keys.dart'; -import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; +// import 'package:flutter/material.dart'; +// import 'package:starter_architecture_flutter_firebase/src/constants/keys.dart'; +// import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; -enum TabItem { jobs, entries, account } +// enum TabItem { jobs, entries, account } -class TabItemData { - const TabItemData( - {required this.key, required this.title, required this.icon}); +// class TabItemData { +// const TabItemData( +// {required this.key, required this.title, required this.icon}); - final String key; - final String title; - final IconData icon; +// final String key; +// final String title; +// final IconData icon; - static const Map allTabs = { - TabItem.jobs: TabItemData( - key: Keys.jobsTab, - title: Strings.jobs, - icon: Icons.work, - ), - TabItem.entries: TabItemData( - key: Keys.entriesTab, - title: Strings.entries, - icon: Icons.view_headline, - ), - TabItem.account: TabItemData( - key: Keys.accountTab, - title: Strings.account, - icon: Icons.person, - ), - }; -} +// static const Map allTabs = { +// TabItem.jobs: TabItemData( +// key: Keys.jobsTab, +// title: Strings.jobs, +// icon: Icons.work, +// ), +// TabItem.entries: TabItemData( +// key: Keys.entriesTab, +// title: Strings.entries, +// icon: Icons.view_headline, +// ), +// TabItem.account: TabItemData( +// key: Keys.accountTab, +// title: Strings.account, +// icon: Icons.person, +// ), +// }; +// } diff --git a/lib/src/features/job_entries/job_entries_page.dart b/lib/src/features/job_entries/job_entries_page.dart index 475ae5a3..1e47828b 100644 --- a/lib/src/features/job_entries/job_entries_page.dart +++ b/lib/src/features/job_entries/job_entries_page.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; @@ -9,21 +10,48 @@ import 'package:starter_architecture_flutter_firebase/src/features/job_entries/e import 'package:starter_architecture_flutter_firebase/src/features/job_entries/entry_page.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/edit_job_page.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/list_items_builder.dart'; -import 'package:starter_architecture_flutter_firebase/src/routing/cupertino_tab_view_router.dart'; +import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; import '../authentication/data/firebase_auth_repository.dart'; -class JobEntriesPage extends StatelessWidget { - const JobEntriesPage({required this.job}); - final Job job; +class JobEntriesPage extends ConsumerWidget { + const JobEntriesPage({required this.jobId, this.job}); + final JobID jobId; + final Job? job; - static Future show(BuildContext context, Job job) async { - await Navigator.of(context).pushNamed( - CupertinoTabViewRoutes.jobEntriesPage, - arguments: job, - ); + @override + Widget build(BuildContext context, WidgetRef ref) { + if (job != null) { + return JobEntriesPageContents(job: job!); + } else { + final jobAsync = ref.watch(jobStreamProvider(jobId)); + return jobAsync.when( + error: (e, st) => Scaffold( + appBar: AppBar( + elevation: 2.0, + title: Text('Error'), + centerTitle: true, + ), + body: Center(child: Text(e.toString())), + ), + loading: () => Scaffold( + appBar: AppBar( + elevation: 2.0, + title: Text('Loading'), + centerTitle: true, + ), + body: Center(child: CircularProgressIndicator()), + ), + data: (job) => JobEntriesPageContents(job: job), + ); + } } +} + +class JobEntriesPageContents extends StatelessWidget { + const JobEntriesPageContents({super.key, required this.job}); + final Job job; @override Widget build(BuildContext context) { @@ -35,7 +63,8 @@ class JobEntriesPage extends StatelessWidget { actions: [ IconButton( icon: const Icon(Icons.edit, color: Colors.white), - onPressed: () => EditJobPage.show( + onPressed: () => context.goNamed(AppRoute.editJob.name, params: { 'id': job.id }) + EditJobPage.show( context, job: job, ), @@ -49,21 +78,11 @@ class JobEntriesPage extends StatelessWidget { ), ], ), - body: JobEntriesContents(job: job), + body: JobEntriesList(job: job), ); } } -final jobStreamProvider = - StreamProvider.autoDispose.family((ref, jobId) { - final user = ref.watch(authStateChangesProvider).value; - if (user == null) { - throw AssertionError('User can\'t be null when fetching jobs'); - } - final database = ref.watch(databaseProvider); - return database.jobStream(uid: user.uid, jobId: jobId); -}); - class JobEntriesAppBarTitle extends ConsumerWidget { const JobEntriesAppBarTitle({required this.job}); final Job job; @@ -79,19 +98,9 @@ class JobEntriesAppBarTitle extends ConsumerWidget { } } -final jobEntriesStreamProvider = - StreamProvider.autoDispose.family, Job>((ref, job) { - final user = ref.watch(authStateChangesProvider).value; - if (user == null) { - throw AssertionError('User can\'t be null when fetching jobs'); - } - final database = ref.watch(databaseProvider); - return database.entriesStream(uid: user.uid, job: job); -}); - -class JobEntriesContents extends ConsumerWidget { +class JobEntriesList extends ConsumerWidget { final Job job; - const JobEntriesContents({required this.job}); + const JobEntriesList({required this.job}); Future _deleteEntry( BuildContext context, WidgetRef ref, Entry entry) async { diff --git a/lib/src/features/jobs/edit_job_page.dart b/lib/src/features/jobs/edit_job_page.dart index 7e95e983..f6265968 100644 --- a/lib/src/features/jobs/edit_job_page.dart +++ b/lib/src/features/jobs/edit_job_page.dart @@ -5,20 +5,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; -import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; class EditJobPage extends ConsumerStatefulWidget { - const EditJobPage({Key? key, this.job}) : super(key: key); + const EditJobPage({Key? key, this.jobId, this.job}) : super(key: key); + final JobID? jobId; final Job? job; - static Future show(BuildContext context, {Job? job}) async { - await Navigator.of(context, rootNavigator: true).pushNamed( - AppRoutes.editJobPage, - arguments: job, - ); - } - @override ConsumerState createState() => _EditJobPageState(); } diff --git a/lib/src/features/jobs/jobs_page.dart b/lib/src/features/jobs/jobs_page.dart index 7af09dcb..2f017c07 100644 --- a/lib/src/features/jobs/jobs_page.dart +++ b/lib/src/features/jobs/jobs_page.dart @@ -2,17 +2,16 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/job_entries/job_entries_page.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/edit_job_page.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/job_list_tile.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/list_items_builder.dart'; +import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; -// watch database class JobsPage extends ConsumerWidget { Future _delete(BuildContext context, WidgetRef ref, Job job) async { try { @@ -37,14 +36,13 @@ class JobsPage extends ConsumerWidget { actions: [ IconButton( icon: const Icon(Icons.add, color: Colors.white), - onPressed: () => EditJobPage.show(context), + onPressed: () => context.goNamed(AppRoute.addJob.name), ), ], ), body: Consumer( builder: (context, ref, child) { - final user = ref.watch(authStateChangesProvider).value!; - final jobsAsyncValue = ref.watch(jobsStreamProvider(user.uid)); + final jobsAsyncValue = ref.watch(jobsStreamProvider); return ListItemsBuilder( data: jobsAsyncValue, itemBuilder: (context, job) => Dismissible( @@ -54,7 +52,11 @@ class JobsPage extends ConsumerWidget { onDismissed: (direction) => _delete(context, ref, job), child: JobListTile( job: job, - onTap: () => JobEntriesPage.show(context, job), + onTap: () => context.goNamed( + AppRoute.job.name, + params: {'id': job.id}, + extra: job, + ), ), ), ); diff --git a/lib/src/routing/app_router.dart b/lib/src/routing/app_router.dart index d14a015a..b3647c7d 100644 --- a/lib/src/routing/app_router.dart +++ b/lib/src/routing/app_router.dart @@ -1,50 +1,171 @@ -import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/email_password/email_password_sign_in_form_type.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/email_password/email_password_sign_in_screen.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/sign_in/sign_in_screen.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/job_entries/entry_page.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/job_entries/job_entries_page.dart'; +import 'package:go_router/go_router.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/edit_job_page.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/jobs_page.dart'; +import 'package:starter_architecture_flutter_firebase/src/routing/go_router_refresh_stream.dart'; +import 'package:starter_architecture_flutter_firebase/src/routing/scaffold_with_bottom_nav_bar.dart'; -class AppRoutes { - static const emailPasswordSignInPage = '/email-password-sign-in-page'; - static const editJobPage = '/edit-job-page'; - static const entryPage = '/entry-page'; +// ignore: avoid_classes_with_only_static_members +// class AppRouter { +// static Route? onGenerateRoute( +// RouteSettings settings, FirebaseAuth firebaseAuth) { +// final args = settings.arguments; +// switch (settings.name) { +// case AppRoutes.emailPasswordSignInPage: +// return MaterialPageRoute( +// builder: (_) => const EmailPasswordSignInScreen( +// formType: EmailPasswordSignInFormType.signIn, +// ), +// settings: settings, +// fullscreenDialog: true, +// ); +// case AppRoutes.editJobPage: +// return MaterialPageRoute( +// builder: (_) => EditJobPage(job: args as Job?), +// settings: settings, +// fullscreenDialog: true, +// ); +// case AppRoutes.entryPage: +// final mapArgs = args as Map; +// final job = mapArgs['job'] as Job; +// final entry = mapArgs['entry'] as Entry?; +// return MaterialPageRoute( +// builder: (_) => EntryPage(job: job, entry: entry), +// settings: settings, +// fullscreenDialog: true, +// ); +// default: +// // TODO: Throw +// return null; +// } +// } +// } + +// private navigators +final _rootNavigatorKey = GlobalKey(); +final _shellNavigatorKey = GlobalKey(); + +enum AppRoute { + signIn, + emailPassword, + jobs, + job, + addJob, + editJob, + entry, + editEntry, + entries, + account, } -// ignore: avoid_classes_with_only_static_members -class AppRouter { - static Route? onGenerateRoute( - RouteSettings settings, FirebaseAuth firebaseAuth) { - final args = settings.arguments; - switch (settings.name) { - case AppRoutes.emailPasswordSignInPage: - return MaterialPageRoute( - builder: (_) => const EmailPasswordSignInScreen( - formType: EmailPasswordSignInFormType.signIn, +final goRouterProvider = Provider((ref) { + final authRepository = ref.watch(authRepositoryProvider); + return GoRouter( + initialLocation: '/', + navigatorKey: _rootNavigatorKey, + debugLogDiagnostics: false, + redirect: (context, state) { + final isLoggedIn = authRepository.currentUser != null; + if (isLoggedIn) { + if (state.location == '/signIn') { + return '/'; + } + } else { + // TODO + if (state.location == '/account' || state.location == '/orders') { + return '/'; + } + } + return null; + }, + refreshListenable: GoRouterRefreshStream(authRepository.authStateChanges()), + routes: [ + GoRoute( + path: '/signIn', + name: AppRoute.signIn.name, + builder: (context, state) => const SignInScreen(), + routes: [ + GoRoute( + path: 'emailPassword', + name: AppRoute.emailPassword.name, + pageBuilder: (context, state) => MaterialPage( + key: state.pageKey, + fullscreenDialog: true, + child: EmailPasswordSignInScreen( + formType: EmailPasswordSignInFormType.signIn, + ), + ), ), - settings: settings, - fullscreenDialog: true, - ); - case AppRoutes.editJobPage: - return MaterialPageRoute( - builder: (_) => EditJobPage(job: args as Job?), - settings: settings, - fullscreenDialog: true, - ); - case AppRoutes.entryPage: - final mapArgs = args as Map; - final job = mapArgs['job'] as Job; - final entry = mapArgs['entry'] as Entry?; - return MaterialPageRoute( - builder: (_) => EntryPage(job: job, entry: entry), - settings: settings, - fullscreenDialog: true, - ); - default: - // TODO: Throw - return null; - } - } -} + ], + ), + ShellRoute( + navigatorKey: _shellNavigatorKey, + builder: (context, state, child) { + return ScaffoldWithBottomNavBar(child: child); + }, + routes: [ + GoRoute( + path: 'jobs', + name: AppRoute.jobs.name, + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: JobsPage(), + ), + routes: [ + GoRoute( + path: ':id', + name: AppRoute.job.name, + pageBuilder: (context, state) { + final id = state.params['id']!; + final job = state.extra as Job?; + return MaterialPage( + key: state.pageKey, + fullscreenDialog: true, + child: JobEntriesPage( + jobId: id, + job: job, + ), + ); + }, + routes: [ + GoRoute( + path: 'edit', + name: AppRoute.editJob.name, + pageBuilder: (context, state) { + final jobId = state.params['id']; + final job = state.extra as Job?; + return MaterialPage( + key: state.pageKey, + fullscreenDialog: true, + child: EditJobPage(jobId: jobId, job: job), + ); + }, + ), + ], + ), + GoRoute( + path: 'add', + name: AppRoute.addJob.name, + pageBuilder: (context, state) { + return MaterialPage( + key: state.pageKey, + fullscreenDialog: true, + child: EditJobPage(), + ); + }, + ), + ], + ), + ], + ), + ], + //errorBuilder: (context, state) => const NotFoundScreen(), + ); +}); diff --git a/lib/src/routing/cupertino_tab_view_router.dart b/lib/src/routing/cupertino_tab_view_router.dart deleted file mode 100644 index bb293341..00000000 --- a/lib/src/routing/cupertino_tab_view_router.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/job_entries/job_entries_page.dart'; - -class CupertinoTabViewRoutes { - static const jobEntriesPage = '/job-entries-page'; -} - -// ignore:avoid_classes_with_only_static_members -class CupertinoTabViewRouter { - static Route? generateRoute(RouteSettings settings) { - switch (settings.name) { - case CupertinoTabViewRoutes.jobEntriesPage: - final job = settings.arguments as Job; - return CupertinoPageRoute( - builder: (_) => JobEntriesPage(job: job), - settings: settings, - fullscreenDialog: false, - ); - } - return null; - } -} diff --git a/lib/src/routing/go_router_refresh_stream.dart b/lib/src/routing/go_router_refresh_stream.dart new file mode 100644 index 00000000..8c9c433b --- /dev/null +++ b/lib/src/routing/go_router_refresh_stream.dart @@ -0,0 +1,21 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +/// This class was imported from the migration guide for GoRouter 5.0 +class GoRouterRefreshStream extends ChangeNotifier { + GoRouterRefreshStream(Stream stream) { + notifyListeners(); + _subscription = stream.asBroadcastStream().listen( + (dynamic _) => notifyListeners(), + ); + } + + late final StreamSubscription _subscription; + + @override + void dispose() { + _subscription.cancel(); + super.dispose(); + } +} diff --git a/lib/src/routing/not_found_screen.dart b/lib/src/routing/not_found_screen.dart new file mode 100644 index 00000000..55b38f82 --- /dev/null +++ b/lib/src/routing/not_found_screen.dart @@ -0,0 +1,18 @@ +// import 'package:ecommerce_app/src/localization/string_hardcoded.dart'; +// import 'package:ecommerce_app/src/common_widgets/empty_placeholder_widget.dart'; +// import 'package:flutter/material.dart'; + +// /// Simple not found screen used for 404 errors (page not found on web) +// class NotFoundScreen extends StatelessWidget { +// const NotFoundScreen({super.key}); + +// @override +// Widget build(BuildContext context) { +// return Scaffold( +// appBar: AppBar(), +// body: EmptyPlaceholderWidget( +// message: '404 - Page not found!'.hardcoded, +// ), +// ); +// } +// } diff --git a/lib/src/routing/scaffold_with_bottom_nav_bar.dart b/lib/src/routing/scaffold_with_bottom_nav_bar.dart new file mode 100644 index 00000000..81501fdc --- /dev/null +++ b/lib/src/routing/scaffold_with_bottom_nav_bar.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:starter_architecture_flutter_firebase/src/localization/string_hardcoded.dart'; +import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; + +// This is a temporary implementation +// TODO: Implement a better solution once this PR is merged: +// https://github.com/flutter/packages/pull/2650 +class ScaffoldWithBottomNavBar extends StatefulWidget { + const ScaffoldWithBottomNavBar({Key? key, required this.child}) + : super(key: key); + final Widget child; + + @override + State createState() => + _ScaffoldWithBottomNavBarState(); +} + +class _ScaffoldWithBottomNavBarState extends State { + // used for the currentIndex argument of BottomNavigationBar + int _selectedIndex = 0; + + void _tap(BuildContext context, int index) { + if (index == _selectedIndex) { + // If the tab hasn't changed, do nothing + return; + } + setState(() => _selectedIndex = index); + if (index == 0) { + // Note: this won't remember the previous state of the route + // More info here: + // https://github.com/flutter/flutter/issues/99124 + context.goNamed(AppRoute.jobs.name); + } else if (index == 1) { + context.goNamed(AppRoute.entries.name); + } else if (index == 2) { + context.goNamed(AppRoute.account.name); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: widget.child, + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + // TODO: figure out theming + unselectedItemColor: Colors.grey, + selectedItemColor: Colors.white, + currentIndex: _selectedIndex, + items: [ + // products + BottomNavigationBarItem( + icon: const Icon(Icons.work), + label: 'Jobs'.hardcoded, + ), + BottomNavigationBarItem( + icon: const Icon(Icons.view_headline), + label: 'Entries'.hardcoded, + ), + BottomNavigationBarItem( + icon: const Icon(Icons.person), + label: 'Account'.hardcoded, + ), + ], + onTap: (index) => _tap(context, index), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 5bbae1ba..26c46813 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -310,6 +310,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.0" + go_router: + dependency: "direct main" + description: + name: go_router + url: "https://pub.dartlang.org" + source: hosted + version: "5.1.1" graphs: dependency: transitive description: @@ -786,4 +793,4 @@ packages: version: "3.1.1" sdks: dart: ">=2.18.0 <3.0.0" - flutter: ">=3.0.0" + flutter: ">=3.3.0" diff --git a/pubspec.yaml b/pubspec.yaml index c3a8250f..acc6f5f3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: sdk: flutter flutter_riverpod: 2.0.2 flutter_svg: + go_router: ^5.1.1 intl: logger: rxdart: From f13f602c7856bac80856136eaf626e6489b8f65b Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Mon, 31 Oct 2022 15:03:32 +0000 Subject: [PATCH 18/45] Comment out broken code --- lib/main.dart | 24 +- lib/src/features/job_entries/entry_page.dart | 316 +++++++++--------- .../job_entries/job_entries_page.dart | 36 +- 3 files changed, 178 insertions(+), 198 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index c2b7e389..d053fc24 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,12 +4,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:starter_architecture_flutter_firebase/firebase_options.dart'; -import 'package:starter_architecture_flutter_firebase/src/auth_widget.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/home_page.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/sign_in/sign_in_screen.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/onboarding/presentation/onboarding_controller.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/onboarding/presentation/onboarding_screen.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; import 'package:starter_architecture_flutter_firebase/src/features/onboarding/data/onboarding_repository.dart'; @@ -32,23 +26,11 @@ Future main() async { class MyApp extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final firebaseAuth = ref.read(firebaseAuthProvider); - return MaterialApp( + final goRouter = ref.watch(goRouterProvider); + return MaterialApp.router( + routerConfig: goRouter, theme: ThemeData(primarySwatch: Colors.indigo), debugShowCheckedModeBanner: false, - // TODO: Implement all this with GoRouter - home: AuthWidget( - nonSignedInBuilder: (_) => Consumer( - builder: (context, ref, _) { - final didCompleteOnboarding = - ref.watch(onboardingControllerProvider); - return didCompleteOnboarding ? SignInScreen() : OnboardingScreen(); - }, - ), - signedInBuilder: (_) => HomePage(), - ), - onGenerateRoute: (settings) => - AppRouter.onGenerateRoute(settings, firebaseAuth), ); } } diff --git a/lib/src/features/job_entries/entry_page.dart b/lib/src/features/job_entries/entry_page.dart index 4e3426b9..5e19cd37 100644 --- a/lib/src/features/job_entries/entry_page.dart +++ b/lib/src/features/job_entries/entry_page.dart @@ -1,173 +1,173 @@ -import 'dart:async'; +// import 'dart:async'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/src/common_widgets/date_time_picker.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/job_entries/format.dart'; -import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; -import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; +// import 'package:flutter/material.dart'; +// import 'package:flutter_riverpod/flutter_riverpod.dart'; +// import 'package:starter_architecture_flutter_firebase/src/common_widgets/date_time_picker.dart'; +// import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; +// import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; +// import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; +// import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; +// import 'package:starter_architecture_flutter_firebase/src/features/job_entries/format.dart'; +// import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; +// import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; -class EntryPage extends ConsumerStatefulWidget { - const EntryPage({required this.job, this.entry}); - final Job job; - final Entry? entry; +// class EntryPage extends ConsumerStatefulWidget { +// const EntryPage({required this.job, this.entry}); +// final Job job; +// final Entry? entry; - static Future show( - {required BuildContext context, required Job job, Entry? entry}) async { - await Navigator.of(context, rootNavigator: true).pushNamed( - AppRoutes.entryPage, - arguments: { - 'job': job, - 'entry': entry, - }, - ); - } +// static Future show( +// {required BuildContext context, required Job job, Entry? entry}) async { +// await Navigator.of(context, rootNavigator: true).pushNamed( +// AppRoutes.entryPage, +// arguments: { +// 'job': job, +// 'entry': entry, +// }, +// ); +// } - @override - ConsumerState createState() => _EntryPageState(); -} +// @override +// ConsumerState createState() => _EntryPageState(); +// } -class _EntryPageState extends ConsumerState { - late DateTime _startDate; - late TimeOfDay _startTime; - late DateTime _endDate; - late TimeOfDay _endTime; - late String _comment; +// class _EntryPageState extends ConsumerState { +// late DateTime _startDate; +// late TimeOfDay _startTime; +// late DateTime _endDate; +// late TimeOfDay _endTime; +// late String _comment; - @override - void initState() { - super.initState(); - final start = widget.entry?.start ?? DateTime.now(); - _startDate = DateTime(start.year, start.month, start.day); - _startTime = TimeOfDay.fromDateTime(start); +// @override +// void initState() { +// super.initState(); +// final start = widget.entry?.start ?? DateTime.now(); +// _startDate = DateTime(start.year, start.month, start.day); +// _startTime = TimeOfDay.fromDateTime(start); - final end = widget.entry?.end ?? DateTime.now(); - _endDate = DateTime(end.year, end.month, end.day); - _endTime = TimeOfDay.fromDateTime(end); +// final end = widget.entry?.end ?? DateTime.now(); +// _endDate = DateTime(end.year, end.month, end.day); +// _endTime = TimeOfDay.fromDateTime(end); - _comment = widget.entry?.comment ?? ''; - } +// _comment = widget.entry?.comment ?? ''; +// } - Entry _entryFromState() { - final start = DateTime(_startDate.year, _startDate.month, _startDate.day, - _startTime.hour, _startTime.minute); - final end = DateTime(_endDate.year, _endDate.month, _endDate.day, - _endTime.hour, _endTime.minute); - final id = widget.entry?.id ?? documentIdFromCurrentDate(); - return Entry( - id: id, - jobId: widget.job.id, - start: start, - end: end, - comment: _comment, - ); - } +// Entry _entryFromState() { +// final start = DateTime(_startDate.year, _startDate.month, _startDate.day, +// _startTime.hour, _startTime.minute); +// final end = DateTime(_endDate.year, _endDate.month, _endDate.day, +// _endTime.hour, _endTime.minute); +// final id = widget.entry?.id ?? documentIdFromCurrentDate(); +// return Entry( +// id: id, +// jobId: widget.job.id, +// start: start, +// end: end, +// comment: _comment, +// ); +// } - Future _setEntryAndDismiss() async { - try { - final currentUser = ref.read(authRepositoryProvider).currentUser!; - final database = ref.read(databaseProvider); - final entry = _entryFromState(); - await database.setEntry(uid: currentUser.uid, entry: entry); - Navigator.of(context).pop(); - } catch (e) { - unawaited(showExceptionAlertDialog( - context: context, - title: 'Operation failed', - exception: e, - )); - } - } +// Future _setEntryAndDismiss() async { +// try { +// final currentUser = ref.read(authRepositoryProvider).currentUser!; +// final database = ref.read(databaseProvider); +// final entry = _entryFromState(); +// await database.setEntry(uid: currentUser.uid, entry: entry); +// Navigator.of(context).pop(); +// } catch (e) { +// unawaited(showExceptionAlertDialog( +// context: context, +// title: 'Operation failed', +// exception: e, +// )); +// } +// } - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - elevation: 2.0, - title: Text(widget.job.name), - actions: [ - TextButton( - child: Text( - widget.entry != null ? 'Update' : 'Create', - style: const TextStyle(fontSize: 18.0, color: Colors.white), - ), - onPressed: () => _setEntryAndDismiss(), - ), - ], - ), - body: SingleChildScrollView( - child: Container( - padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildStartDate(), - _buildEndDate(), - const SizedBox(height: 8.0), - _buildDuration(), - const SizedBox(height: 8.0), - _buildComment(), - ], - ), - ), - ), - ); - } +// @override +// Widget build(BuildContext context) { +// return Scaffold( +// appBar: AppBar( +// elevation: 2.0, +// title: Text(widget.job.name), +// actions: [ +// TextButton( +// child: Text( +// widget.entry != null ? 'Update' : 'Create', +// style: const TextStyle(fontSize: 18.0, color: Colors.white), +// ), +// onPressed: () => _setEntryAndDismiss(), +// ), +// ], +// ), +// body: SingleChildScrollView( +// child: Container( +// padding: const EdgeInsets.all(16.0), +// child: Column( +// mainAxisSize: MainAxisSize.min, +// crossAxisAlignment: CrossAxisAlignment.start, +// children: [ +// _buildStartDate(), +// _buildEndDate(), +// const SizedBox(height: 8.0), +// _buildDuration(), +// const SizedBox(height: 8.0), +// _buildComment(), +// ], +// ), +// ), +// ), +// ); +// } - Widget _buildStartDate() { - return DateTimePicker( - labelText: 'Start', - selectedDate: _startDate, - selectedTime: _startTime, - onSelectedDate: (date) => setState(() => _startDate = date), - onSelectedTime: (time) => setState(() => _startTime = time), - ); - } +// Widget _buildStartDate() { +// return DateTimePicker( +// labelText: 'Start', +// selectedDate: _startDate, +// selectedTime: _startTime, +// onSelectedDate: (date) => setState(() => _startDate = date), +// onSelectedTime: (time) => setState(() => _startTime = time), +// ); +// } - Widget _buildEndDate() { - return DateTimePicker( - labelText: 'End', - selectedDate: _endDate, - selectedTime: _endTime, - onSelectedDate: (date) => setState(() => _endDate = date), - onSelectedTime: (time) => setState(() => _endTime = time), - ); - } +// Widget _buildEndDate() { +// return DateTimePicker( +// labelText: 'End', +// selectedDate: _endDate, +// selectedTime: _endTime, +// onSelectedDate: (date) => setState(() => _endDate = date), +// onSelectedTime: (time) => setState(() => _endTime = time), +// ); +// } - Widget _buildDuration() { - final currentEntry = _entryFromState(); - final durationFormatted = Format.hours(currentEntry.durationInHours); - return Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Text( - 'Duration: $durationFormatted', - style: const TextStyle(fontSize: 18.0, fontWeight: FontWeight.w500), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ); - } +// Widget _buildDuration() { +// final currentEntry = _entryFromState(); +// final durationFormatted = Format.hours(currentEntry.durationInHours); +// return Row( +// mainAxisAlignment: MainAxisAlignment.end, +// children: [ +// Text( +// 'Duration: $durationFormatted', +// style: const TextStyle(fontSize: 18.0, fontWeight: FontWeight.w500), +// maxLines: 1, +// overflow: TextOverflow.ellipsis, +// ), +// ], +// ); +// } - Widget _buildComment() { - return TextField( - keyboardType: TextInputType.text, - maxLength: 50, - controller: TextEditingController(text: _comment), - decoration: const InputDecoration( - labelText: 'Comment', - labelStyle: TextStyle(fontSize: 18.0, fontWeight: FontWeight.w500), - ), - keyboardAppearance: Brightness.light, - style: const TextStyle(fontSize: 20.0, color: Colors.black), - maxLines: null, - onChanged: (comment) => _comment = comment, - ); - } -} +// Widget _buildComment() { +// return TextField( +// keyboardType: TextInputType.text, +// maxLength: 50, +// controller: TextEditingController(text: _comment), +// decoration: const InputDecoration( +// labelText: 'Comment', +// labelStyle: TextStyle(fontSize: 18.0, fontWeight: FontWeight.w500), +// ), +// keyboardAppearance: Brightness.light, +// style: const TextStyle(fontSize: 20.0, color: Colors.black), +// maxLines: null, +// onChanged: (comment) => _comment = comment, +// ); +// } +// } diff --git a/lib/src/features/job_entries/job_entries_page.dart b/lib/src/features/job_entries/job_entries_page.dart index 1e47828b..e4096576 100644 --- a/lib/src/features/job_entries/job_entries_page.dart +++ b/lib/src/features/job_entries/job_entries_page.dart @@ -7,8 +7,6 @@ import 'package:starter_architecture_flutter_firebase/src/features/home/data/fir import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; import 'package:starter_architecture_flutter_firebase/src/features/job_entries/entry_list_item.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/job_entries/entry_page.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/edit_job_page.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/list_items_builder.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; @@ -63,19 +61,18 @@ class JobEntriesPageContents extends StatelessWidget { actions: [ IconButton( icon: const Icon(Icons.edit, color: Colors.white), - onPressed: () => context.goNamed(AppRoute.editJob.name, params: { 'id': job.id }) - EditJobPage.show( - context, - job: job, - ), - ), - IconButton( - icon: const Icon(Icons.add, color: Colors.white), - onPressed: () => EntryPage.show( - context: context, - job: job, - ), + // EditJobPage + onPressed: () => + context.goNamed(AppRoute.editJob.name, params: {'id': job.id}), ), + // TODO: Restore + // IconButton( + // icon: const Icon(Icons.add, color: Colors.white), + // onPressed: () => EntryPage.show( + // context: context, + // job: job, + // ), + // ), ], ), body: JobEntriesList(job: job), @@ -128,11 +125,12 @@ class JobEntriesList extends ConsumerWidget { entry: entry, job: job, onDismissed: () => _deleteEntry(context, ref, entry), - onTap: () => EntryPage.show( - context: context, - job: job, - entry: entry, - ), + // TODO: Restore + // onTap: () => EntryPage.show( + // context: context, + // job: job, + // entry: entry, + // ), ); }, ); From 50ec007d6c999fee50978ae6b4bde475c8785741 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Tue, 1 Nov 2022 06:40:14 +0000 Subject: [PATCH 19/45] Move more code to GoRouter --- ios/Podfile.lock | 2 +- lib/main.dart | 9 +- .../presentation/sign_in/sign_in_screen.dart | 1 - lib/src/features/entries/entries_page.dart | 1 - .../home/data/firestore_repository.dart | 5 +- lib/src/features/home/models/entry.dart | 4 +- lib/src/features/job_entries/entry_page.dart | 306 +++++++++--------- .../job_entries/job_entries_page.dart | 45 +-- lib/src/features/jobs/edit_job_page.dart | 3 +- lib/src/routing/app_router.dart | 92 +++++- .../routing/scaffold_with_bottom_nav_bar.dart | 3 - 11 files changed, 263 insertions(+), 208 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 4764a7fd..05c38f0c 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -123,7 +123,7 @@ SPEC CHECKSUMS: FirebaseCore: 2082fffcd855f95f883c0a1641133eb9bbe76d40 FirebaseCoreDiagnostics: 99a495094b10a57eeb3ae8efa1665700ad0bdaa6 FirebaseCoreInternal: bca76517fe1ed381e989f5e7d8abb0da8d85bed3 - FirebaseFirestore: 9bfe2e814686fb3ba2495b97618b38987e4c8526 + FirebaseFirestore: 56251017e7fb2530d39d42724a6772fd78fd734e Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 GoogleDataTransport: 1c8145da7117bd68bbbed00cf304edb6a24de00f GoogleUtilities: 1d20a6ad97ef46f67bbdec158ce00563a671ebb7 diff --git a/lib/main.dart b/lib/main.dart index d053fc24..323ffa3f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -29,7 +29,14 @@ class MyApp extends ConsumerWidget { final goRouter = ref.watch(goRouterProvider); return MaterialApp.router( routerConfig: goRouter, - theme: ThemeData(primarySwatch: Colors.indigo), + theme: ThemeData( + primarySwatch: Colors.indigo, + unselectedWidgetColor: Colors.grey, + appBarTheme: AppBarTheme( + elevation: 2.0, + centerTitle: true, + ), + ), debugShowCheckedModeBanner: false, ); } diff --git a/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart b/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart index 68f52ae9..d43cb5eb 100644 --- a/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart +++ b/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart @@ -46,7 +46,6 @@ class SignInPageContents extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - elevation: 2.0, title: Text(title), ), backgroundColor: Colors.grey[200], diff --git a/lib/src/features/entries/entries_page.dart b/lib/src/features/entries/entries_page.dart index f04ca6ba..cfd314c1 100644 --- a/lib/src/features/entries/entries_page.dart +++ b/lib/src/features/entries/entries_page.dart @@ -26,7 +26,6 @@ class EntriesPage extends ConsumerWidget { return Scaffold( appBar: AppBar( title: const Text(Strings.entries), - elevation: 2.0, ), body: Consumer( builder: (context, ref, child) { diff --git a/lib/src/features/home/data/firestore_repository.dart b/lib/src/features/home/data/firestore_repository.dart index 7adec6d7..13e9b13a 100644 --- a/lib/src/features/home/data/firestore_repository.dart +++ b/lib/src/features/home/data/firestore_repository.dart @@ -7,7 +7,10 @@ import 'package:starter_architecture_flutter_firebase/src/features/home/models/e import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_data_source.dart'; -String documentIdFromCurrentDate() => DateTime.now().toIso8601String(); +String documentIdFromCurrentDate() { + final iso = DateTime.now().toIso8601String(); + return iso.replaceAll(':', '-').replaceAll('.', '-'); +} class FirestorePath { static String job(String uid, String jobId) => 'users/$uid/jobs/$jobId'; diff --git a/lib/src/features/home/models/entry.dart b/lib/src/features/home/models/entry.dart index 56c326a0..5c883fb3 100644 --- a/lib/src/features/home/models/entry.dart +++ b/lib/src/features/home/models/entry.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +typedef EntryID = String; + class Entry extends Equatable { const Entry({ required this.id, @@ -9,7 +11,7 @@ class Entry extends Equatable { required this.comment, }); - final String id; + final EntryID id; final String jobId; final DateTime start; final DateTime end; diff --git a/lib/src/features/job_entries/entry_page.dart b/lib/src/features/job_entries/entry_page.dart index 5e19cd37..7450e8fd 100644 --- a/lib/src/features/job_entries/entry_page.dart +++ b/lib/src/features/job_entries/entry_page.dart @@ -1,173 +1,161 @@ -// import 'dart:async'; +import 'dart:async'; -// import 'package:flutter/material.dart'; -// import 'package:flutter_riverpod/flutter_riverpod.dart'; -// import 'package:starter_architecture_flutter_firebase/src/common_widgets/date_time_picker.dart'; -// import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; -// import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; -// import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; -// import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; -// import 'package:starter_architecture_flutter_firebase/src/features/job_entries/format.dart'; -// import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; -// import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:starter_architecture_flutter_firebase/src/common_widgets/date_time_picker.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/job_entries/format.dart'; +import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; -// class EntryPage extends ConsumerStatefulWidget { -// const EntryPage({required this.job, this.entry}); -// final Job job; -// final Entry? entry; +class EntryPage extends ConsumerStatefulWidget { + const EntryPage({required this.jobId, this.entryId, this.entry}); + final JobID jobId; + final EntryID? entryId; + final Entry? entry; -// static Future show( -// {required BuildContext context, required Job job, Entry? entry}) async { -// await Navigator.of(context, rootNavigator: true).pushNamed( -// AppRoutes.entryPage, -// arguments: { -// 'job': job, -// 'entry': entry, -// }, -// ); -// } + @override + ConsumerState createState() => _EntryPageState(); +} -// @override -// ConsumerState createState() => _EntryPageState(); -// } +class _EntryPageState extends ConsumerState { + late DateTime _startDate; + late TimeOfDay _startTime; + late DateTime _endDate; + late TimeOfDay _endTime; + late String _comment; -// class _EntryPageState extends ConsumerState { -// late DateTime _startDate; -// late TimeOfDay _startTime; -// late DateTime _endDate; -// late TimeOfDay _endTime; -// late String _comment; + @override + void initState() { + super.initState(); + final start = widget.entry?.start ?? DateTime.now(); + _startDate = DateTime(start.year, start.month, start.day); + _startTime = TimeOfDay.fromDateTime(start); -// @override -// void initState() { -// super.initState(); -// final start = widget.entry?.start ?? DateTime.now(); -// _startDate = DateTime(start.year, start.month, start.day); -// _startTime = TimeOfDay.fromDateTime(start); + final end = widget.entry?.end ?? DateTime.now(); + _endDate = DateTime(end.year, end.month, end.day); + _endTime = TimeOfDay.fromDateTime(end); -// final end = widget.entry?.end ?? DateTime.now(); -// _endDate = DateTime(end.year, end.month, end.day); -// _endTime = TimeOfDay.fromDateTime(end); + _comment = widget.entry?.comment ?? ''; + } -// _comment = widget.entry?.comment ?? ''; -// } + Entry _entryFromState() { + final start = DateTime(_startDate.year, _startDate.month, _startDate.day, + _startTime.hour, _startTime.minute); + final end = DateTime(_endDate.year, _endDate.month, _endDate.day, + _endTime.hour, _endTime.minute); + final id = widget.entry?.id ?? documentIdFromCurrentDate(); + return Entry( + id: id, + jobId: widget.jobId, + start: start, + end: end, + comment: _comment, + ); + } -// Entry _entryFromState() { -// final start = DateTime(_startDate.year, _startDate.month, _startDate.day, -// _startTime.hour, _startTime.minute); -// final end = DateTime(_endDate.year, _endDate.month, _endDate.day, -// _endTime.hour, _endTime.minute); -// final id = widget.entry?.id ?? documentIdFromCurrentDate(); -// return Entry( -// id: id, -// jobId: widget.job.id, -// start: start, -// end: end, -// comment: _comment, -// ); -// } + Future _setEntryAndDismiss() async { + try { + final currentUser = ref.read(authRepositoryProvider).currentUser!; + final database = ref.read(databaseProvider); + final entry = _entryFromState(); + await database.setEntry(uid: currentUser.uid, entry: entry); + Navigator.of(context).pop(); + } catch (e) { + unawaited(showExceptionAlertDialog( + context: context, + title: 'Operation failed', + exception: e, + )); + } + } -// Future _setEntryAndDismiss() async { -// try { -// final currentUser = ref.read(authRepositoryProvider).currentUser!; -// final database = ref.read(databaseProvider); -// final entry = _entryFromState(); -// await database.setEntry(uid: currentUser.uid, entry: entry); -// Navigator.of(context).pop(); -// } catch (e) { -// unawaited(showExceptionAlertDialog( -// context: context, -// title: 'Operation failed', -// exception: e, -// )); -// } -// } + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.entry != null ? 'Edit Entry' : 'New Entry'), + actions: [ + TextButton( + child: Text( + widget.entry != null ? 'Update' : 'Create', + style: const TextStyle(fontSize: 18.0, color: Colors.white), + ), + onPressed: () => _setEntryAndDismiss(), + ), + ], + ), + body: SingleChildScrollView( + child: Container( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildStartDate(), + _buildEndDate(), + const SizedBox(height: 8.0), + _buildDuration(), + const SizedBox(height: 8.0), + _buildComment(), + ], + ), + ), + ), + ); + } -// @override -// Widget build(BuildContext context) { -// return Scaffold( -// appBar: AppBar( -// elevation: 2.0, -// title: Text(widget.job.name), -// actions: [ -// TextButton( -// child: Text( -// widget.entry != null ? 'Update' : 'Create', -// style: const TextStyle(fontSize: 18.0, color: Colors.white), -// ), -// onPressed: () => _setEntryAndDismiss(), -// ), -// ], -// ), -// body: SingleChildScrollView( -// child: Container( -// padding: const EdgeInsets.all(16.0), -// child: Column( -// mainAxisSize: MainAxisSize.min, -// crossAxisAlignment: CrossAxisAlignment.start, -// children: [ -// _buildStartDate(), -// _buildEndDate(), -// const SizedBox(height: 8.0), -// _buildDuration(), -// const SizedBox(height: 8.0), -// _buildComment(), -// ], -// ), -// ), -// ), -// ); -// } + Widget _buildStartDate() { + return DateTimePicker( + labelText: 'Start', + selectedDate: _startDate, + selectedTime: _startTime, + onSelectedDate: (date) => setState(() => _startDate = date), + onSelectedTime: (time) => setState(() => _startTime = time), + ); + } -// Widget _buildStartDate() { -// return DateTimePicker( -// labelText: 'Start', -// selectedDate: _startDate, -// selectedTime: _startTime, -// onSelectedDate: (date) => setState(() => _startDate = date), -// onSelectedTime: (time) => setState(() => _startTime = time), -// ); -// } + Widget _buildEndDate() { + return DateTimePicker( + labelText: 'End', + selectedDate: _endDate, + selectedTime: _endTime, + onSelectedDate: (date) => setState(() => _endDate = date), + onSelectedTime: (time) => setState(() => _endTime = time), + ); + } -// Widget _buildEndDate() { -// return DateTimePicker( -// labelText: 'End', -// selectedDate: _endDate, -// selectedTime: _endTime, -// onSelectedDate: (date) => setState(() => _endDate = date), -// onSelectedTime: (time) => setState(() => _endTime = time), -// ); -// } + Widget _buildDuration() { + final currentEntry = _entryFromState(); + final durationFormatted = Format.hours(currentEntry.durationInHours); + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + 'Duration: $durationFormatted', + style: const TextStyle(fontSize: 18.0, fontWeight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ); + } -// Widget _buildDuration() { -// final currentEntry = _entryFromState(); -// final durationFormatted = Format.hours(currentEntry.durationInHours); -// return Row( -// mainAxisAlignment: MainAxisAlignment.end, -// children: [ -// Text( -// 'Duration: $durationFormatted', -// style: const TextStyle(fontSize: 18.0, fontWeight: FontWeight.w500), -// maxLines: 1, -// overflow: TextOverflow.ellipsis, -// ), -// ], -// ); -// } - -// Widget _buildComment() { -// return TextField( -// keyboardType: TextInputType.text, -// maxLength: 50, -// controller: TextEditingController(text: _comment), -// decoration: const InputDecoration( -// labelText: 'Comment', -// labelStyle: TextStyle(fontSize: 18.0, fontWeight: FontWeight.w500), -// ), -// keyboardAppearance: Brightness.light, -// style: const TextStyle(fontSize: 20.0, color: Colors.black), -// maxLines: null, -// onChanged: (comment) => _comment = comment, -// ); -// } -// } + Widget _buildComment() { + return TextField( + keyboardType: TextInputType.text, + maxLength: 50, + controller: TextEditingController(text: _comment), + decoration: const InputDecoration( + labelText: 'Comment', + labelStyle: TextStyle(fontSize: 18.0, fontWeight: FontWeight.w500), + ), + keyboardAppearance: Brightness.light, + style: const TextStyle(fontSize: 20.0, color: Colors.black), + maxLines: null, + onChanged: (comment) => _comment = comment, + ); + } +} diff --git a/lib/src/features/job_entries/job_entries_page.dart b/lib/src/features/job_entries/job_entries_page.dart index e4096576..42d21ede 100644 --- a/lib/src/features/job_entries/job_entries_page.dart +++ b/lib/src/features/job_entries/job_entries_page.dart @@ -27,17 +27,13 @@ class JobEntriesPage extends ConsumerWidget { return jobAsync.when( error: (e, st) => Scaffold( appBar: AppBar( - elevation: 2.0, title: Text('Error'), - centerTitle: true, ), body: Center(child: Text(e.toString())), ), loading: () => Scaffold( appBar: AppBar( - elevation: 2.0, title: Text('Loading'), - centerTitle: true, ), body: Center(child: CircularProgressIndicator()), ), @@ -55,27 +51,29 @@ class JobEntriesPageContents extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - elevation: 2.0, title: JobEntriesAppBarTitle(job: job), - centerTitle: true, actions: [ IconButton( icon: const Icon(Icons.edit, color: Colors.white), // EditJobPage - onPressed: () => - context.goNamed(AppRoute.editJob.name, params: {'id': job.id}), + onPressed: () => context.goNamed( + AppRoute.editJob.name, + params: {'id': job.id}, + extra: job, + ), ), - // TODO: Restore - // IconButton( - // icon: const Icon(Icons.add, color: Colors.white), - // onPressed: () => EntryPage.show( - // context: context, - // job: job, - // ), - // ), ], ), body: JobEntriesList(job: job), + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.add, color: Colors.white), + // EntryPage + onPressed: () => context.goNamed( + AppRoute.addEntry.name, + params: {'id': job.id}, + extra: job, + ), + ), ); } } @@ -125,11 +123,16 @@ class JobEntriesList extends ConsumerWidget { entry: entry, job: job, onDismissed: () => _deleteEntry(context, ref, entry), - // TODO: Restore - // onTap: () => EntryPage.show( - // context: context, - // job: job, - // entry: entry, + onTap: () => context.go( + '/jobs/${job.id}/entries/${entry.id}', + // AppRoute.entry.name, + // params: {'id': job.id, 'eid': entry.id}, + extra: entry, + ), + // onTap: () => context.goNamed( + // AppRoute.entry.name, + // params: {'id': job.id, 'eid': entry.id}, + // extra: entry, // ), ); }, diff --git a/lib/src/features/jobs/edit_job_page.dart b/lib/src/features/jobs/edit_job_page.dart index f6265968..3f884f57 100644 --- a/lib/src/features/jobs/edit_job_page.dart +++ b/lib/src/features/jobs/edit_job_page.dart @@ -8,7 +8,7 @@ import 'package:starter_architecture_flutter_firebase/src/features/home/models/j import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; class EditJobPage extends ConsumerStatefulWidget { - const EditJobPage({Key? key, this.jobId, this.job}) : super(key: key); + const EditJobPage({super.key, this.jobId, this.job}); final JobID? jobId; final Job? job; @@ -79,7 +79,6 @@ class _EditJobPageState extends ConsumerState { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - elevation: 2.0, title: Text(widget.job == null ? 'New Job' : 'Edit Job'), actions: [ TextButton( diff --git a/lib/src/routing/app_router.dart b/lib/src/routing/app_router.dart index b3647c7d..239f3e13 100644 --- a/lib/src/routing/app_router.dart +++ b/lib/src/routing/app_router.dart @@ -1,10 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/account/account_screen.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/email_password/email_password_sign_in_form_type.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/email_password/email_password_sign_in_screen.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/sign_in/sign_in_screen.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/entries/entries_page.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/job_entries/entry_page.dart'; import 'package:starter_architecture_flutter_firebase/src/features/job_entries/job_entries_page.dart'; import 'package:go_router/go_router.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/edit_job_page.dart'; @@ -60,6 +64,7 @@ enum AppRoute { addJob, editJob, entry, + addEntry, editEntry, entries, account, @@ -68,19 +73,19 @@ enum AppRoute { final goRouterProvider = Provider((ref) { final authRepository = ref.watch(authRepositoryProvider); return GoRouter( - initialLocation: '/', + initialLocation: '/signIn', navigatorKey: _rootNavigatorKey, - debugLogDiagnostics: false, + debugLogDiagnostics: true, redirect: (context, state) { final isLoggedIn = authRepository.currentUser != null; if (isLoggedIn) { if (state.location == '/signIn') { - return '/'; + return '/jobs'; } } else { // TODO if (state.location == '/account' || state.location == '/orders') { - return '/'; + return '/signIn'; } } return null; @@ -112,13 +117,25 @@ final goRouterProvider = Provider((ref) { }, routes: [ GoRoute( - path: 'jobs', + path: '/jobs', name: AppRoute.jobs.name, pageBuilder: (context, state) => NoTransitionPage( key: state.pageKey, child: JobsPage(), ), routes: [ + GoRoute( + path: 'add', + name: AppRoute.addJob.name, + parentNavigatorKey: _rootNavigatorKey, + pageBuilder: (context, state) { + return MaterialPage( + key: state.pageKey, + fullscreenDialog: true, + child: EditJobPage(), + ); + }, + ), GoRoute( path: ':id', name: AppRoute.job.name, @@ -127,7 +144,6 @@ final goRouterProvider = Provider((ref) { final job = state.extra as Job?; return MaterialPage( key: state.pageKey, - fullscreenDialog: true, child: JobEntriesPage( jobId: id, job: job, @@ -135,6 +151,43 @@ final goRouterProvider = Provider((ref) { ); }, routes: [ + GoRoute( + path: 'entries/add', + name: AppRoute.addEntry.name, + parentNavigatorKey: _rootNavigatorKey, + pageBuilder: (context, state) { + final jobId = state.params['id']!; + return MaterialPage( + key: state.pageKey, + fullscreenDialog: true, + child: EntryPage( + jobId: jobId, + ), + ); + }, + ), + // TODO: Figure out why this is not reached, and `jobs/:id` is matched instead + GoRoute( + path: 'entries/:eid', + name: AppRoute.entry.name, + parentNavigatorKey: _rootNavigatorKey, + pageBuilder: (context, state) { + final jobId = state.params['id']!; + final entryId = state.params['eid']!; + final entry = state.extra as Entry?; + return MaterialPage( + key: state.pageKey, + fullscreenDialog: true, + child: EntryPage( + jobId: jobId, + entryId: entryId, + entry: entry, + ), + ); + }, + // TODO: Edit + routes: [], + ), GoRoute( path: 'edit', name: AppRoute.editJob.name, @@ -150,19 +203,24 @@ final goRouterProvider = Provider((ref) { ), ], ), - GoRoute( - path: 'add', - name: AppRoute.addJob.name, - pageBuilder: (context, state) { - return MaterialPage( - key: state.pageKey, - fullscreenDialog: true, - child: EditJobPage(), - ); - }, - ), ], ), + GoRoute( + path: '/entries', + name: AppRoute.entries.name, + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: EntriesPage(), + ), + ), + GoRoute( + path: '/account', + name: AppRoute.account.name, + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: AccountScreen(), + ), + ), ], ), ], diff --git a/lib/src/routing/scaffold_with_bottom_nav_bar.dart b/lib/src/routing/scaffold_with_bottom_nav_bar.dart index 81501fdc..e27db944 100644 --- a/lib/src/routing/scaffold_with_bottom_nav_bar.dart +++ b/lib/src/routing/scaffold_with_bottom_nav_bar.dart @@ -44,9 +44,6 @@ class _ScaffoldWithBottomNavBarState extends State { body: widget.child, bottomNavigationBar: BottomNavigationBar( type: BottomNavigationBarType.fixed, - // TODO: figure out theming - unselectedItemColor: Colors.grey, - selectedItemColor: Colors.white, currentIndex: _selectedIndex, items: [ // products From bf4eeccead83ddc15f1d963fe95dbb1eace65477 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Tue, 1 Nov 2022 07:18:53 +0000 Subject: [PATCH 20/45] Fix navigation --- ios/Podfile.lock | 2 +- lib/main.dart | 1 + .../email_password_sign_in_controller.dart | 3 +- .../email_password_sign_in_screen.dart | 7 +-- .../presentation/sign_in/sign_in_screen.dart | 3 +- lib/src/features/jobs/edit_job_page.dart | 1 - lib/src/routing/app_router.dart | 52 ++++--------------- 7 files changed, 14 insertions(+), 55 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 05c38f0c..4764a7fd 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -123,7 +123,7 @@ SPEC CHECKSUMS: FirebaseCore: 2082fffcd855f95f883c0a1641133eb9bbe76d40 FirebaseCoreDiagnostics: 99a495094b10a57eeb3ae8efa1665700ad0bdaa6 FirebaseCoreInternal: bca76517fe1ed381e989f5e7d8abb0da8d85bed3 - FirebaseFirestore: 56251017e7fb2530d39d42724a6772fd78fd734e + FirebaseFirestore: 9bfe2e814686fb3ba2495b97618b38987e4c8526 Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 GoogleDataTransport: 1c8145da7117bd68bbbed00cf304edb6a24de00f GoogleUtilities: 1d20a6ad97ef46f67bbdec158ce00563a671ebb7 diff --git a/lib/main.dart b/lib/main.dart index 323ffa3f..77795e81 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -36,6 +36,7 @@ class MyApp extends ConsumerWidget { elevation: 2.0, centerTitle: true, ), + scaffoldBackgroundColor: Colors.grey[200], ), debugShowCheckedModeBanner: false, ); diff --git a/lib/src/features/authentication/presentation/email_password/email_password_sign_in_controller.dart b/lib/src/features/authentication/presentation/email_password/email_password_sign_in_controller.dart index 0da0bad2..f8f57fe2 100644 --- a/lib/src/features/authentication/presentation/email_password/email_password_sign_in_controller.dart +++ b/lib/src/features/authentication/presentation/email_password/email_password_sign_in_controller.dart @@ -10,14 +10,13 @@ class EmailPasswordSignInController extends AutoDisposeAsyncNotifier { // ok to leave this empty if the return type is FutureOr } - Future submit( + Future submit( {required String email, required String password, required EmailPasswordSignInFormType formType}) async { state = const AsyncValue.loading(); state = await AsyncValue.guard(() => _authenticate(email, password, formType)); - return state.hasError == false; } Future _authenticate( diff --git a/lib/src/features/authentication/presentation/email_password/email_password_sign_in_screen.dart b/lib/src/features/authentication/presentation/email_password/email_password_sign_in_screen.dart index 5621d8a0..5a27488b 100644 --- a/lib/src/features/authentication/presentation/email_password/email_password_sign_in_screen.dart +++ b/lib/src/features/authentication/presentation/email_password/email_password_sign_in_screen.dart @@ -40,10 +40,8 @@ class EmailPasswordSignInScreen extends StatelessWidget { class EmailPasswordSignInContents extends ConsumerStatefulWidget { const EmailPasswordSignInContents({ super.key, - this.onSignedIn, required this.formType, }); - final VoidCallback? onSignedIn; /// The default form type to use. final EmailPasswordSignInFormType formType; @@ -86,14 +84,11 @@ class _EmailPasswordSignInContentsState if (_formKey.currentState!.validate()) { final controller = ref.read(emailPasswordSignInControllerProvider.notifier); - final success = await controller.submit( + await controller.submit( email: email, password: password, formType: _formType, ); - if (success) { - widget.onSignedIn?.call(); - } } } diff --git a/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart b/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart index d43cb5eb..250f16de 100644 --- a/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart +++ b/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart @@ -27,7 +27,7 @@ class SignInScreen extends ConsumerWidget { final signInModel = ref.watch(signInModelProvider); return SignInPageContents( viewModel: signInModel, - title: 'Architecture Demo', + title: 'Time Tracker', ); } } @@ -48,7 +48,6 @@ class SignInPageContents extends StatelessWidget { appBar: AppBar( title: Text(title), ), - backgroundColor: Colors.grey[200], body: _buildSignIn(context), ); } diff --git a/lib/src/features/jobs/edit_job_page.dart b/lib/src/features/jobs/edit_job_page.dart index 3f884f57..249c5d36 100644 --- a/lib/src/features/jobs/edit_job_page.dart +++ b/lib/src/features/jobs/edit_job_page.dart @@ -91,7 +91,6 @@ class _EditJobPageState extends ConsumerState { ], ), body: _buildContents(), - backgroundColor: Colors.grey[200], ); } diff --git a/lib/src/routing/app_router.dart b/lib/src/routing/app_router.dart index 239f3e13..2509e21d 100644 --- a/lib/src/routing/app_router.dart +++ b/lib/src/routing/app_router.dart @@ -16,42 +16,6 @@ import 'package:starter_architecture_flutter_firebase/src/features/jobs/jobs_pag import 'package:starter_architecture_flutter_firebase/src/routing/go_router_refresh_stream.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/scaffold_with_bottom_nav_bar.dart'; -// ignore: avoid_classes_with_only_static_members -// class AppRouter { -// static Route? onGenerateRoute( -// RouteSettings settings, FirebaseAuth firebaseAuth) { -// final args = settings.arguments; -// switch (settings.name) { -// case AppRoutes.emailPasswordSignInPage: -// return MaterialPageRoute( -// builder: (_) => const EmailPasswordSignInScreen( -// formType: EmailPasswordSignInFormType.signIn, -// ), -// settings: settings, -// fullscreenDialog: true, -// ); -// case AppRoutes.editJobPage: -// return MaterialPageRoute( -// builder: (_) => EditJobPage(job: args as Job?), -// settings: settings, -// fullscreenDialog: true, -// ); -// case AppRoutes.entryPage: -// final mapArgs = args as Map; -// final job = mapArgs['job'] as Job; -// final entry = mapArgs['entry'] as Entry?; -// return MaterialPageRoute( -// builder: (_) => EntryPage(job: job, entry: entry), -// settings: settings, -// fullscreenDialog: true, -// ); -// default: -// // TODO: Throw -// return null; -// } -// } -// } - // private navigators final _rootNavigatorKey = GlobalKey(); final _shellNavigatorKey = GlobalKey(); @@ -92,10 +56,14 @@ final goRouterProvider = Provider((ref) { }, refreshListenable: GoRouterRefreshStream(authRepository.authStateChanges()), routes: [ + // TODO: Onboarding GoRoute( path: '/signIn', name: AppRoute.signIn.name, - builder: (context, state) => const SignInScreen(), + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: const SignInScreen(), + ), routes: [ GoRoute( path: 'emailPassword', @@ -141,7 +109,10 @@ final goRouterProvider = Provider((ref) { name: AppRoute.job.name, pageBuilder: (context, state) { final id = state.params['id']!; - final job = state.extra as Job?; + final extra = state.extra; + // extra could be a Job or an Entry (see entries/:eid route below) + // so we only use it if it's a Job + final job = extra is Job ? extra : null; return MaterialPage( key: state.pageKey, child: JobEntriesPage( @@ -166,18 +137,15 @@ final goRouterProvider = Provider((ref) { ); }, ), - // TODO: Figure out why this is not reached, and `jobs/:id` is matched instead GoRoute( path: 'entries/:eid', name: AppRoute.entry.name, - parentNavigatorKey: _rootNavigatorKey, pageBuilder: (context, state) { final jobId = state.params['id']!; final entryId = state.params['eid']!; final entry = state.extra as Entry?; return MaterialPage( key: state.pageKey, - fullscreenDialog: true, child: EntryPage( jobId: jobId, entryId: entryId, @@ -185,8 +153,6 @@ final goRouterProvider = Provider((ref) { ), ); }, - // TODO: Edit - routes: [], ), GoRoute( path: 'edit', From 3137a4f5d359cc72dc01f13143d42297898c55da Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Wed, 2 Nov 2022 07:08:58 +0000 Subject: [PATCH 21/45] Add onboarding flow to GoRouter --- lib/src/features/jobs/list_items_builder.dart | 2 +- .../presentation/onboarding_controller.dart | 24 +++++++------- .../presentation/onboarding_screen.dart | 31 ++++++++++--------- lib/src/routing/app_router.dart | 28 ++++++++++++++--- pubspec.lock | 4 +-- pubspec.yaml | 2 +- 6 files changed, 57 insertions(+), 34 deletions(-) diff --git a/lib/src/features/jobs/list_items_builder.dart b/lib/src/features/jobs/list_items_builder.dart index 43aaa0cf..eec231ea 100644 --- a/lib/src/features/jobs/list_items_builder.dart +++ b/lib/src/features/jobs/list_items_builder.dart @@ -32,7 +32,7 @@ class ListItemsBuilder extends StatelessWidget { separatorBuilder: (context, index) => const Divider(height: 0.5), itemBuilder: (context, index) { if (index == 0 || index == items.length + 1) { - return Container(); // zero height: not visible + return SizedBox.shrink(); // zero height: not visible } return itemBuilder(context, items[index - 1]); }, diff --git a/lib/src/features/onboarding/presentation/onboarding_controller.dart b/lib/src/features/onboarding/presentation/onboarding_controller.dart index 72440911..768889d5 100644 --- a/lib/src/features/onboarding/presentation/onboarding_controller.dart +++ b/lib/src/features/onboarding/presentation/onboarding_controller.dart @@ -1,21 +1,21 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/features/onboarding/data/onboarding_repository.dart'; -class OnboardingController extends StateNotifier { - OnboardingController(this.sharedPreferencesService) - : super(sharedPreferencesService.isOnboardingComplete()); - final OnboardingRepository sharedPreferencesService; +class OnboardingController extends AutoDisposeAsyncNotifier { + @override + FutureOr build() { + // no op + } Future completeOnboarding() async { - await sharedPreferencesService.setOnboardingComplete(); - state = true; + final onboardingRepository = ref.watch(onboardingRepositoryProvider); + state = AsyncLoading(); + state = await AsyncValue.guard(onboardingRepository.setOnboardingComplete); } - - bool get isOnboardingComplete => state; } final onboardingControllerProvider = - StateNotifierProvider((ref) { - final sharedPreferencesService = ref.watch(onboardingRepositoryProvider); - return OnboardingController(sharedPreferencesService); -}); + AutoDisposeAsyncNotifierProvider( + OnboardingController.new); diff --git a/lib/src/features/onboarding/presentation/onboarding_screen.dart b/lib/src/features/onboarding/presentation/onboarding_screen.dart index e64d7839..8592d511 100644 --- a/lib/src/features/onboarding/presentation/onboarding_screen.dart +++ b/lib/src/features/onboarding/presentation/onboarding_screen.dart @@ -1,12 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:starter_architecture_flutter_firebase/src/common_widgets/custom_raised_button.dart'; +import 'package:go_router/go_router.dart'; +import 'package:starter_architecture_flutter_firebase/src/common_widgets/primary_button.dart'; import 'package:starter_architecture_flutter_firebase/src/features/onboarding/presentation/onboarding_controller.dart'; +import 'package:starter_architecture_flutter_firebase/src/localization/string_hardcoded.dart'; +import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; class OnboardingScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(onboardingControllerProvider); return Scaffold( body: Padding( padding: const EdgeInsets.all(16.0), @@ -24,19 +28,18 @@ class OnboardingScreen extends ConsumerWidget { child: SvgPicture.asset('assets/time-tracking.svg', semanticsLabel: 'Time tracking logo'), ), - CustomRaisedButton( - onPressed: () => ref - .read(onboardingControllerProvider.notifier) - .completeOnboarding(), - color: Colors.indigo, - borderRadius: 30, - child: Text( - 'Get Started', - style: Theme.of(context) - .textTheme - .headline5! - .copyWith(color: Colors.white), - ), + PrimaryButton( + text: 'Get Started'.hardcoded, + isLoading: state.isLoading, + onPressed: state.isLoading + ? null + : () async { + await ref + .read(onboardingControllerProvider.notifier) + .completeOnboarding(); + // go to sign in page after completing onboarding + context.goNamed(AppRoute.signIn.name); + }, ), ], ), diff --git a/lib/src/routing/app_router.dart b/lib/src/routing/app_router.dart index 2509e21d..4f34bcb4 100644 --- a/lib/src/routing/app_router.dart +++ b/lib/src/routing/app_router.dart @@ -13,6 +13,8 @@ import 'package:starter_architecture_flutter_firebase/src/features/job_entries/j import 'package:go_router/go_router.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/edit_job_page.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/jobs_page.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/onboarding/data/onboarding_repository.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/onboarding/presentation/onboarding_screen.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/go_router_refresh_stream.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/scaffold_with_bottom_nav_bar.dart'; @@ -21,6 +23,7 @@ final _rootNavigatorKey = GlobalKey(); final _shellNavigatorKey = GlobalKey(); enum AppRoute { + onboarding, signIn, emailPassword, jobs, @@ -36,19 +39,29 @@ enum AppRoute { final goRouterProvider = Provider((ref) { final authRepository = ref.watch(authRepositoryProvider); + final onboardingRepository = ref.watch(onboardingRepositoryProvider); return GoRouter( initialLocation: '/signIn', navigatorKey: _rootNavigatorKey, debugLogDiagnostics: true, redirect: (context, state) { + final didCompleteOnboarding = onboardingRepository.isOnboardingComplete(); + if (!didCompleteOnboarding) { + // Always check state.subloc before returning a non-null route + // https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/redirection.dart#L78 + if (state.subloc != '/onboarding') { + return '/onboarding'; + } + } final isLoggedIn = authRepository.currentUser != null; if (isLoggedIn) { - if (state.location == '/signIn') { + if (state.subloc == '/signIn') { return '/jobs'; } } else { - // TODO - if (state.location == '/account' || state.location == '/orders') { + if (state.subloc.startsWith('/jobs') || + state.subloc.startsWith('/entries') || + state.subloc.startsWith('/account')) { return '/signIn'; } } @@ -56,7 +69,14 @@ final goRouterProvider = Provider((ref) { }, refreshListenable: GoRouterRefreshStream(authRepository.authStateChanges()), routes: [ - // TODO: Onboarding + GoRoute( + path: '/onboarding', + name: AppRoute.onboarding.name, + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: OnboardingScreen(), + ), + ), GoRoute( path: '/signIn', name: AppRoute.signIn.name, diff --git a/pubspec.lock b/pubspec.lock index 26c46813..064b9869 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -278,7 +278,7 @@ packages: name: flutter_riverpod url: "https://pub.dartlang.org" source: hosted - version: "2.0.2" + version: "2.1.0" flutter_svg: dependency: "direct main" description: @@ -533,7 +533,7 @@ packages: name: riverpod url: "https://pub.dartlang.org" source: hosted - version: "2.0.2" + version: "2.1.0" rxdart: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index acc6f5f3..c2b469e4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ dependencies: firebase_core: flutter: sdk: flutter - flutter_riverpod: 2.0.2 + flutter_riverpod: ^2.1.0 flutter_svg: go_router: ^5.1.1 intl: From d9917519f7b7934d717ed5a07c79b0524b7a1030 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Wed, 2 Nov 2022 07:18:35 +0000 Subject: [PATCH 22/45] Cleanup SignInScreen --- ...utton.dart => custom_elevated_button.dart} | 4 +- .../common_widgets/form_submit_button.dart | 4 +- .../data/firebase_auth_repository.dart | 4 + .../presentation/sign_in/sign_in_button.dart | 4 +- .../presentation/sign_in/sign_in_screen.dart | 155 ++++++++---------- .../sign_in/sign_in_screen_controller.dart | 21 +++ .../sign_in/sign_in_view_model.dart | 36 ---- 7 files changed, 95 insertions(+), 133 deletions(-) rename lib/src/common_widgets/{custom_raised_button.dart => custom_elevated_button.dart} (94%) create mode 100644 lib/src/features/authentication/presentation/sign_in/sign_in_screen_controller.dart delete mode 100644 lib/src/features/authentication/presentation/sign_in/sign_in_view_model.dart diff --git a/lib/src/common_widgets/custom_raised_button.dart b/lib/src/common_widgets/custom_elevated_button.dart similarity index 94% rename from lib/src/common_widgets/custom_raised_button.dart rename to lib/src/common_widgets/custom_elevated_button.dart index 537402bf..3368ba93 100644 --- a/lib/src/common_widgets/custom_raised_button.dart +++ b/lib/src/common_widgets/custom_elevated_button.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -class CustomRaisedButton extends StatelessWidget { - const CustomRaisedButton({ +class CustomElevatedButton extends StatelessWidget { + const CustomElevatedButton({ super.key, required this.child, this.color, diff --git a/lib/src/common_widgets/form_submit_button.dart b/lib/src/common_widgets/form_submit_button.dart index 87dfa520..c24c2633 100644 --- a/lib/src/common_widgets/form_submit_button.dart +++ b/lib/src/common_widgets/form_submit_button.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:starter_architecture_flutter_firebase/src/common_widgets/custom_raised_button.dart'; +import 'package:starter_architecture_flutter_firebase/src/common_widgets/custom_elevated_button.dart'; -class FormSubmitButton extends CustomRaisedButton { +class FormSubmitButton extends CustomElevatedButton { FormSubmitButton({ super.key, required String text, diff --git a/lib/src/features/authentication/data/firebase_auth_repository.dart b/lib/src/features/authentication/data/firebase_auth_repository.dart index 27ab79a6..4ea94cc8 100644 --- a/lib/src/features/authentication/data/firebase_auth_repository.dart +++ b/lib/src/features/authentication/data/firebase_auth_repository.dart @@ -8,6 +8,10 @@ class AuthRepository { Stream authStateChanges() => _auth.authStateChanges(); User? get currentUser => _auth.currentUser; + Future signInAnonymously() { + return _auth.signInAnonymously(); + } + Future signInWithEmailAndPassword(String email, String password) { return _auth.signInWithEmailAndPassword(email: email, password: password); } diff --git a/lib/src/features/authentication/presentation/sign_in/sign_in_button.dart b/lib/src/features/authentication/presentation/sign_in/sign_in_button.dart index c00d6a4a..ade86759 100644 --- a/lib/src/features/authentication/presentation/sign_in/sign_in_button.dart +++ b/lib/src/features/authentication/presentation/sign_in/sign_in_button.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:starter_architecture_flutter_firebase/src/common_widgets/custom_raised_button.dart'; +import 'package:starter_architecture_flutter_firebase/src/common_widgets/custom_elevated_button.dart'; -class SignInButton extends CustomRaisedButton { +class SignInButton extends CustomElevatedButton { SignInButton({ Key? key, required String text, diff --git a/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart b/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart index 250f16de..b1462cce 100644 --- a/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart +++ b/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; @@ -7,108 +6,82 @@ import 'package:go_router/go_router.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/keys.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/sign_in/sign_in_button.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/sign_in/sign_in_view_model.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/sign_in/sign_in_screen_controller.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; -import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; +import 'package:starter_architecture_flutter_firebase/src/utils/async_value_ui.dart'; class SignInScreen extends ConsumerWidget { const SignInScreen({super.key}); - @override - Widget build(BuildContext context, WidgetRef ref) { - ref.listen(signInModelProvider, (prev, model) async { - if (model.error != null) { - unawaited(showExceptionAlertDialog( - context: context, - title: Strings.signInFailed, - exception: model.error, - )); - } - }); - final signInModel = ref.watch(signInModelProvider); - return SignInPageContents( - viewModel: signInModel, - title: 'Time Tracker', - ); - } -} - -class SignInPageContents extends StatelessWidget { - const SignInPageContents( - {Key? key, required this.viewModel, this.title = 'Architecture Demo'}) - : super(key: key); - final SignInViewModel viewModel; - final String title; static const Key emailPasswordButtonKey = Key(Keys.emailPassword); static const Key anonymousButtonKey = Key(Keys.anonymous); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + ref.listen( + signInScreenControllerProvider, + (_, state) => state.showAlertDialogOnError(context), + ); + final state = ref.watch(signInScreenControllerProvider); return Scaffold( appBar: AppBar( - title: Text(title), + title: Text('Sign In'), + ), + body: Center( + child: LayoutBuilder(builder: (context, constraints) { + return Container( + width: min(constraints.maxWidth, 600), + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 32.0), + // Sign in text or loading UI + SizedBox( + height: 50.0, + child: state.isLoading + ? Center(child: CircularProgressIndicator()) + : Text( + Strings.signIn, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 32.0, fontWeight: FontWeight.w600), + ), + ), + const SizedBox(height: 32.0), + SignInButton( + key: emailPasswordButtonKey, + text: Strings.signInWithEmailPassword, + onPressed: state.isLoading + ? null + : () => context.goNamed(AppRoute.emailPassword.name), + textColor: Colors.white, + color: Theme.of(context).primaryColor, + ), + const SizedBox(height: 8), + const Text( + Strings.or, + style: TextStyle(fontSize: 14.0, color: Colors.black87), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + SignInButton( + key: anonymousButtonKey, + text: Strings.goAnonymous, + color: Theme.of(context).primaryColor, + textColor: Colors.white, + onPressed: state.isLoading + ? null + : () => ref + .read(signInScreenControllerProvider.notifier) + .signInAnonymously(), + ), + ], + ), + ); + }), ), - body: _buildSignIn(context), - ); - } - - Widget _buildHeader() { - if (viewModel.isLoading) { - return const Center( - child: CircularProgressIndicator(), - ); - } - return const Text( - Strings.signIn, - textAlign: TextAlign.center, - style: TextStyle(fontSize: 32.0, fontWeight: FontWeight.w600), - ); - } - - Widget _buildSignIn(BuildContext context) { - return Center( - child: LayoutBuilder(builder: (context, constraints) { - return Container( - width: min(constraints.maxWidth, 600), - padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 32.0), - SizedBox( - height: 50.0, - child: _buildHeader(), - ), - const SizedBox(height: 32.0), - SignInButton( - key: emailPasswordButtonKey, - text: Strings.signInWithEmailPassword, - onPressed: viewModel.isLoading - ? null - : () => context.goNamed(AppRoute.emailPassword.name), - textColor: Colors.white, - color: Theme.of(context).primaryColor, - ), - const SizedBox(height: 8), - const Text( - Strings.or, - style: TextStyle(fontSize: 14.0, color: Colors.black87), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - SignInButton( - key: anonymousButtonKey, - text: Strings.goAnonymous, - color: Theme.of(context).primaryColor, - textColor: Colors.white, - onPressed: - viewModel.isLoading ? null : viewModel.signInAnonymously, - ), - ], - ), - ); - }), ); } } diff --git a/lib/src/features/authentication/presentation/sign_in/sign_in_screen_controller.dart b/lib/src/features/authentication/presentation/sign_in/sign_in_screen_controller.dart new file mode 100644 index 00000000..ba13cc01 --- /dev/null +++ b/lib/src/features/authentication/presentation/sign_in/sign_in_screen_controller.dart @@ -0,0 +1,21 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; + +class SignInScreenController extends AutoDisposeAsyncNotifier { + @override + FutureOr build() { + // ok to leave this empty if the return type is FutureOr + } + + Future signInAnonymously() async { + final authRepository = ref.read(authRepositoryProvider); + state = const AsyncLoading(); + state = await AsyncValue.guard(authRepository.signInAnonymously); + } +} + +final signInScreenControllerProvider = + AutoDisposeAsyncNotifierProvider( + SignInScreenController.new); diff --git a/lib/src/features/authentication/presentation/sign_in/sign_in_view_model.dart b/lib/src/features/authentication/presentation/sign_in/sign_in_view_model.dart deleted file mode 100644 index 5fc58d92..00000000 --- a/lib/src/features/authentication/presentation/sign_in/sign_in_view_model.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'dart:async'; - -import 'package:firebase_auth/firebase_auth.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; - -class SignInViewModel with ChangeNotifier { - SignInViewModel({required this.auth}); - final FirebaseAuth auth; - bool isLoading = false; - dynamic error; - - Future _signIn(Future Function() signInMethod) async { - try { - isLoading = true; - notifyListeners(); - await signInMethod(); - error = null; - } catch (e) { - error = e; - rethrow; - } finally { - isLoading = false; - notifyListeners(); - } - } - - Future signInAnonymously() async { - await _signIn(auth.signInAnonymously); - } -} - -final signInModelProvider = ChangeNotifierProvider( - (ref) => SignInViewModel(auth: ref.watch(firebaseAuthProvider)), -); From 5fa55a4d404d7d25c11661f2f8a80ee1b9c57ebd Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Wed, 2 Nov 2022 15:46:58 +0000 Subject: [PATCH 23/45] Cleanup AccountScreen --- .../common_widgets/action_text_button.dart | 23 ++++ .../presentation/account/account_screen.dart | 101 ++++++++---------- .../account/account_screen_controller.dart | 21 ++++ lib/src/utils/show_alert_dialog.dart | 6 +- .../account_screen_controller_test.dart | 100 +++++++++++++++++ test/src/mocks.dart | 8 ++ 6 files changed, 200 insertions(+), 59 deletions(-) create mode 100644 lib/src/common_widgets/action_text_button.dart create mode 100644 lib/src/features/authentication/presentation/account/account_screen_controller.dart create mode 100644 test/src/features/authentication/presentation/account/account_screen_controller_test.dart create mode 100644 test/src/mocks.dart diff --git a/lib/src/common_widgets/action_text_button.dart b/lib/src/common_widgets/action_text_button.dart new file mode 100644 index 00000000..812eba54 --- /dev/null +++ b/lib/src/common_widgets/action_text_button.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:starter_architecture_flutter_firebase/src/constants/app_sizes.dart'; + +/// Text button to be used as an [AppBar] action +class ActionTextButton extends StatelessWidget { + const ActionTextButton({super.key, required this.text, this.onPressed}); + final String text; + final VoidCallback? onPressed; + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: Sizes.p16), + child: TextButton( + onPressed: onPressed, + child: Text(text, + style: Theme.of(context) + .textTheme + .headline6! + .copyWith(color: Colors.white)), + ), + ); + } +} diff --git a/lib/src/features/authentication/presentation/account/account_screen.dart b/lib/src/features/authentication/presentation/account/account_screen.dart index a3906bfb..4c47f091 100644 --- a/lib/src/features/authentication/presentation/account/account_screen.dart +++ b/lib/src/features/authentication/presentation/account/account_screen.dart @@ -1,77 +1,66 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:starter_architecture_flutter_firebase/src/common_widgets/action_text_button.dart'; import 'package:starter_architecture_flutter_firebase/src/common_widgets/avatar.dart'; -import 'package:starter_architecture_flutter_firebase/src/constants/keys.dart'; -import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/account/account_screen_controller.dart'; +import 'package:starter_architecture_flutter_firebase/src/localization/string_hardcoded.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; +import 'package:starter_architecture_flutter_firebase/src/utils/async_value_ui.dart'; -// TODO create corresponding notifier class AccountScreen extends ConsumerWidget { - Future _signOut(BuildContext context, WidgetRef ref) async { - try { - await ref.read(authRepositoryProvider).signOut(); - } catch (e) { - unawaited(showExceptionAlertDialog( - context: context, - title: Strings.logoutFailed, - exception: e, - )); - } - } - - Future _confirmSignOut(BuildContext context, WidgetRef ref) async { - final bool didRequestSignOut = await showAlertDialog( - context: context, - title: Strings.logout, - content: Strings.logoutAreYouSure, - cancelActionText: Strings.cancel, - defaultActionText: Strings.logout, - ) ?? - false; - if (didRequestSignOut == true) { - await _signOut(context, ref); - } - } - @override Widget build(BuildContext context, WidgetRef ref) { - final user = ref.watch(authRepositoryProvider).currentUser!; + ref.listen( + accountScreenControllerProvider, + (_, state) => state.showAlertDialogOnError(context), + ); + final state = ref.watch(accountScreenControllerProvider); + final user = ref.watch(authRepositoryProvider).currentUser; return Scaffold( appBar: AppBar( - title: const Text(Strings.accountPage), - actions: [ - TextButton( - key: const Key(Keys.logout), - child: const Text( - Strings.logout, - style: TextStyle( - fontSize: 18.0, - color: Colors.white, - ), - ), - onPressed: () => _confirmSignOut(context, ref), + title: state.isLoading + ? const CircularProgressIndicator() + : Text('Account'.hardcoded), + actions: [ + ActionTextButton( + text: 'Logout'.hardcoded, + onPressed: state.isLoading + ? null + : () async { + final logout = await showAlertDialog( + context: context, + title: 'Are you sure?'.hardcoded, + cancelActionText: 'Cancel'.hardcoded, + defaultActionText: 'Logout'.hardcoded, + ); + if (logout == true) { + ref + .read(accountScreenControllerProvider.notifier) + .signOut(); + } + }, ), ], bottom: PreferredSize( preferredSize: const Size.fromHeight(130.0), child: Column( children: [ - Avatar( - photoUrl: user.photoURL, - radius: 50, - borderColor: Colors.black54, - borderWidth: 2.0, - ), - const SizedBox(height: 8), - if (user.displayName != null) - Text( - user.displayName!, - style: const TextStyle(color: Colors.white), + if (user != null) ...[ + Avatar( + photoUrl: user.photoURL, + radius: 50, + borderColor: Colors.black54, + borderWidth: 2.0, ), - const SizedBox(height: 8), + const SizedBox(height: 8), + if (user.displayName != null) + Text( + user.displayName!, + style: const TextStyle(color: Colors.white), + ), + const SizedBox(height: 8), + ], ], ), ), diff --git a/lib/src/features/authentication/presentation/account/account_screen_controller.dart b/lib/src/features/authentication/presentation/account/account_screen_controller.dart new file mode 100644 index 00000000..18fc8e8a --- /dev/null +++ b/lib/src/features/authentication/presentation/account/account_screen_controller.dart @@ -0,0 +1,21 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; + +class AccountScreenController extends AutoDisposeAsyncNotifier { + @override + FutureOr build() { + // ok to leave this empty if the return type is FutureOr + } + + Future signOut() async { + final authRepository = ref.read(authRepositoryProvider); + state = const AsyncLoading(); + state = await AsyncValue.guard(authRepository.signOut); + } +} + +final accountScreenControllerProvider = + AutoDisposeAsyncNotifierProvider( + AccountScreenController.new); diff --git a/lib/src/utils/show_alert_dialog.dart b/lib/src/utils/show_alert_dialog.dart index 6a26798b..2bad9358 100644 --- a/lib/src/utils/show_alert_dialog.dart +++ b/lib/src/utils/show_alert_dialog.dart @@ -3,7 +3,7 @@ part of alert_dialogs; Future showAlertDialog({ required BuildContext context, required String title, - required String content, + String? content, String? cancelActionText, required String defaultActionText, }) async { @@ -12,7 +12,7 @@ Future showAlertDialog({ context: context, builder: (context) => AlertDialog( title: Text(title), - content: Text(content), + content: content != null ? Text(content) : null, actions: [ if (cancelActionText != null) TextButton( @@ -31,7 +31,7 @@ Future showAlertDialog({ context: context, builder: (context) => CupertinoAlertDialog( title: Text(title), - content: Text(content), + content: content != null ? Text(content) : null, actions: [ if (cancelActionText != null) CupertinoDialogAction( diff --git a/test/src/features/authentication/presentation/account/account_screen_controller_test.dart b/test/src/features/authentication/presentation/account/account_screen_controller_test.dart new file mode 100644 index 00000000..8912be17 --- /dev/null +++ b/test/src/features/authentication/presentation/account/account_screen_controller_test.dart @@ -0,0 +1,100 @@ +@Timeout(Duration(milliseconds: 500)) +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/account/account_screen_controller.dart'; + +import '../../../../mocks.dart'; + +void main() { + ProviderContainer makeProviderContainer(MockAuthRepository authRepository) { + final container = ProviderContainer( + overrides: [ + authRepositoryProvider.overrideWithValue(authRepository), + ], + ); + addTearDown(container.dispose); + return container; + } + + group('AccountScreenController', () { + test('initial state is AsyncValue.data', () { + final authRepository = MockAuthRepository(); + final container = makeProviderContainer(authRepository); + final listener = Listener>(); + container.listen( + accountScreenControllerProvider, + listener, + fireImmediately: true, + ); + // verify initial value from build method + verify(() => listener(null, const AsyncData(null))); + // no more interactions after that + verifyNoMoreInteractions(listener); + verifyNever(authRepository.signOut); + }); + + test('signOut success', () async { + // setup + final authRepository = MockAuthRepository(); + when(authRepository.signOut).thenAnswer((_) => Future.value()); + final container = makeProviderContainer(authRepository); + final controller = + container.read(accountScreenControllerProvider.notifier); + final listener = Listener>(); + container.listen( + accountScreenControllerProvider, + listener, + fireImmediately: true, + ); + // verify initial value from build method + verify(() => listener(null, const AsyncData(null))); + // run + await controller.signOut(); + // verify + verifyInOrder([ + // set loading state + () => listener(const AsyncData(null), const AsyncLoading()), + // data when complete + () => listener(const AsyncLoading(), const AsyncData(null)), + ]); + verifyNoMoreInteractions(listener); + verify(authRepository.signOut).called(1); + }); + test('signOut failure', () async { + // setup + final authRepository = MockAuthRepository(); + final exception = Exception('Connection failed'); + when(authRepository.signOut).thenThrow(exception); + final container = makeProviderContainer(authRepository); + final controller = + container.read(accountScreenControllerProvider.notifier); + final listener = Listener>(); + container.listen( + accountScreenControllerProvider, + listener, + fireImmediately: true, + ); + // verify initial value from build method + verify(() => listener(null, const AsyncData(null))); + // run + await controller.signOut(); + // verify + verifyInOrder([ + // set loading state + () => listener(const AsyncData(null), const AsyncLoading()), + // error when complete + () => listener( + const AsyncLoading(), + any(that: predicate>((value) { + expect(value.hasError, true); + return true; + })), + ), + ]); + verifyNoMoreInteractions(listener); + verify(authRepository.signOut).called(1); + }); + }); +} diff --git a/test/src/mocks.dart b/test/src/mocks.dart new file mode 100644 index 00000000..df0bf0fe --- /dev/null +++ b/test/src/mocks.dart @@ -0,0 +1,8 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; + +class MockAuthRepository extends Mock implements AuthRepository {} + +class Listener extends Mock { + void call(T? previous, T? next); +} From 696d7c8a0e7dbafaa588825a6b5b2901234bf3ff Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Wed, 2 Nov 2022 15:48:58 +0000 Subject: [PATCH 24/45] Remove unused file --- .../home/cupertino_home_scaffold.dart | 59 ------------------- 1 file changed, 59 deletions(-) delete mode 100644 lib/src/features/home/cupertino_home_scaffold.dart diff --git a/lib/src/features/home/cupertino_home_scaffold.dart b/lib/src/features/home/cupertino_home_scaffold.dart deleted file mode 100644 index 20dd7fbd..00000000 --- a/lib/src/features/home/cupertino_home_scaffold.dart +++ /dev/null @@ -1,59 +0,0 @@ -// import 'package:flutter/cupertino.dart'; -// import 'package:flutter/material.dart'; -// import 'package:starter_architecture_flutter_firebase/src/constants/keys.dart'; -// import 'package:starter_architecture_flutter_firebase/src/features/home/tab_item.dart'; -// import 'package:starter_architecture_flutter_firebase/src/routing/cupertino_tab_view_router.dart'; - -// @immutable -// class CupertinoHomeScaffold extends StatelessWidget { -// const CupertinoHomeScaffold({ -// Key? key, -// required this.currentTab, -// required this.onSelectTab, -// required this.widgetBuilders, -// required this.navigatorKeys, -// }) : super(key: key); - -// final TabItem currentTab; -// final ValueChanged onSelectTab; -// final Map widgetBuilders; -// final Map> navigatorKeys; - -// @override -// Widget build(BuildContext context) { -// return CupertinoTabScaffold( -// tabBar: CupertinoTabBar( -// key: const Key(Keys.tabBar), -// currentIndex: currentTab.index, -// activeColor: Colors.indigo, -// items: [ -// _buildItem(TabItem.jobs), -// _buildItem(TabItem.entries), -// _buildItem(TabItem.account), -// ], -// onTap: (index) => onSelectTab(TabItem.values[index]), -// ), -// tabBuilder: (context, index) { -// final item = TabItem.values[index]; -// return CupertinoTabView( -// navigatorKey: navigatorKeys[item], -// builder: (context) => widgetBuilders[item]!(context), -// onGenerateRoute: CupertinoTabViewRouter.generateRoute, -// ); -// }, -// ); -// } - -// BottomNavigationBarItem _buildItem(TabItem tabItem) { -// final itemData = TabItemData.allTabs[tabItem]!; -// final color = currentTab == tabItem ? Colors.indigo : Colors.grey; -// return BottomNavigationBarItem( -// icon: Icon( -// itemData.icon, -// key: Key(itemData.key), -// color: color, -// ), -// label: itemData.title, -// ); -// } -// } From ef1d7d2205d6d39ceca2a0df2ed6bfbf1cdb3164 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Wed, 2 Nov 2022 16:11:11 +0000 Subject: [PATCH 25/45] Refactor JobsScreen --- lib/main.dart | 43 +++++++++++-------- lib/src/app.dart | 23 ++++++++++ lib/src/auth_widget.dart | 41 ------------------ lib/src/common_widgets/date_time_picker.dart | 2 +- .../empty_content.dart | 0 .../list_items_builder.dart | 2 +- lib/src/features/entries/entries_page.dart | 2 +- .../features/entries/entries_view_model.dart | 2 +- .../features/job_entries/entry_list_item.dart | 2 +- lib/src/features/job_entries/entry_page.dart | 2 +- .../job_entries/job_entries_page.dart | 2 +- .../edit_job_screen/edit_job_screen.dart} | 8 ++-- .../jobs_screen}/job_list_tile.dart | 0 .../jobs_screen/jobs_screen.dart} | 39 +++++++---------- .../jobs_screen/jobs_screen_controller.dart | 28 ++++++++++++ lib/src/routing/app_router.dart | 10 ++--- .../job_entries => utils}/format.dart | 0 17 files changed, 108 insertions(+), 98 deletions(-) create mode 100644 lib/src/app.dart delete mode 100644 lib/src/auth_widget.dart rename lib/src/{features/jobs => common_widgets}/empty_content.dart (100%) rename lib/src/{features/jobs => common_widgets}/list_items_builder.dart (92%) rename lib/src/features/jobs/{edit_job_page.dart => presentation/edit_job_screen/edit_job_screen.dart} (94%) rename lib/src/features/jobs/{ => presentation/jobs_screen}/job_list_tile.dart (100%) rename lib/src/features/jobs/{jobs_page.dart => presentation/jobs_screen/jobs_screen.dart} (67%) create mode 100644 lib/src/features/jobs/presentation/jobs_screen/jobs_screen_controller.dart rename lib/src/{features/job_entries => utils}/format.dart (100%) diff --git a/lib/main.dart b/lib/main.dart index 77795e81..d9bd4be0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,10 +1,12 @@ //import 'package:auth_widget_builder/auth_widget_builder.dart'; import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:starter_architecture_flutter_firebase/firebase_options.dart'; -import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; +import 'package:starter_architecture_flutter_firebase/src/app.dart'; +import 'package:starter_architecture_flutter_firebase/src/localization/string_hardcoded.dart'; import 'package:starter_architecture_flutter_firebase/src/features/onboarding/data/onboarding_repository.dart'; Future main() async { @@ -13,6 +15,10 @@ Future main() async { options: DefaultFirebaseOptions.currentPlatform, ); final sharedPreferences = await SharedPreferences.getInstance(); + // * Register error handlers. For more info, see: + // * https://docs.flutter.dev/testing/errors + registerErrorHandlers(); + // * Entry point of the app runApp(ProviderScope( overrides: [ onboardingRepositoryProvider.overrideWithValue( @@ -23,22 +29,25 @@ Future main() async { )); } -class MyApp extends ConsumerWidget { - @override - Widget build(BuildContext context, WidgetRef ref) { - final goRouter = ref.watch(goRouterProvider); - return MaterialApp.router( - routerConfig: goRouter, - theme: ThemeData( - primarySwatch: Colors.indigo, - unselectedWidgetColor: Colors.grey, - appBarTheme: AppBarTheme( - elevation: 2.0, - centerTitle: true, - ), - scaffoldBackgroundColor: Colors.grey[200], +void registerErrorHandlers() { + // * Show some error UI if any uncaught exception happens + FlutterError.onError = (FlutterErrorDetails details) { + FlutterError.presentError(details); + debugPrint(details.toString()); + }; + // * Handle errors from the underlying platform/OS + PlatformDispatcher.instance.onError = (Object error, StackTrace stack) { + debugPrint(error.toString()); + return true; + }; + // * Show some error UI when any widget in the app fails to build + ErrorWidget.builder = (FlutterErrorDetails details) { + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.red, + title: Text('An error occurred'.hardcoded), ), - debugShowCheckedModeBanner: false, + body: Center(child: Text(details.toString())), ); - } + }; } diff --git a/lib/src/app.dart b/lib/src/app.dart new file mode 100644 index 00000000..efee48d9 --- /dev/null +++ b/lib/src/app.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; + +class MyApp extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final goRouter = ref.watch(goRouterProvider); + return MaterialApp.router( + routerConfig: goRouter, + theme: ThemeData( + primarySwatch: Colors.indigo, + unselectedWidgetColor: Colors.grey, + appBarTheme: AppBarTheme( + elevation: 2.0, + centerTitle: true, + ), + scaffoldBackgroundColor: Colors.grey[200], + ), + debugShowCheckedModeBanner: false, + ); + } +} diff --git a/lib/src/auth_widget.dart b/lib/src/auth_widget.dart deleted file mode 100644 index f85d8138..00000000 --- a/lib/src/auth_widget.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:firebase_auth/firebase_auth.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/empty_content.dart'; - -class AuthWidget extends ConsumerWidget { - const AuthWidget({ - Key? key, - required this.signedInBuilder, - required this.nonSignedInBuilder, - }) : super(key: key); - final WidgetBuilder nonSignedInBuilder; - final WidgetBuilder signedInBuilder; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final authStateChanges = ref.watch(authStateChangesProvider); - return authStateChanges.when( - data: (user) => _data(context, user), - loading: () => const Scaffold( - body: Center( - child: CircularProgressIndicator(), - ), - ), - error: (_, __) => const Scaffold( - body: EmptyContent( - title: 'Something went wrong', - message: 'Can\'t load data right now.', - ), - ), - ); - } - - Widget _data(BuildContext context, User? user) { - if (user != null) { - return signedInBuilder(context); - } - return nonSignedInBuilder(context); - } -} diff --git a/lib/src/common_widgets/date_time_picker.dart b/lib/src/common_widgets/date_time_picker.dart index fc7fce98..5856edda 100644 --- a/lib/src/common_widgets/date_time_picker.dart +++ b/lib/src/common_widgets/date_time_picker.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:starter_architecture_flutter_firebase/src/common_widgets/input_dropdown.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/job_entries/format.dart'; +import 'package:starter_architecture_flutter_firebase/src/utils/format.dart'; class DateTimePicker extends StatelessWidget { const DateTimePicker({ diff --git a/lib/src/features/jobs/empty_content.dart b/lib/src/common_widgets/empty_content.dart similarity index 100% rename from lib/src/features/jobs/empty_content.dart rename to lib/src/common_widgets/empty_content.dart diff --git a/lib/src/features/jobs/list_items_builder.dart b/lib/src/common_widgets/list_items_builder.dart similarity index 92% rename from lib/src/features/jobs/list_items_builder.dart rename to lib/src/common_widgets/list_items_builder.dart index eec231ea..c8213803 100644 --- a/lib/src/features/jobs/list_items_builder.dart +++ b/lib/src/common_widgets/list_items_builder.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/empty_content.dart'; +import 'package:starter_architecture_flutter_firebase/src/common_widgets/empty_content.dart'; typedef ItemWidgetBuilder = Widget Function(BuildContext context, T item); diff --git a/lib/src/features/entries/entries_page.dart b/lib/src/features/entries/entries_page.dart index cfd314c1..95efca0f 100644 --- a/lib/src/features/entries/entries_page.dart +++ b/lib/src/features/entries/entries_page.dart @@ -5,7 +5,7 @@ import 'package:starter_architecture_flutter_firebase/src/features/authenticatio import 'package:starter_architecture_flutter_firebase/src/features/entries/entries_list_tile.dart'; import 'package:starter_architecture_flutter_firebase/src/features/entries/entries_view_model.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/list_items_builder.dart'; +import 'package:starter_architecture_flutter_firebase/src/common_widgets/list_items_builder.dart'; final entriesTileModelStreamProvider = StreamProvider.autoDispose>( diff --git a/lib/src/features/entries/entries_view_model.dart b/lib/src/features/entries/entries_view_model.dart index 0cf9d7f3..fd8fd239 100644 --- a/lib/src/features/entries/entries_view_model.dart +++ b/lib/src/features/entries/entries_view_model.dart @@ -4,7 +4,7 @@ import 'package:starter_architecture_flutter_firebase/src/features/entries/daily import 'package:starter_architecture_flutter_firebase/src/features/entries/entries_list_tile.dart'; import 'package:starter_architecture_flutter_firebase/src/features/entries/entry_job.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/job_entries/format.dart'; +import 'package:starter_architecture_flutter_firebase/src/utils/format.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; diff --git a/lib/src/features/job_entries/entry_list_item.dart b/lib/src/features/job_entries/entry_list_item.dart index bbf1b12a..e8f2c5d1 100644 --- a/lib/src/features/job_entries/entry_list_item.dart +++ b/lib/src/features/job_entries/entry_list_item.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/job_entries/format.dart'; +import 'package:starter_architecture_flutter_firebase/src/utils/format.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; diff --git a/lib/src/features/job_entries/entry_page.dart b/lib/src/features/job_entries/entry_page.dart index 7450e8fd..1bf4963d 100644 --- a/lib/src/features/job_entries/entry_page.dart +++ b/lib/src/features/job_entries/entry_page.dart @@ -7,7 +7,7 @@ import 'package:starter_architecture_flutter_firebase/src/features/authenticatio import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/job_entries/format.dart'; +import 'package:starter_architecture_flutter_firebase/src/utils/format.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; class EntryPage extends ConsumerStatefulWidget { diff --git a/lib/src/features/job_entries/job_entries_page.dart b/lib/src/features/job_entries/job_entries_page.dart index 42d21ede..a07b2fd3 100644 --- a/lib/src/features/job_entries/job_entries_page.dart +++ b/lib/src/features/job_entries/job_entries_page.dart @@ -7,7 +7,7 @@ import 'package:starter_architecture_flutter_firebase/src/features/home/data/fir import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; import 'package:starter_architecture_flutter_firebase/src/features/job_entries/entry_list_item.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/list_items_builder.dart'; +import 'package:starter_architecture_flutter_firebase/src/common_widgets/list_items_builder.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; diff --git a/lib/src/features/jobs/edit_job_page.dart b/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen.dart similarity index 94% rename from lib/src/features/jobs/edit_job_page.dart rename to lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen.dart index 249c5d36..f6d0a7f0 100644 --- a/lib/src/features/jobs/edit_job_page.dart +++ b/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen.dart @@ -7,16 +7,16 @@ import 'package:starter_architecture_flutter_firebase/src/features/home/data/fir import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; -class EditJobPage extends ConsumerStatefulWidget { - const EditJobPage({super.key, this.jobId, this.job}); +class EditJobScreen extends ConsumerStatefulWidget { + const EditJobScreen({super.key, this.jobId, this.job}); final JobID? jobId; final Job? job; @override - ConsumerState createState() => _EditJobPageState(); + ConsumerState createState() => _EditJobPageState(); } -class _EditJobPageState extends ConsumerState { +class _EditJobPageState extends ConsumerState { final _formKey = GlobalKey(); String? _name; diff --git a/lib/src/features/jobs/job_list_tile.dart b/lib/src/features/jobs/presentation/jobs_screen/job_list_tile.dart similarity index 100% rename from lib/src/features/jobs/job_list_tile.dart rename to lib/src/features/jobs/presentation/jobs_screen/job_list_tile.dart diff --git a/lib/src/features/jobs/jobs_page.dart b/lib/src/features/jobs/presentation/jobs_screen/jobs_screen.dart similarity index 67% rename from lib/src/features/jobs/jobs_page.dart rename to lib/src/features/jobs/presentation/jobs_screen/jobs_screen.dart index 2f017c07..4ad4dab1 100644 --- a/lib/src/features/jobs/jobs_page.dart +++ b/lib/src/features/jobs/presentation/jobs_screen/jobs_screen.dart @@ -1,35 +1,18 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/job_list_tile.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/list_items_builder.dart'; +import 'package:starter_architecture_flutter_firebase/src/common_widgets/list_items_builder.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/job_list_tile.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/jobs_screen/jobs_screen_controller.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; -import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; - -class JobsPage extends ConsumerWidget { - Future _delete(BuildContext context, WidgetRef ref, Job job) async { - try { - final currentUser = ref.read(authRepositoryProvider).currentUser!; - await ref - .read(databaseProvider) - .deleteJob(uid: currentUser.uid, job: job); - } catch (e) { - unawaited(showExceptionAlertDialog( - context: context, - title: 'Operation failed', - exception: e, - )); - } - } +import 'package:starter_architecture_flutter_firebase/src/utils/async_value_ui.dart'; +class JobsScreen extends StatelessWidget { @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text(Strings.jobs), @@ -42,6 +25,12 @@ class JobsPage extends ConsumerWidget { ), body: Consumer( builder: (context, ref, child) { + ref.listen( + jobsScreenControllerProvider, + (_, state) => state.showAlertDialogOnError(context), + ); + // * TODO: investigate why we get a dismissible error if we call + // * ref.watch(jobsScreenControllerProvider) here final jobsAsyncValue = ref.watch(jobsStreamProvider); return ListItemsBuilder( data: jobsAsyncValue, @@ -49,7 +38,9 @@ class JobsPage extends ConsumerWidget { key: Key('job-${job.id}'), background: Container(color: Colors.red), direction: DismissDirection.endToStart, - onDismissed: (direction) => _delete(context, ref, job), + onDismissed: (direction) => ref + .read(jobsScreenControllerProvider.notifier) + .deleteJob(job), child: JobListTile( job: job, onTap: () => context.goNamed( diff --git a/lib/src/features/jobs/presentation/jobs_screen/jobs_screen_controller.dart b/lib/src/features/jobs/presentation/jobs_screen/jobs_screen_controller.dart new file mode 100644 index 00000000..22c98130 --- /dev/null +++ b/lib/src/features/jobs/presentation/jobs_screen/jobs_screen_controller.dart @@ -0,0 +1,28 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; + +class JobsScreenController extends AutoDisposeAsyncNotifier { + @override + FutureOr build() { + // ok to leave this empty if the return type is FutureOr + } + + Future deleteJob(Job job) async { + final currentUser = ref.read(authRepositoryProvider).currentUser; + if (currentUser == null) { + throw AssertionError('User can\'t be null'); + } + final database = ref.read(databaseProvider); + state = const AsyncLoading(); + state = await AsyncValue.guard( + () => database.deleteJob(uid: currentUser.uid, job: job)); + } +} + +final jobsScreenControllerProvider = + AutoDisposeAsyncNotifierProvider( + JobsScreenController.new); diff --git a/lib/src/routing/app_router.dart b/lib/src/routing/app_router.dart index 4f34bcb4..034371ca 100644 --- a/lib/src/routing/app_router.dart +++ b/lib/src/routing/app_router.dart @@ -11,8 +11,8 @@ import 'package:starter_architecture_flutter_firebase/src/features/home/models/j import 'package:starter_architecture_flutter_firebase/src/features/job_entries/entry_page.dart'; import 'package:starter_architecture_flutter_firebase/src/features/job_entries/job_entries_page.dart'; import 'package:go_router/go_router.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/edit_job_page.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/jobs_page.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/edit_job_screen.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/jobs_screen/jobs_screen.dart'; import 'package:starter_architecture_flutter_firebase/src/features/onboarding/data/onboarding_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/onboarding/presentation/onboarding_screen.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/go_router_refresh_stream.dart'; @@ -109,7 +109,7 @@ final goRouterProvider = Provider((ref) { name: AppRoute.jobs.name, pageBuilder: (context, state) => NoTransitionPage( key: state.pageKey, - child: JobsPage(), + child: JobsScreen(), ), routes: [ GoRoute( @@ -120,7 +120,7 @@ final goRouterProvider = Provider((ref) { return MaterialPage( key: state.pageKey, fullscreenDialog: true, - child: EditJobPage(), + child: EditJobScreen(), ); }, ), @@ -183,7 +183,7 @@ final goRouterProvider = Provider((ref) { return MaterialPage( key: state.pageKey, fullscreenDialog: true, - child: EditJobPage(jobId: jobId, job: job), + child: EditJobScreen(jobId: jobId, job: job), ); }, ), diff --git a/lib/src/features/job_entries/format.dart b/lib/src/utils/format.dart similarity index 100% rename from lib/src/features/job_entries/format.dart rename to lib/src/utils/format.dart From 36be7c188e6b3498a2281a2fdc20f8001a622583 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Wed, 2 Nov 2022 16:21:11 +0000 Subject: [PATCH 26/45] Start implementing EditJobScreenController --- .../edit_job_screen_controller.dart | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen_controller.dart diff --git a/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen_controller.dart b/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen_controller.dart new file mode 100644 index 00000000..a5246b40 --- /dev/null +++ b/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen_controller.dart @@ -0,0 +1,51 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; + +class EditJobScreenController extends AutoDisposeAsyncNotifier { + @override + FutureOr build() { + // ok to leave this empty if the return type is FutureOr + } + + Future submit(Job? job, String name, int ratePerHour) async { + final currentUser = ref.read(authRepositoryProvider).currentUser; + if (currentUser == null) { + throw AssertionError('User can\'t be null'); + } + final database = ref.read(databaseProvider); + state = AsyncLoading(); + // TODO: use a Future + final jobs = await database.jobsStream(uid: currentUser.uid).first; + final allLowerCaseNames = + jobs.map((job) => job.name.toLowerCase()).toList(); + if (job != null) { + allLowerCaseNames.remove(job.name.toLowerCase()); + } + // check if name is already used + if (allLowerCaseNames.contains(name.toLowerCase())) { + // TODO: Define error + state = AsyncError(Exception('Name already used'), StackTrace.current); + // await showAlertDialog( + // context: context, + // title: 'Name already used', + // content: 'Please choose a different job name', + // defaultActionText: 'OK', + // ); + } else { + final id = job?.id ?? documentIdFromCurrentDate(); + final updated = Job(id: id, name: name, ratePerHour: ratePerHour); + state = await AsyncValue.guard( + () => database.setJob(uid: currentUser.uid, job: updated), + ); + //Navigator.of(context).pop(); + } + } +} + +final editJobScreenControllerProvider = + AutoDisposeAsyncNotifierProvider( + EditJobScreenController.new); From e396d95d6ee3e6f9a4527e2014ab689c460769a0 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Thu, 3 Nov 2022 15:24:39 +0000 Subject: [PATCH 27/45] Refactor jobs and entries pages --- .../entries_service.dart} | 37 +++-- lib/src/features/entries/entries_page.dart | 42 ------ .../{ => model}/daily_jobs_details.dart | 2 +- .../model/entries_list_tile_model.dart | 12 ++ .../entries/{ => model}/entry_job.dart | 4 +- .../entries_screen.dart} | 35 +++-- lib/src/features/home/home_page.dart | 53 ------- lib/src/features/home/tab_item.dart | 32 ---- .../job_entries/job_entries_page.dart | 141 ------------------ .../data/firestore_data_source.dart | 46 +++++- .../data/firestore_repository.dart | 26 ++-- .../features/{home => jobs}/models/entry.dart | 0 .../features/{home => jobs}/models/job.dart | 0 .../edit_job_screen/edit_job_screen.dart | 52 +++---- .../edit_job_screen_controller.dart | 27 ++-- .../edit_job_screen/job_submit_exception.dart | 9 ++ .../entry_screen/entry_screen.dart} | 40 +++-- .../entry_screen/entry_screen_controller.dart | 29 ++++ .../job_entries_screen}/entry_list_item.dart | 4 +- .../job_entries_screen/job_entries_list.dart | 43 ++++++ .../job_entries_list_controller.dart | 28 ++++ .../job_entries_screen.dart | 83 +++++++++++ .../jobs_screen/job_list_tile.dart | 18 --- .../presentation/jobs_screen/jobs_screen.dart | 21 ++- .../jobs_screen/jobs_screen_controller.dart | 4 +- lib/src/routing/app_router.dart | 20 +-- lib/src/utils/async_value_ui.dart | 1 + pubspec.lock | 25 +++- pubspec.yaml | 4 +- test/job_test.dart | 2 +- 30 files changed, 422 insertions(+), 418 deletions(-) rename lib/src/features/entries/{entries_view_model.dart => application/entries_service.dart} (71%) delete mode 100644 lib/src/features/entries/entries_page.dart rename lib/src/features/entries/{ => model}/daily_jobs_details.dart (98%) create mode 100644 lib/src/features/entries/model/entries_list_tile_model.dart rename lib/src/features/entries/{ => model}/entry_job.dart (80%) rename lib/src/features/entries/{entries_list_tile.dart => presentation/entries_screen.dart} (50%) delete mode 100644 lib/src/features/home/home_page.dart delete mode 100644 lib/src/features/home/tab_item.dart delete mode 100644 lib/src/features/job_entries/job_entries_page.dart rename lib/src/features/{home => jobs}/data/firestore_data_source.dart (56%) rename lib/src/features/{home => jobs}/data/firestore_repository.dart (85%) rename lib/src/features/{home => jobs}/models/entry.dart (100%) rename lib/src/features/{home => jobs}/models/job.dart (100%) create mode 100644 lib/src/features/jobs/presentation/edit_job_screen/job_submit_exception.dart rename lib/src/features/{job_entries/entry_page.dart => jobs/presentation/entry_screen/entry_screen.dart} (83%) create mode 100644 lib/src/features/jobs/presentation/entry_screen/entry_screen_controller.dart rename lib/src/features/{job_entries => jobs/presentation/job_entries_screen}/entry_list_item.dart (98%) create mode 100644 lib/src/features/jobs/presentation/job_entries_screen/job_entries_list.dart create mode 100644 lib/src/features/jobs/presentation/job_entries_screen/job_entries_list_controller.dart create mode 100644 lib/src/features/jobs/presentation/job_entries_screen/job_entries_screen.dart delete mode 100644 lib/src/features/jobs/presentation/jobs_screen/job_list_tile.dart diff --git a/lib/src/features/entries/entries_view_model.dart b/lib/src/features/entries/application/entries_service.dart similarity index 71% rename from lib/src/features/entries/entries_view_model.dart rename to lib/src/features/entries/application/entries_service.dart index fd8fd239..cbd2dbee 100644 --- a/lib/src/features/entries/entries_view_model.dart +++ b/lib/src/features/entries/application/entries_service.dart @@ -1,22 +1,25 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:rxdart/rxdart.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/domain/app_user.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/entries/daily_jobs_details.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/entries/entries_list_tile.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/entries/entry_job.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/entries/model/daily_jobs_details.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/entries/model/entries_list_tile_model.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/entries/model/entry_job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/data/firestore_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/format.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/job.dart'; -class EntriesViewModel { - EntriesViewModel({required this.database}); +// TODO: Clean up this code a bit more +class EntriesService { + EntriesService({required this.database}); final FirestoreRepository database; /// combine List, List into List Stream> _allEntriesStream(UserID uid) => CombineLatestStream.combine2( database.entriesStream(uid: uid), - database.jobsStream(uid: uid), + database.watchJobs(uid: uid), _entriesJobsCombiner, ); @@ -71,3 +74,19 @@ class EntriesViewModel { ]; } } + +final entriesServiceProvider = Provider((ref) { + return EntriesService(database: ref.watch(databaseProvider)); +}); + +final entriesTileModelStreamProvider = + StreamProvider.autoDispose>( + (ref) { + final user = ref.watch(authStateChangesProvider).value; + if (user == null) { + throw AssertionError('User can\'t be null when fetching entries'); + } + final entriesService = ref.watch(entriesServiceProvider); + return entriesService.entriesTileModelStream(user.uid); + }, +); diff --git a/lib/src/features/entries/entries_page.dart b/lib/src/features/entries/entries_page.dart deleted file mode 100644 index 95efca0f..00000000 --- a/lib/src/features/entries/entries_page.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/entries/entries_list_tile.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/entries/entries_view_model.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/common_widgets/list_items_builder.dart'; - -final entriesTileModelStreamProvider = - StreamProvider.autoDispose>( - (ref) { - final user = ref.watch(authStateChangesProvider).value; - if (user == null) { - throw AssertionError('User can\'t be null when fetching entries'); - } - final database = ref.watch(databaseProvider); - final vm = EntriesViewModel(database: database); - return vm.entriesTileModelStream(user.uid); - }, -); - -class EntriesPage extends ConsumerWidget { - @override - Widget build(BuildContext context, WidgetRef ref) { - return Scaffold( - appBar: AppBar( - title: const Text(Strings.entries), - ), - body: Consumer( - builder: (context, ref, child) { - final entriesTileModelStream = - ref.watch(entriesTileModelStreamProvider); - return ListItemsBuilder( - data: entriesTileModelStream, - itemBuilder: (context, model) => EntriesListTile(model: model), - ); - }, - ), - ); - } -} diff --git a/lib/src/features/entries/daily_jobs_details.dart b/lib/src/features/entries/model/daily_jobs_details.dart similarity index 98% rename from lib/src/features/entries/daily_jobs_details.dart rename to lib/src/features/entries/model/daily_jobs_details.dart index 359561f7..6020b4f8 100644 --- a/lib/src/features/entries/daily_jobs_details.dart +++ b/lib/src/features/entries/model/daily_jobs_details.dart @@ -1,4 +1,4 @@ -import 'package:starter_architecture_flutter_firebase/src/features/entries/entry_job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/entries/model/entry_job.dart'; /// Temporary model class to store the time tracked and pay for a job class JobDetails { diff --git a/lib/src/features/entries/model/entries_list_tile_model.dart b/lib/src/features/entries/model/entries_list_tile_model.dart new file mode 100644 index 00000000..179afa27 --- /dev/null +++ b/lib/src/features/entries/model/entries_list_tile_model.dart @@ -0,0 +1,12 @@ +class EntriesListTileModel { + const EntriesListTileModel({ + required this.leadingText, + required this.trailingText, + this.middleText, + this.isHeader = false, + }); + final String leadingText; + final String trailingText; + final String? middleText; + final bool isHeader; +} diff --git a/lib/src/features/entries/entry_job.dart b/lib/src/features/entries/model/entry_job.dart similarity index 80% rename from lib/src/features/entries/entry_job.dart rename to lib/src/features/entries/model/entry_job.dart index 1ca4c81e..0526b69e 100644 --- a/lib/src/features/entries/entry_job.dart +++ b/lib/src/features/entries/model/entry_job.dart @@ -1,5 +1,5 @@ -import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/job.dart'; class EntryJob { EntryJob(this.entry, this.job); diff --git a/lib/src/features/entries/entries_list_tile.dart b/lib/src/features/entries/presentation/entries_screen.dart similarity index 50% rename from lib/src/features/entries/entries_list_tile.dart rename to lib/src/features/entries/presentation/entries_screen.dart index 419d0da7..272619cf 100644 --- a/lib/src/features/entries/entries_list_tile.dart +++ b/lib/src/features/entries/presentation/entries_screen.dart @@ -1,16 +1,29 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/entries/model/entries_list_tile_model.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/entries/application/entries_service.dart'; +import 'package:starter_architecture_flutter_firebase/src/common_widgets/list_items_builder.dart'; -class EntriesListTileModel { - const EntriesListTileModel({ - required this.leadingText, - required this.trailingText, - this.middleText, - this.isHeader = false, - }); - final String leadingText; - final String trailingText; - final String? middleText; - final bool isHeader; +class EntriesScreen extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: AppBar( + title: const Text(Strings.entries), + ), + body: Consumer( + builder: (context, ref, child) { + final entriesTileModelStream = + ref.watch(entriesTileModelStreamProvider); + return ListItemsBuilder( + data: entriesTileModelStream, + itemBuilder: (context, model) => EntriesListTile(model: model), + ); + }, + ), + ); + } } class EntriesListTile extends StatelessWidget { diff --git a/lib/src/features/home/home_page.dart b/lib/src/features/home/home_page.dart deleted file mode 100644 index beec97f8..00000000 --- a/lib/src/features/home/home_page.dart +++ /dev/null @@ -1,53 +0,0 @@ -// import 'package:flutter/material.dart'; -// import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/account/account_screen.dart'; -// import 'package:starter_architecture_flutter_firebase/src/features/entries/entries_page.dart'; -// import 'package:starter_architecture_flutter_firebase/src/features/home/cupertino_home_scaffold.dart'; -// import 'package:starter_architecture_flutter_firebase/src/features/home/tab_item.dart'; -// import 'package:starter_architecture_flutter_firebase/src/features/jobs/jobs_page.dart'; - -// class HomePage extends StatefulWidget { -// @override -// _HomePageState createState() => _HomePageState(); -// } - -// class _HomePageState extends State { -// TabItem _currentTab = TabItem.jobs; - -// final Map> navigatorKeys = { -// TabItem.jobs: GlobalKey(), -// TabItem.entries: GlobalKey(), -// TabItem.account: GlobalKey(), -// }; - -// Map get widgetBuilders { -// return { -// TabItem.jobs: (_) => JobsPage(), -// TabItem.entries: (_) => EntriesPage(), -// TabItem.account: (_) => AccountScreen(), -// }; -// } - -// void _select(TabItem tabItem) { -// if (tabItem == _currentTab) { -// // pop to first route -// navigatorKeys[tabItem]!.currentState?.popUntil((route) => route.isFirst); -// } else { -// setState(() => _currentTab = tabItem); -// } -// } - -// @override -// Widget build(BuildContext context) { -// return WillPopScope( -// onWillPop: () async => -// !(await navigatorKeys[_currentTab]!.currentState?.maybePop() ?? -// false), -// child: CupertinoHomeScaffold( -// currentTab: _currentTab, -// onSelectTab: _select, -// widgetBuilders: widgetBuilders, -// navigatorKeys: navigatorKeys, -// ), -// ); -// } -// } diff --git a/lib/src/features/home/tab_item.dart b/lib/src/features/home/tab_item.dart deleted file mode 100644 index ec92a8bd..00000000 --- a/lib/src/features/home/tab_item.dart +++ /dev/null @@ -1,32 +0,0 @@ -// import 'package:flutter/material.dart'; -// import 'package:starter_architecture_flutter_firebase/src/constants/keys.dart'; -// import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; - -// enum TabItem { jobs, entries, account } - -// class TabItemData { -// const TabItemData( -// {required this.key, required this.title, required this.icon}); - -// final String key; -// final String title; -// final IconData icon; - -// static const Map allTabs = { -// TabItem.jobs: TabItemData( -// key: Keys.jobsTab, -// title: Strings.jobs, -// icon: Icons.work, -// ), -// TabItem.entries: TabItemData( -// key: Keys.entriesTab, -// title: Strings.entries, -// icon: Icons.view_headline, -// ), -// TabItem.account: TabItemData( -// key: Keys.accountTab, -// title: Strings.account, -// icon: Icons.person, -// ), -// }; -// } diff --git a/lib/src/features/job_entries/job_entries_page.dart b/lib/src/features/job_entries/job_entries_page.dart deleted file mode 100644 index a07b2fd3..00000000 --- a/lib/src/features/job_entries/job_entries_page.dart +++ /dev/null @@ -1,141 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/job_entries/entry_list_item.dart'; -import 'package:starter_architecture_flutter_firebase/src/common_widgets/list_items_builder.dart'; -import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; -import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; - -import '../authentication/data/firebase_auth_repository.dart'; - -class JobEntriesPage extends ConsumerWidget { - const JobEntriesPage({required this.jobId, this.job}); - final JobID jobId; - final Job? job; - - @override - Widget build(BuildContext context, WidgetRef ref) { - if (job != null) { - return JobEntriesPageContents(job: job!); - } else { - final jobAsync = ref.watch(jobStreamProvider(jobId)); - return jobAsync.when( - error: (e, st) => Scaffold( - appBar: AppBar( - title: Text('Error'), - ), - body: Center(child: Text(e.toString())), - ), - loading: () => Scaffold( - appBar: AppBar( - title: Text('Loading'), - ), - body: Center(child: CircularProgressIndicator()), - ), - data: (job) => JobEntriesPageContents(job: job), - ); - } - } -} - -class JobEntriesPageContents extends StatelessWidget { - const JobEntriesPageContents({super.key, required this.job}); - final Job job; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: JobEntriesAppBarTitle(job: job), - actions: [ - IconButton( - icon: const Icon(Icons.edit, color: Colors.white), - // EditJobPage - onPressed: () => context.goNamed( - AppRoute.editJob.name, - params: {'id': job.id}, - extra: job, - ), - ), - ], - ), - body: JobEntriesList(job: job), - floatingActionButton: FloatingActionButton( - child: const Icon(Icons.add, color: Colors.white), - // EntryPage - onPressed: () => context.goNamed( - AppRoute.addEntry.name, - params: {'id': job.id}, - extra: job, - ), - ), - ); - } -} - -class JobEntriesAppBarTitle extends ConsumerWidget { - const JobEntriesAppBarTitle({required this.job}); - final Job job; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final jobAsyncValue = ref.watch(jobStreamProvider(job.id)); - return jobAsyncValue.when( - data: (job) => Text(job.name), - loading: () => Container(), - error: (_, __) => Container(), - ); - } -} - -class JobEntriesList extends ConsumerWidget { - final Job job; - const JobEntriesList({required this.job}); - - Future _deleteEntry( - BuildContext context, WidgetRef ref, Entry entry) async { - try { - final currentUser = ref.read(authRepositoryProvider).currentUser!; - final database = ref.read(databaseProvider); - await database.deleteEntry(uid: currentUser.uid, entry: entry); - } catch (e) { - unawaited(showExceptionAlertDialog( - context: context, - title: 'Operation failed', - exception: e, - )); - } - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final entriesStream = ref.watch(jobEntriesStreamProvider(job)); - return ListItemsBuilder( - data: entriesStream, - itemBuilder: (context, entry) { - return DismissibleEntryListItem( - dismissibleKey: Key('entry-${entry.id}'), - entry: entry, - job: job, - onDismissed: () => _deleteEntry(context, ref, entry), - onTap: () => context.go( - '/jobs/${job.id}/entries/${entry.id}', - // AppRoute.entry.name, - // params: {'id': job.id, 'eid': entry.id}, - extra: entry, - ), - // onTap: () => context.goNamed( - // AppRoute.entry.name, - // params: {'id': job.id, 'eid': entry.id}, - // extra: entry, - // ), - ); - }, - ); - } -} diff --git a/lib/src/features/home/data/firestore_data_source.dart b/lib/src/features/jobs/data/firestore_data_source.dart similarity index 56% rename from lib/src/features/home/data/firestore_data_source.dart rename to lib/src/features/jobs/data/firestore_data_source.dart index ca9526a6..9ad7665d 100644 --- a/lib/src/features/home/data/firestore_data_source.dart +++ b/lib/src/features/jobs/data/firestore_data_source.dart @@ -10,17 +10,16 @@ class FirestoreDataSource { bool merge = false, }) async { final reference = FirebaseFirestore.instance.doc(path); - print('$path: $data'); await reference.set(data, SetOptions(merge: merge)); } Future deleteData({required String path}) async { final reference = FirebaseFirestore.instance.doc(path); - print('delete: $path'); await reference.delete(); } - Stream> collectionStream({ + // watch collections and documents as streams + Stream> watchCollection({ required String path, required T Function(Map? data, String documentID) builder, Query>? Function(Query> query)? @@ -32,8 +31,7 @@ class FirestoreDataSource { if (queryBuilder != null) { query = queryBuilder(query)!; } - final Stream>> snapshots = - query.snapshots(); + final snapshots = query.snapshots(); return snapshots.map((snapshot) { final result = snapshot.docs .map((snapshot) => builder(snapshot.data(), snapshot.id)) @@ -46,16 +44,48 @@ class FirestoreDataSource { }); } - Stream documentStream({ + Stream watchDocument({ required String path, required T Function(Map? data, String documentID) builder, }) { - final DocumentReference> reference = - FirebaseFirestore.instance.doc(path); + final reference = FirebaseFirestore.instance.doc(path); final Stream>> snapshots = reference.snapshots(); return snapshots.map((snapshot) => builder(snapshot.data(), snapshot.id)); } + + // fetch collections and documents as futures + Future> fetchCollection({ + required String path, + required T Function(Map? data, String documentID) builder, + Query>? Function(Query> query)? + queryBuilder, + int Function(T lhs, T rhs)? sort, + }) async { + Query> query = + FirebaseFirestore.instance.collection(path); + if (queryBuilder != null) { + query = queryBuilder(query)!; + } + final snapshot = await query.get(); + final result = snapshot.docs + .map((snapshot) => builder(snapshot.data(), snapshot.id)) + .where((value) => value != null) + .toList(); + if (sort != null) { + result.sort(sort); + } + return result; + } + + Future fetchDocument({ + required String path, + required T Function(Map? data, String documentID) builder, + }) async { + final reference = FirebaseFirestore.instance.doc(path); + final snapshot = await reference.get(); + return builder(snapshot.data(), snapshot.id); + } } final firestoreDataSourceProvider = Provider((ref) { diff --git a/lib/src/features/home/data/firestore_repository.dart b/lib/src/features/jobs/data/firestore_repository.dart similarity index 85% rename from lib/src/features/home/data/firestore_repository.dart rename to lib/src/features/jobs/data/firestore_repository.dart index 13e9b13a..cf9f6d95 100644 --- a/lib/src/features/home/data/firestore_repository.dart +++ b/lib/src/features/jobs/data/firestore_repository.dart @@ -3,9 +3,9 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/domain/app_user.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_data_source.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/data/firestore_data_source.dart'; String documentIdFromCurrentDate() { final iso = DateTime.now().toIso8601String(); @@ -42,14 +42,20 @@ class FirestoreRepository { await _dataSource.deleteData(path: FirestorePath.job(uid, job.id)); } - Stream jobStream({required UserID uid, required JobID jobId}) => - _dataSource.documentStream( + Stream watchJob({required UserID uid, required JobID jobId}) => + _dataSource.watchDocument( path: FirestorePath.job(uid, jobId), builder: (data, documentId) => Job.fromMap(data, documentId), ); - Stream> jobsStream({required UserID uid}) => - _dataSource.collectionStream( + Stream> watchJobs({required UserID uid}) => + _dataSource.watchCollection( + path: FirestorePath.jobs(uid), + builder: (data, documentId) => Job.fromMap(data, documentId), + ); + + Future> fetchJobs({required UserID uid}) => + _dataSource.fetchCollection( path: FirestorePath.jobs(uid), builder: (data, documentId) => Job.fromMap(data, documentId), ); @@ -64,7 +70,7 @@ class FirestoreRepository { _dataSource.deleteData(path: FirestorePath.entry(uid, entry.id)); Stream> entriesStream({required UserID uid, Job? job}) => - _dataSource.collectionStream( + _dataSource.watchCollection( path: FirestorePath.entries(uid), queryBuilder: job != null ? (query) => query.where('jobId', isEqualTo: job.id) @@ -84,7 +90,7 @@ final jobsStreamProvider = StreamProvider.autoDispose>((ref) { throw AssertionError('User can\'t be null'); } final database = ref.watch(databaseProvider); - return database.jobsStream(uid: user.uid); + return database.watchJobs(uid: user.uid); }); final jobStreamProvider = @@ -94,7 +100,7 @@ final jobStreamProvider = throw AssertionError('User can\'t be null'); } final database = ref.watch(databaseProvider); - return database.jobStream(uid: user.uid, jobId: jobId); + return database.watchJob(uid: user.uid, jobId: jobId); }); final jobEntriesStreamProvider = diff --git a/lib/src/features/home/models/entry.dart b/lib/src/features/jobs/models/entry.dart similarity index 100% rename from lib/src/features/home/models/entry.dart rename to lib/src/features/jobs/models/entry.dart diff --git a/lib/src/features/home/models/job.dart b/lib/src/features/jobs/models/job.dart similarity index 100% rename from lib/src/features/home/models/job.dart rename to lib/src/features/jobs/models/job.dart diff --git a/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen.dart b/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen.dart index f6d0a7f0..b02e4628 100644 --- a/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen.dart +++ b/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen.dart @@ -2,10 +2,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; -import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; +import 'package:go_router/go_router.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/edit_job_screen/edit_job_screen_controller.dart'; +import 'package:starter_architecture_flutter_firebase/src/utils/async_value_ui.dart'; class EditJobScreen extends ConsumerStatefulWidget { const EditJobScreen({super.key, this.jobId, this.job}); @@ -42,41 +42,25 @@ class _EditJobPageState extends ConsumerState { Future _submit() async { if (_validateAndSaveForm()) { - try { - final currentUser = ref.read(authRepositoryProvider).currentUser!; - final database = ref.read(databaseProvider); - final jobs = await database.jobsStream(uid: currentUser.uid).first; - final allLowerCaseNames = - jobs.map((job) => job.name.toLowerCase()).toList(); - if (widget.job != null) { - allLowerCaseNames.remove(widget.job!.name.toLowerCase()); - } - if (allLowerCaseNames.contains(_name?.toLowerCase())) { - await showAlertDialog( - context: context, - title: 'Name already used', - content: 'Please choose a different job name', - defaultActionText: 'OK', - ); - } else { - final id = widget.job?.id ?? documentIdFromCurrentDate(); - final job = - Job(id: id, name: _name ?? '', ratePerHour: _ratePerHour ?? 0); - await database.setJob(uid: currentUser.uid, job: job); - Navigator.of(context).pop(); - } - } catch (e) { - unawaited(showExceptionAlertDialog( - context: context, - title: 'Operation failed', - exception: e, - )); + final success = + await ref.read(editJobScreenControllerProvider.notifier).submit( + job: widget.job, + name: _name ?? '', + ratePerHour: _ratePerHour ?? 0, + ); + if (success) { + context.pop(); } } } @override Widget build(BuildContext context) { + ref.listen( + editJobScreenControllerProvider, + (_, state) => state.showAlertDialogOnError(context), + ); + final state = ref.watch(editJobScreenControllerProvider); return Scaffold( appBar: AppBar( title: Text(widget.job == null ? 'New Job' : 'Edit Job'), @@ -86,7 +70,7 @@ class _EditJobPageState extends ConsumerState { 'Save', style: TextStyle(fontSize: 18, color: Colors.white), ), - onPressed: () => _submit(), + onPressed: state.isLoading ? null : _submit, ), ], ), diff --git a/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen_controller.dart b/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen_controller.dart index a5246b40..bf2c2288 100644 --- a/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen_controller.dart +++ b/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen_controller.dart @@ -2,8 +2,9 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/data/firestore_repository.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/edit_job_screen/job_submit_exception.dart'; class EditJobScreenController extends AutoDisposeAsyncNotifier { @override @@ -11,15 +12,17 @@ class EditJobScreenController extends AutoDisposeAsyncNotifier { // ok to leave this empty if the return type is FutureOr } - Future submit(Job? job, String name, int ratePerHour) async { + Future submit( + {Job? job, required String name, required int ratePerHour}) async { final currentUser = ref.read(authRepositoryProvider).currentUser; if (currentUser == null) { throw AssertionError('User can\'t be null'); } + // set loading state + state = AsyncLoading().copyWithPrevious(state); + // check if name is already in use final database = ref.read(databaseProvider); - state = AsyncLoading(); - // TODO: use a Future - final jobs = await database.jobsStream(uid: currentUser.uid).first; + final jobs = await database.fetchJobs(uid: currentUser.uid); final allLowerCaseNames = jobs.map((job) => job.name.toLowerCase()).toList(); if (job != null) { @@ -27,21 +30,15 @@ class EditJobScreenController extends AutoDisposeAsyncNotifier { } // check if name is already used if (allLowerCaseNames.contains(name.toLowerCase())) { - // TODO: Define error - state = AsyncError(Exception('Name already used'), StackTrace.current); - // await showAlertDialog( - // context: context, - // title: 'Name already used', - // content: 'Please choose a different job name', - // defaultActionText: 'OK', - // ); + state = AsyncError(JobSubmitException(), StackTrace.current); + return false; } else { final id = job?.id ?? documentIdFromCurrentDate(); final updated = Job(id: id, name: name, ratePerHour: ratePerHour); state = await AsyncValue.guard( () => database.setJob(uid: currentUser.uid, job: updated), ); - //Navigator.of(context).pop(); + return state.hasError == false; } } } diff --git a/lib/src/features/jobs/presentation/edit_job_screen/job_submit_exception.dart b/lib/src/features/jobs/presentation/edit_job_screen/job_submit_exception.dart new file mode 100644 index 00000000..aef6f19a --- /dev/null +++ b/lib/src/features/jobs/presentation/edit_job_screen/job_submit_exception.dart @@ -0,0 +1,9 @@ +class JobSubmitException { + String get title => 'Name already used'; + String get description => 'Please choose a different job name'; + + @override + String toString() { + return '${title}. ${description}.'; + } +} diff --git a/lib/src/features/job_entries/entry_page.dart b/lib/src/features/jobs/presentation/entry_screen/entry_screen.dart similarity index 83% rename from lib/src/features/job_entries/entry_page.dart rename to lib/src/features/jobs/presentation/entry_screen/entry_screen.dart index 1bf4963d..92bc8e30 100644 --- a/lib/src/features/job_entries/entry_page.dart +++ b/lib/src/features/jobs/presentation/entry_screen/entry_screen.dart @@ -2,25 +2,26 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:starter_architecture_flutter_firebase/src/common_widgets/date_time_picker.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/data/firestore_repository.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/entry_screen/entry_screen_controller.dart'; +import 'package:starter_architecture_flutter_firebase/src/utils/async_value_ui.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/format.dart'; -import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.dart'; -class EntryPage extends ConsumerStatefulWidget { - const EntryPage({required this.jobId, this.entryId, this.entry}); +class EntryScreen extends ConsumerStatefulWidget { + const EntryScreen({required this.jobId, this.entryId, this.entry}); final JobID jobId; final EntryID? entryId; final Entry? entry; @override - ConsumerState createState() => _EntryPageState(); + ConsumerState createState() => _EntryPageState(); } -class _EntryPageState extends ConsumerState { +class _EntryPageState extends ConsumerState { late DateTime _startDate; late TimeOfDay _startTime; late DateTime _endDate; @@ -57,23 +58,20 @@ class _EntryPageState extends ConsumerState { } Future _setEntryAndDismiss() async { - try { - final currentUser = ref.read(authRepositoryProvider).currentUser!; - final database = ref.read(databaseProvider); - final entry = _entryFromState(); - await database.setEntry(uid: currentUser.uid, entry: entry); - Navigator.of(context).pop(); - } catch (e) { - unawaited(showExceptionAlertDialog( - context: context, - title: 'Operation failed', - exception: e, - )); + final entry = _entryFromState(); + final success = + await ref.read(entryScreenControllerProvider.notifier).setEntry(entry); + if (success) { + context.pop(); } } @override Widget build(BuildContext context) { + ref.listen( + entryScreenControllerProvider, + (_, state) => state.showAlertDialogOnError(context), + ); return Scaffold( appBar: AppBar( title: Text(widget.entry != null ? 'Edit Entry' : 'New Entry'), diff --git a/lib/src/features/jobs/presentation/entry_screen/entry_screen_controller.dart b/lib/src/features/jobs/presentation/entry_screen/entry_screen_controller.dart new file mode 100644 index 00000000..1138b2ef --- /dev/null +++ b/lib/src/features/jobs/presentation/entry_screen/entry_screen_controller.dart @@ -0,0 +1,29 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/data/firestore_repository.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/entry.dart'; + +class EntryScreenController extends AutoDisposeAsyncNotifier { + @override + FutureOr build() { + // ok to leave this empty if the return type is FutureOr + } + + Future setEntry(Entry entry) async { + final currentUser = ref.read(authRepositoryProvider).currentUser; + if (currentUser == null) { + throw AssertionError('User can\'t be null'); + } + final database = ref.read(databaseProvider); + state = const AsyncLoading(); + state = await AsyncValue.guard( + () => database.setEntry(uid: currentUser.uid, entry: entry)); + return state.hasError == false; + } +} + +final entryScreenControllerProvider = + AutoDisposeAsyncNotifierProvider( + EntryScreenController.new); diff --git a/lib/src/features/job_entries/entry_list_item.dart b/lib/src/features/jobs/presentation/job_entries_screen/entry_list_item.dart similarity index 98% rename from lib/src/features/job_entries/entry_list_item.dart rename to lib/src/features/jobs/presentation/job_entries_screen/entry_list_item.dart index e8f2c5d1..16dbb801 100644 --- a/lib/src/features/job_entries/entry_list_item.dart +++ b/lib/src/features/jobs/presentation/job_entries_screen/entry_list_item.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/format.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/job.dart'; class EntryListItem extends StatelessWidget { const EntryListItem({ diff --git a/lib/src/features/jobs/presentation/job_entries_screen/job_entries_list.dart b/lib/src/features/jobs/presentation/job_entries_screen/job_entries_list.dart new file mode 100644 index 00000000..19e9b460 --- /dev/null +++ b/lib/src/features/jobs/presentation/job_entries_screen/job_entries_list.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:starter_architecture_flutter_firebase/src/common_widgets/list_items_builder.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/data/firestore_repository.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/job_entries_screen/entry_list_item.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/job_entries_screen/job_entries_list_controller.dart'; +import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; +import 'package:starter_architecture_flutter_firebase/src/utils/async_value_ui.dart'; + +class JobEntriesList extends ConsumerWidget { + const JobEntriesList({required this.job}); + final Job job; + + @override + Widget build(BuildContext context, WidgetRef ref) { + ref.listen( + jobsEntriesListControllerProvider, + (_, state) => state.showAlertDialogOnError(context), + ); + final entriesStream = ref.watch(jobEntriesStreamProvider(job)); + return ListItemsBuilder( + data: entriesStream, + itemBuilder: (context, entry) { + return DismissibleEntryListItem( + dismissibleKey: Key('entry-${entry.id}'), + entry: entry, + job: job, + onDismissed: () => ref + .read(jobsEntriesListControllerProvider.notifier) + .deleteEntry(entry), + onTap: () => context.goNamed( + AppRoute.entry.name, + params: {'id': job.id, 'eid': entry.id}, + extra: entry, + ), + ); + }, + ); + } +} diff --git a/lib/src/features/jobs/presentation/job_entries_screen/job_entries_list_controller.dart b/lib/src/features/jobs/presentation/job_entries_screen/job_entries_list_controller.dart new file mode 100644 index 00000000..68c1eea9 --- /dev/null +++ b/lib/src/features/jobs/presentation/job_entries_screen/job_entries_list_controller.dart @@ -0,0 +1,28 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/data/firestore_repository.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/entry.dart'; + +class JobsEntriesListController extends AutoDisposeAsyncNotifier { + @override + FutureOr build() { + // ok to leave this empty if the return type is FutureOr + } + + Future deleteEntry(Entry entry) async { + final currentUser = ref.read(authRepositoryProvider).currentUser; + if (currentUser == null) { + throw AssertionError('User can\'t be null'); + } + final database = ref.read(databaseProvider); + state = const AsyncLoading(); + state = await AsyncValue.guard( + () => database.deleteEntry(uid: currentUser.uid, entry: entry)); + } +} + +final jobsEntriesListControllerProvider = + AutoDisposeAsyncNotifierProvider( + JobsEntriesListController.new); diff --git a/lib/src/features/jobs/presentation/job_entries_screen/job_entries_screen.dart b/lib/src/features/jobs/presentation/job_entries_screen/job_entries_screen.dart new file mode 100644 index 00000000..db26b3f1 --- /dev/null +++ b/lib/src/features/jobs/presentation/job_entries_screen/job_entries_screen.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/data/firestore_repository.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/job_entries_screen/job_entries_list.dart'; +import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; + +class JobEntriesScreen extends ConsumerWidget { + const JobEntriesScreen({required this.jobId, this.job}); + final JobID jobId; + final Job? job; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (job != null) { + // show contents directly + return JobEntriesPageContents(job: job!); + } else { + // else watch data and map to the UI + final jobAsync = ref.watch(jobStreamProvider(jobId)); + // TODO: Test this on web + return jobAsync.when( + error: (e, st) => Scaffold( + appBar: AppBar( + title: Text('Error'), + ), + body: Center(child: Text(e.toString())), + ), + loading: () => Scaffold( + appBar: AppBar( + title: Text('Loading'), + ), + body: Center(child: CircularProgressIndicator()), + ), + data: (job) => JobEntriesPageContents(job: job), + ); + } + } +} + +class JobEntriesPageContents extends StatelessWidget { + const JobEntriesPageContents({super.key, required this.job}); + final Job job; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Consumer( + builder: (context, ref, child) { + final jobAsyncValue = ref.watch(jobStreamProvider(job.id)); + return jobAsyncValue.when( + data: (job) => Text(job.name), + loading: () => SizedBox.shrink(), + error: (_, __) => SizedBox.shrink(), + ); + }, + ), + actions: [ + IconButton( + icon: const Icon(Icons.edit, color: Colors.white), + onPressed: () => context.goNamed( + AppRoute.editJob.name, + params: {'id': job.id}, + extra: job, + ), + ), + ], + ), + body: JobEntriesList(job: job), + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.add, color: Colors.white), + // EntryPage + onPressed: () => context.goNamed( + AppRoute.addEntry.name, + params: {'id': job.id}, + extra: job, + ), + ), + ); + } +} diff --git a/lib/src/features/jobs/presentation/jobs_screen/job_list_tile.dart b/lib/src/features/jobs/presentation/jobs_screen/job_list_tile.dart deleted file mode 100644 index 017b00cb..00000000 --- a/lib/src/features/jobs/presentation/jobs_screen/job_list_tile.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; - -class JobListTile extends StatelessWidget { - const JobListTile({Key? key, required this.job, this.onTap}) - : super(key: key); - final Job job; - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - return ListTile( - title: Text(job.name), - trailing: const Icon(Icons.chevron_right), - onTap: onTap, - ); - } -} diff --git a/lib/src/features/jobs/presentation/jobs_screen/jobs_screen.dart b/lib/src/features/jobs/presentation/jobs_screen/jobs_screen.dart index 4ad4dab1..f6cef7c2 100644 --- a/lib/src/features/jobs/presentation/jobs_screen/jobs_screen.dart +++ b/lib/src/features/jobs/presentation/jobs_screen/jobs_screen.dart @@ -2,10 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/data/firestore_repository.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/job.dart'; import 'package:starter_architecture_flutter_firebase/src/common_widgets/list_items_builder.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/job_list_tile.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/jobs_screen/jobs_screen_controller.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/async_value_ui.dart'; @@ -56,3 +55,19 @@ class JobsScreen extends StatelessWidget { ); } } + +class JobListTile extends StatelessWidget { + const JobListTile({Key? key, required this.job, this.onTap}) + : super(key: key); + final Job job; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(job.name), + trailing: const Icon(Icons.chevron_right), + onTap: onTap, + ); + } +} diff --git a/lib/src/features/jobs/presentation/jobs_screen/jobs_screen_controller.dart b/lib/src/features/jobs/presentation/jobs_screen/jobs_screen_controller.dart index 22c98130..b1da933a 100644 --- a/lib/src/features/jobs/presentation/jobs_screen/jobs_screen_controller.dart +++ b/lib/src/features/jobs/presentation/jobs_screen/jobs_screen_controller.dart @@ -2,8 +2,8 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/data/firestore_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/data/firestore_repository.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/job.dart'; class JobsScreenController extends AutoDisposeAsyncNotifier { @override diff --git a/lib/src/routing/app_router.dart b/lib/src/routing/app_router.dart index 034371ca..b0da4f48 100644 --- a/lib/src/routing/app_router.dart +++ b/lib/src/routing/app_router.dart @@ -5,13 +5,13 @@ import 'package:starter_architecture_flutter_firebase/src/features/authenticatio import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/email_password/email_password_sign_in_form_type.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/email_password/email_password_sign_in_screen.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/sign_in/sign_in_screen.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/entries/entries_page.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/job_entries/entry_page.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/job_entries/job_entries_page.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/entries/presentation/entries_screen.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/entry_screen/entry_screen.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/job_entries_screen/job_entries_screen.dart'; import 'package:go_router/go_router.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/edit_job_screen.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/edit_job_screen/edit_job_screen.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/jobs_screen/jobs_screen.dart'; import 'package:starter_architecture_flutter_firebase/src/features/onboarding/data/onboarding_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/onboarding/presentation/onboarding_screen.dart'; @@ -135,7 +135,7 @@ final goRouterProvider = Provider((ref) { final job = extra is Job ? extra : null; return MaterialPage( key: state.pageKey, - child: JobEntriesPage( + child: JobEntriesScreen( jobId: id, job: job, ), @@ -151,7 +151,7 @@ final goRouterProvider = Provider((ref) { return MaterialPage( key: state.pageKey, fullscreenDialog: true, - child: EntryPage( + child: EntryScreen( jobId: jobId, ), ); @@ -166,7 +166,7 @@ final goRouterProvider = Provider((ref) { final entry = state.extra as Entry?; return MaterialPage( key: state.pageKey, - child: EntryPage( + child: EntryScreen( jobId: jobId, entryId: entryId, entry: entry, @@ -196,7 +196,7 @@ final goRouterProvider = Provider((ref) { name: AppRoute.entries.name, pageBuilder: (context, state) => NoTransitionPage( key: state.pageKey, - child: EntriesPage(), + child: EntriesScreen(), ), ), GoRoute( diff --git a/lib/src/utils/async_value_ui.dart b/lib/src/utils/async_value_ui.dart index b47b14fa..95238401 100644 --- a/lib/src/utils/async_value_ui.dart +++ b/lib/src/utils/async_value_ui.dart @@ -5,6 +5,7 @@ import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.da extension AsyncValueUI on AsyncValue { void showAlertDialogOnError(BuildContext context) { + debugPrint('isRefreshing: $isRefreshing, hasError: $hasError'); if (!isRefreshing && hasError) { final message = error.toString(); showExceptionAlertDialog( diff --git a/pubspec.lock b/pubspec.lock index 064b9869..b44a7f1b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -278,7 +278,7 @@ packages: name: flutter_riverpod url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.1.1" flutter_svg: dependency: "direct main" description: @@ -296,6 +296,20 @@ packages: description: flutter source: sdk version: "0.0.0" + freezed: + dependency: "direct dev" + description: + name: freezed + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.0" + freezed_annotation: + dependency: "direct main" + description: + name: freezed_annotation + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.0" frontend_server_client: dependency: transitive description: @@ -533,7 +547,7 @@ packages: name: riverpod url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.1.1" rxdart: dependency: "direct main" description: @@ -630,6 +644,13 @@ packages: description: flutter source: sdk version: "0.0.99" + source_gen: + dependency: transitive + description: + name: source_gen + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.6" source_map_stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c2b469e4..fca9beb7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,8 +15,9 @@ dependencies: firebase_core: flutter: sdk: flutter - flutter_riverpod: ^2.1.0 + flutter_riverpod: ^2.1.1 flutter_svg: + freezed_annotation: go_router: ^5.1.1 intl: logger: @@ -27,6 +28,7 @@ dev_dependencies: build_runner: flutter_test: sdk: flutter + freezed: mocktail: random_string: diff --git a/test/job_test.dart b/test/job_test.dart index 5414e257..e29315d5 100644 --- a/test/job_test.dart +++ b/test/job_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/home/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/job.dart'; void main() { group('fromMap', () { From b97b531dde894491ff38a9b7caeb8852dc521748 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Mon, 14 Nov 2022 20:43:15 +0000 Subject: [PATCH 28/45] Update all packages --- ios/Podfile | 2 +- ios/Podfile.lock | 113 +++++++++++++++++++++-------------------------- pubspec.lock | 34 +++++++------- pubspec.yaml | 28 ++++++------ 4 files changed, 82 insertions(+), 95 deletions(-) diff --git a/ios/Podfile b/ios/Podfile index c3d0f275..7a5c87ef 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -28,7 +28,7 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe flutter_ios_podfile_setup target 'Runner' do - pod 'FirebaseFirestore', :git => 'https://github.com/invertase/firestore-ios-sdk-frameworks.git', :tag => '9.6.0' + pod 'FirebaseFirestore', :git => 'https://github.com/invertase/firestore-ios-sdk-frameworks.git', :tag => '10.1.0' use_frameworks! use_modular_headers! diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 4764a7fd..7d9f7878 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,69 +1,60 @@ PODS: - - cloud_firestore (2.5.4): - - Firebase/Firestore (= 9.6.0) + - cloud_firestore (4.0.5): + - Firebase/Firestore (= 10.1.0) - firebase_core - Flutter - - Firebase/Auth (9.6.0): + - nanopb (< 2.30910.0, >= 2.30908.0) + - Firebase/Auth (10.1.0): - Firebase/CoreOnly - - FirebaseAuth (~> 9.6.0) - - Firebase/CoreOnly (9.6.0): - - FirebaseCore (= 9.6.0) - - Firebase/Firestore (9.6.0): + - FirebaseAuth (~> 10.1.0) + - Firebase/CoreOnly (10.1.0): + - FirebaseCore (= 10.1.0) + - Firebase/Firestore (10.1.0): - Firebase/CoreOnly - - FirebaseFirestore (~> 9.6.0) - - firebase_auth (3.11.2): - - Firebase/Auth (= 9.6.0) + - FirebaseFirestore (~> 10.1.0) + - firebase_auth (4.1.2): + - Firebase/Auth (= 10.1.0) - firebase_core - Flutter - - firebase_core (1.24.0): - - Firebase/CoreOnly (= 9.6.0) + - firebase_core (2.2.0): + - Firebase/CoreOnly (= 10.1.0) - Flutter - - FirebaseAuth (9.6.0): - - FirebaseCore (~> 9.0) - - GoogleUtilities/AppDelegateSwizzler (~> 7.7) - - GoogleUtilities/Environment (~> 7.7) - - GTMSessionFetcher/Core (< 3.0, >= 1.7) - - FirebaseCore (9.6.0): - - FirebaseCoreDiagnostics (~> 9.0) - - FirebaseCoreInternal (~> 9.0) - - GoogleUtilities/Environment (~> 7.7) - - GoogleUtilities/Logger (~> 7.7) - - FirebaseCoreDiagnostics (9.6.0): - - GoogleDataTransport (< 10.0.0, >= 9.1.4) - - GoogleUtilities/Environment (~> 7.7) - - GoogleUtilities/Logger (~> 7.7) - - nanopb (< 2.30910.0, >= 2.30908.0) - - FirebaseCoreInternal (9.6.0): - - "GoogleUtilities/NSData+zlib (~> 7.7)" - - FirebaseFirestore (9.6.0): - - FirebaseFirestore/AutodetectLeveldb (= 9.6.0) - - FirebaseFirestore/AutodetectLeveldb (9.6.0): + - FirebaseAuth (10.1.0): + - FirebaseCore (~> 10.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.8) + - GoogleUtilities/Environment (~> 7.8) + - GTMSessionFetcher/Core (~> 2.1) + - FirebaseCore (10.1.0): + - FirebaseCoreInternal (~> 10.0) + - GoogleUtilities/Environment (~> 7.8) + - GoogleUtilities/Logger (~> 7.8) + - FirebaseCoreInternal (10.1.0): + - "GoogleUtilities/NSData+zlib (~> 7.8)" + - FirebaseFirestore (10.1.0): + - FirebaseFirestore/AutodetectLeveldb (= 10.1.0) + - FirebaseFirestore/AutodetectLeveldb (10.1.0): - FirebaseFirestore/Base - FirebaseFirestore/WithLeveldb - - FirebaseFirestore/Base (9.6.0) - - FirebaseFirestore/WithLeveldb (9.6.0): + - FirebaseFirestore/Base (10.1.0) + - FirebaseFirestore/WithLeveldb (10.1.0): - FirebaseFirestore/Base - Flutter (1.0.0) - - GoogleDataTransport (9.2.0): - - GoogleUtilities/Environment (~> 7.7) - - nanopb (< 2.30910.0, >= 2.30908.0) - - PromisesObjC (< 3.0, >= 1.2) - - GoogleUtilities/AppDelegateSwizzler (7.8.0): + - GoogleUtilities/AppDelegateSwizzler (7.10.0): - GoogleUtilities/Environment - GoogleUtilities/Logger - GoogleUtilities/Network - - GoogleUtilities/Environment (7.8.0): + - GoogleUtilities/Environment (7.10.0): - PromisesObjC (< 3.0, >= 1.2) - - GoogleUtilities/Logger (7.8.0): + - GoogleUtilities/Logger (7.10.0): - GoogleUtilities/Environment - - GoogleUtilities/Network (7.8.0): + - GoogleUtilities/Network (7.10.0): - GoogleUtilities/Logger - "GoogleUtilities/NSData+zlib" - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (7.8.0)" - - GoogleUtilities/Reachability (7.8.0): + - "GoogleUtilities/NSData+zlib (7.10.0)" + - GoogleUtilities/Reachability (7.10.0): - GoogleUtilities/Logger - - GTMSessionFetcher/Core (2.1.0) + - GTMSessionFetcher/Core (2.3.0) - nanopb (2.30909.0): - nanopb/decode (= 2.30909.0) - nanopb/encode (= 2.30909.0) @@ -77,7 +68,7 @@ DEPENDENCIES: - cloud_firestore (from `.symlinks/plugins/cloud_firestore/ios`) - firebase_auth (from `.symlinks/plugins/firebase_auth/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`) - - FirebaseFirestore (from `https://github.com/invertase/firestore-ios-sdk-frameworks.git`, tag `9.6.0`) + - FirebaseFirestore (from `https://github.com/invertase/firestore-ios-sdk-frameworks.git`, tag `10.1.0`) - Flutter (from `Flutter`) - shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`) @@ -86,9 +77,7 @@ SPEC REPOS: - Firebase - FirebaseAuth - FirebaseCore - - FirebaseCoreDiagnostics - FirebaseCoreInternal - - GoogleDataTransport - GoogleUtilities - GTMSessionFetcher - nanopb @@ -103,7 +92,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/firebase_core/ios" FirebaseFirestore: :git: https://github.com/invertase/firestore-ios-sdk-frameworks.git - :tag: 9.6.0 + :tag: 10.1.0 Flutter: :path: Flutter shared_preferences_ios: @@ -112,26 +101,24 @@ EXTERNAL SOURCES: CHECKOUT OPTIONS: FirebaseFirestore: :git: https://github.com/invertase/firestore-ios-sdk-frameworks.git - :tag: 9.6.0 + :tag: 10.1.0 SPEC CHECKSUMS: - cloud_firestore: bc2bc8456db5f8067349de0cbd9fb36b0747c258 - Firebase: 5ae8b7cf8efce559a653aef0ad95bab3f427c351 - firebase_auth: 07a4db69cfa447ac42cb7faa560fc100708b707c - firebase_core: 7c28ecc1e5dd74e03829ac3e9ff5ba3314e737a9 - FirebaseAuth: e4a5d3c36e778e41141b91cc861103a441d80bcc - FirebaseCore: 2082fffcd855f95f883c0a1641133eb9bbe76d40 - FirebaseCoreDiagnostics: 99a495094b10a57eeb3ae8efa1665700ad0bdaa6 - FirebaseCoreInternal: bca76517fe1ed381e989f5e7d8abb0da8d85bed3 - FirebaseFirestore: 9bfe2e814686fb3ba2495b97618b38987e4c8526 + cloud_firestore: 345ab5f423db6ae492abb648372156bdccb9df42 + Firebase: 444b35a9c568a516666213c2f6cccd10cb12559f + firebase_auth: 6e24814d3c9976a288da6bb7a49ac79616863b5b + firebase_core: d2242c6f318db1d0dcecfbfa491e943337b0d755 + FirebaseAuth: 19a85b8a42e7c1104a2ffa6987c748daa79a5e64 + FirebaseCore: 55e7ae35991ccca4db03ff8d8df6ed5f17a3e4c7 + FirebaseCoreInternal: 96d75228e10fd369564da51bd898414eb0f54df5 + FirebaseFirestore: 741ba791488b46b4b84425fe3981e51598aabce7 Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 - GoogleDataTransport: 1c8145da7117bd68bbbed00cf304edb6a24de00f - GoogleUtilities: 1d20a6ad97ef46f67bbdec158ce00563a671ebb7 - GTMSessionFetcher: ffbb25ec00ebcb5201adab0a56d808f6f1902d9f + GoogleUtilities: bad72cb363809015b1f7f19beb1f1cd23c589f95 + GTMSessionFetcher: 3a63d75eecd6aa32c2fc79f578064e1214dfdec2 nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431 PromisesObjC: ab77feca74fa2823e7af4249b8326368e61014cb shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad -PODFILE CHECKSUM: 1e33735680063b481bf2b25dcaf31a75039cc996 +PODFILE CHECKSUM: 21ac9d4574524fb5f3601d1a026bd1e110fc75b1 COCOAPODS: 1.11.3 diff --git a/pubspec.lock b/pubspec.lock index b44a7f1b..cccc77ad 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -14,7 +14,7 @@ packages: name: _flutterfire_internals url: "https://pub.dartlang.org" source: hosted - version: "1.0.2" + version: "1.0.8" analyzer: dependency: transitive description: @@ -126,21 +126,21 @@ packages: name: cloud_firestore url: "https://pub.dartlang.org" source: hosted - version: "2.5.4" + version: "4.0.5" cloud_firestore_platform_interface: dependency: transitive description: name: cloud_firestore_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "5.7.7" + version: "5.8.5" cloud_firestore_web: dependency: transitive description: name: cloud_firestore_web url: "https://pub.dartlang.org" source: hosted - version: "2.8.10" + version: "3.0.5" code_builder: dependency: transitive description: @@ -224,42 +224,42 @@ packages: name: firebase_auth url: "https://pub.dartlang.org" source: hosted - version: "3.11.2" + version: "4.1.2" firebase_auth_platform_interface: dependency: transitive description: name: firebase_auth_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "6.10.1" + version: "6.11.2" firebase_auth_web: dependency: transitive description: name: firebase_auth_web url: "https://pub.dartlang.org" source: hosted - version: "4.6.1" + version: "5.1.2" firebase_core: dependency: "direct main" description: name: firebase_core url: "https://pub.dartlang.org" source: hosted - version: "1.24.0" + version: "2.2.0" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "4.5.1" + version: "4.5.2" firebase_core_web: dependency: transitive description: name: firebase_core_web url: "https://pub.dartlang.org" source: hosted - version: "1.7.3" + version: "2.0.1" fixnum: dependency: transitive description: @@ -323,14 +323,14 @@ packages: name: glob url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.1.1" go_router: dependency: "direct main" description: name: go_router url: "https://pub.dartlang.org" source: hosted - version: "5.1.1" + version: "5.1.6" graphs: dependency: transitive description: @@ -526,7 +526,7 @@ packages: name: pub_semver url: "https://pub.dartlang.org" source: hosted - version: "2.1.2" + version: "2.1.3" pubspec_parse: dependency: transitive description: @@ -554,7 +554,7 @@ packages: name: rxdart url: "https://pub.dartlang.org" source: hosted - version: "0.27.5" + version: "0.27.6" shared_preferences: dependency: "direct main" description: @@ -638,7 +638,7 @@ packages: name: shelf_web_socket url: "https://pub.dartlang.org" source: hosted - version: "1.0.2" + version: "1.0.3" sky_engine: dependency: transitive description: flutter @@ -699,7 +699,7 @@ packages: name: stream_transform url: "https://pub.dartlang.org" source: hosted - version: "2.0.1" + version: "2.1.0" string_scanner: dependency: transitive description: @@ -790,7 +790,7 @@ packages: name: win32 url: "https://pub.dartlang.org" source: hosted - version: "3.0.1" + version: "3.1.1" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index fca9beb7..e6c9600b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,28 +8,28 @@ environment: sdk: ">=2.17.0 <3.0.0" dependencies: - cloud_firestore: - cupertino_icons: - equatable: - firebase_auth: - firebase_core: + cloud_firestore: ^4.0.5 + cupertino_icons: ^1.0.5 + equatable: ^2.0.5 + firebase_auth: ^4.1.2 + firebase_core: ^2.2.0 flutter: sdk: flutter flutter_riverpod: ^2.1.1 - flutter_svg: - freezed_annotation: + flutter_svg: ^1.1.6 + freezed_annotation: ^2.2.0 go_router: ^5.1.1 - intl: - logger: - rxdart: - shared_preferences: + intl: ^0.17.0 + logger: ^1.1.0 + rxdart: ^0.27.6 + shared_preferences: ^2.0.15 dev_dependencies: - build_runner: + build_runner: ^2.3.0 flutter_test: sdk: flutter - freezed: - mocktail: + freezed: ^2.2.0 + mocktail: ^0.3.0 random_string: # https://stackoverflow.com/a/74234079/436422 From 6e0de6afcbfc8909532637dee4db9c2c0e80c18b Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Mon, 14 Nov 2022 20:43:43 +0000 Subject: [PATCH 29/45] Fix JobEntriesScreen to only watch the jobStreamProvider --- .../job_entries_screen.dart | 38 ++++++++----------- .../presentation/jobs_screen/jobs_screen.dart | 1 - lib/src/routing/app_router.dart | 9 +---- 3 files changed, 16 insertions(+), 32 deletions(-) diff --git a/lib/src/features/jobs/presentation/job_entries_screen/job_entries_screen.dart b/lib/src/features/jobs/presentation/job_entries_screen/job_entries_screen.dart index db26b3f1..83307a9e 100644 --- a/lib/src/features/jobs/presentation/job_entries_screen/job_entries_screen.dart +++ b/lib/src/features/jobs/presentation/job_entries_screen/job_entries_screen.dart @@ -7,35 +7,27 @@ import 'package:starter_architecture_flutter_firebase/src/features/jobs/presenta import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; class JobEntriesScreen extends ConsumerWidget { - const JobEntriesScreen({required this.jobId, this.job}); + const JobEntriesScreen({required this.jobId}); final JobID jobId; - final Job? job; @override Widget build(BuildContext context, WidgetRef ref) { - if (job != null) { - // show contents directly - return JobEntriesPageContents(job: job!); - } else { - // else watch data and map to the UI - final jobAsync = ref.watch(jobStreamProvider(jobId)); - // TODO: Test this on web - return jobAsync.when( - error: (e, st) => Scaffold( - appBar: AppBar( - title: Text('Error'), - ), - body: Center(child: Text(e.toString())), + final jobAsync = ref.watch(jobStreamProvider(jobId)); + return jobAsync.when( + error: (e, st) => Scaffold( + appBar: AppBar( + title: Text('Error'), ), - loading: () => Scaffold( - appBar: AppBar( - title: Text('Loading'), - ), - body: Center(child: CircularProgressIndicator()), + body: Center(child: Text(e.toString())), + ), + loading: () => Scaffold( + appBar: AppBar( + title: Text('Loading'), ), - data: (job) => JobEntriesPageContents(job: job), - ); - } + body: Center(child: CircularProgressIndicator()), + ), + data: (job) => JobEntriesPageContents(job: job), + ); } } diff --git a/lib/src/features/jobs/presentation/jobs_screen/jobs_screen.dart b/lib/src/features/jobs/presentation/jobs_screen/jobs_screen.dart index f6cef7c2..5f6f649b 100644 --- a/lib/src/features/jobs/presentation/jobs_screen/jobs_screen.dart +++ b/lib/src/features/jobs/presentation/jobs_screen/jobs_screen.dart @@ -45,7 +45,6 @@ class JobsScreen extends StatelessWidget { onTap: () => context.goNamed( AppRoute.job.name, params: {'id': job.id}, - extra: job, ), ), ), diff --git a/lib/src/routing/app_router.dart b/lib/src/routing/app_router.dart index b0da4f48..03b06cb3 100644 --- a/lib/src/routing/app_router.dart +++ b/lib/src/routing/app_router.dart @@ -129,16 +129,9 @@ final goRouterProvider = Provider((ref) { name: AppRoute.job.name, pageBuilder: (context, state) { final id = state.params['id']!; - final extra = state.extra; - // extra could be a Job or an Entry (see entries/:eid route below) - // so we only use it if it's a Job - final job = extra is Job ? extra : null; return MaterialPage( key: state.pageKey, - child: JobEntriesScreen( - jobId: id, - job: job, - ), + child: JobEntriesScreen(jobId: id), ); }, routes: [ From 6d62e7056c1a80099ea838811259648e2c0113f9 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Mon, 14 Nov 2022 20:50:32 +0000 Subject: [PATCH 30/45] Cleanup JobEntriesScreen, add AsyncValueWidget --- .../common_widgets/async_value_widget.dart | 40 +++++++++++++++++++ .../common_widgets/error_message_widget.dart | 13 ++++++ .../job_entries_screen.dart | 28 ++----------- 3 files changed, 57 insertions(+), 24 deletions(-) create mode 100644 lib/src/common_widgets/async_value_widget.dart create mode 100644 lib/src/common_widgets/error_message_widget.dart diff --git a/lib/src/common_widgets/async_value_widget.dart b/lib/src/common_widgets/async_value_widget.dart new file mode 100644 index 00000000..33137475 --- /dev/null +++ b/lib/src/common_widgets/async_value_widget.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:starter_architecture_flutter_firebase/src/common_widgets/error_message_widget.dart'; + +class AsyncValueWidget extends StatelessWidget { + const AsyncValueWidget({super.key, required this.value, required this.data}); + final AsyncValue value; + final Widget Function(T) data; + + @override + Widget build(BuildContext context) { + return value.when( + data: data, + error: (e, st) => Center(child: ErrorMessageWidget(e.toString())), + loading: () => const Center(child: CircularProgressIndicator()), + ); + } +} + +class ScaffoldAsyncValueWidget extends StatelessWidget { + const ScaffoldAsyncValueWidget( + {super.key, required this.value, required this.data}); + final AsyncValue value; + final Widget Function(T) data; + + @override + Widget build(BuildContext context) { + return value.when( + data: data, + error: (e, st) => Scaffold( + appBar: AppBar(), + body: Center(child: ErrorMessageWidget(e.toString())), + ), + loading: () => Scaffold( + appBar: AppBar(), + body: const Center(child: CircularProgressIndicator()), + ), + ); + } +} diff --git a/lib/src/common_widgets/error_message_widget.dart b/lib/src/common_widgets/error_message_widget.dart new file mode 100644 index 00000000..e2e86cba --- /dev/null +++ b/lib/src/common_widgets/error_message_widget.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +class ErrorMessageWidget extends StatelessWidget { + const ErrorMessageWidget(this.errorMessage, {super.key}); + final String errorMessage; + @override + Widget build(BuildContext context) { + return Text( + errorMessage, + style: Theme.of(context).textTheme.headline6!.copyWith(color: Colors.red), + ); + } +} diff --git a/lib/src/features/jobs/presentation/job_entries_screen/job_entries_screen.dart b/lib/src/features/jobs/presentation/job_entries_screen/job_entries_screen.dart index 83307a9e..9493b3f1 100644 --- a/lib/src/features/jobs/presentation/job_entries_screen/job_entries_screen.dart +++ b/lib/src/features/jobs/presentation/job_entries_screen/job_entries_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:starter_architecture_flutter_firebase/src/common_widgets/async_value_widget.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/data/firestore_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/job.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/job_entries_screen/job_entries_list.dart'; @@ -13,19 +14,8 @@ class JobEntriesScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final jobAsync = ref.watch(jobStreamProvider(jobId)); - return jobAsync.when( - error: (e, st) => Scaffold( - appBar: AppBar( - title: Text('Error'), - ), - body: Center(child: Text(e.toString())), - ), - loading: () => Scaffold( - appBar: AppBar( - title: Text('Loading'), - ), - body: Center(child: CircularProgressIndicator()), - ), + return ScaffoldAsyncValueWidget( + value: jobAsync, data: (job) => JobEntriesPageContents(job: job), ); } @@ -39,16 +29,7 @@ class JobEntriesPageContents extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Consumer( - builder: (context, ref, child) { - final jobAsyncValue = ref.watch(jobStreamProvider(job.id)); - return jobAsyncValue.when( - data: (job) => Text(job.name), - loading: () => SizedBox.shrink(), - error: (_, __) => SizedBox.shrink(), - ); - }, - ), + title: Text(job.name), actions: [ IconButton( icon: const Icon(Icons.edit, color: Colors.white), @@ -63,7 +44,6 @@ class JobEntriesPageContents extends StatelessWidget { body: JobEntriesList(job: job), floatingActionButton: FloatingActionButton( child: const Icon(Icons.add, color: Colors.white), - // EntryPage onPressed: () => context.goNamed( AppRoute.addEntry.name, params: {'id': job.id}, From 9e5b72286be03ea6176b912bef094bb685a7ad2a Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Mon, 14 Nov 2022 21:01:00 +0000 Subject: [PATCH 31/45] Cleanup buttons --- .../custom_elevated_button.dart | 58 ------------------- .../common_widgets/form_submit_button.dart | 21 ------- lib/src/constants/strings.dart | 3 +- .../presentation/sign_in/sign_in_button.dart | 20 ------- .../presentation/sign_in/sign_in_screen.dart | 10 +--- 5 files changed, 4 insertions(+), 108 deletions(-) delete mode 100644 lib/src/common_widgets/custom_elevated_button.dart delete mode 100644 lib/src/common_widgets/form_submit_button.dart delete mode 100644 lib/src/features/authentication/presentation/sign_in/sign_in_button.dart diff --git a/lib/src/common_widgets/custom_elevated_button.dart b/lib/src/common_widgets/custom_elevated_button.dart deleted file mode 100644 index 3368ba93..00000000 --- a/lib/src/common_widgets/custom_elevated_button.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:flutter/material.dart'; - -class CustomElevatedButton extends StatelessWidget { - const CustomElevatedButton({ - super.key, - required this.child, - this.color, - this.textColor, - this.height = 50.0, - this.borderRadius = 4.0, - this.loading = false, - this.onPressed, - }); - final Widget child; - final Color? color; - final Color? textColor; - final double height; - final double borderRadius; - final bool loading; - final VoidCallback? onPressed; - - Widget buildSpinner(BuildContext context) { - final ThemeData data = Theme.of(context); - return Theme( - data: data.copyWith( - colorScheme: - ColorScheme.fromSwatch().copyWith(secondary: Colors.white70)), - child: const SizedBox( - width: 28, - height: 28, - child: CircularProgressIndicator( - strokeWidth: 3.0, - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - return SizedBox( - height: height, - child: ElevatedButton( - child: loading ? buildSpinner(context) : child, - // shape: RoundedRectangleBorder( - // borderRadius: BorderRadius.all( - // Radius.circular(borderRadius), - // ), - // ), - style: ElevatedButton.styleFrom( - backgroundColor: color, - foregroundColor: textColor, - disabledForegroundColor: textColor // foreground (text) color - ), // height / 2 - onPressed: onPressed, - ), - ); - } -} diff --git a/lib/src/common_widgets/form_submit_button.dart b/lib/src/common_widgets/form_submit_button.dart deleted file mode 100644 index c24c2633..00000000 --- a/lib/src/common_widgets/form_submit_button.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:starter_architecture_flutter_firebase/src/common_widgets/custom_elevated_button.dart'; - -class FormSubmitButton extends CustomElevatedButton { - FormSubmitButton({ - super.key, - required String text, - bool loading = false, - VoidCallback? onPressed, - }) : super( - child: Text( - text, - style: const TextStyle(color: Colors.white, fontSize: 20.0), - ), - height: 44.0, - color: Colors.indigo, - textColor: Colors.black87, - loading: loading, - onPressed: onPressed, - ); -} diff --git a/lib/src/constants/strings.dart b/lib/src/constants/strings.dart index 04a83564..ef092248 100644 --- a/lib/src/constants/strings.dart +++ b/lib/src/constants/strings.dart @@ -11,8 +11,7 @@ class Strings { // Sign In Page static const String signIn = 'Sign in'; - static const String signInWithEmailPassword = - 'Sign in with email and password'; + static const String signInWithEmailPassword = 'Sign in with email & password'; static const String goAnonymous = 'Go anonymous'; static const String or = 'or'; static const String signInFailed = 'Sign in failed'; diff --git a/lib/src/features/authentication/presentation/sign_in/sign_in_button.dart b/lib/src/features/authentication/presentation/sign_in/sign_in_button.dart deleted file mode 100644 index ade86759..00000000 --- a/lib/src/features/authentication/presentation/sign_in/sign_in_button.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:starter_architecture_flutter_firebase/src/common_widgets/custom_elevated_button.dart'; - -class SignInButton extends CustomElevatedButton { - SignInButton({ - Key? key, - required String text, - required Color color, - VoidCallback? onPressed, - Color textColor = Colors.black87, - double height = 50.0, - }) : super( - key: key, - child: Text(text, style: TextStyle(color: textColor, fontSize: 16.0)), - color: color, - textColor: textColor, - height: height, - onPressed: onPressed, - ); -} diff --git a/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart b/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart index b1462cce..b580b121 100644 --- a/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart +++ b/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart @@ -3,9 +3,9 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:starter_architecture_flutter_firebase/src/common_widgets/primary_button.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/keys.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/sign_in/sign_in_button.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/sign_in/sign_in_screen_controller.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/async_value_ui.dart'; @@ -50,14 +50,12 @@ class SignInScreen extends ConsumerWidget { ), ), const SizedBox(height: 32.0), - SignInButton( + PrimaryButton( key: emailPasswordButtonKey, text: Strings.signInWithEmailPassword, onPressed: state.isLoading ? null : () => context.goNamed(AppRoute.emailPassword.name), - textColor: Colors.white, - color: Theme.of(context).primaryColor, ), const SizedBox(height: 8), const Text( @@ -66,11 +64,9 @@ class SignInScreen extends ConsumerWidget { textAlign: TextAlign.center, ), const SizedBox(height: 8), - SignInButton( + PrimaryButton( key: anonymousButtonKey, text: Strings.goAnonymous, - color: Theme.of(context).primaryColor, - textColor: Colors.white, onPressed: state.isLoading ? null : () => ref From 59b117e947a815f0cc02735364d722da44b1eb02 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Mon, 14 Nov 2022 21:03:13 +0000 Subject: [PATCH 32/45] Rename some folders --- .../common_widgets/list_items_builder.dart | 27 +++++++++---------- .../entries/application/entries_service.dart | 10 +++---- .../{model => domain}/daily_jobs_details.dart | 2 +- .../entries_list_tile_model.dart | 0 .../entries/{model => domain}/entry_job.dart | 4 +-- .../entries/presentation/entries_screen.dart | 2 +- .../jobs/data/firestore_repository.dart | 4 +-- .../jobs/{models => domain}/entry.dart | 0 .../features/jobs/{models => domain}/job.dart | 0 .../edit_job_screen/edit_job_screen.dart | 2 +- .../edit_job_screen_controller.dart | 2 +- .../entry_screen/entry_screen.dart | 4 +-- .../entry_screen/entry_screen_controller.dart | 2 +- .../job_entries_screen/entry_list_item.dart | 4 +-- .../job_entries_screen/job_entries_list.dart | 4 +-- .../job_entries_list_controller.dart | 2 +- .../job_entries_screen.dart | 2 +- .../presentation/jobs_screen/jobs_screen.dart | 2 +- .../jobs_screen/jobs_screen_controller.dart | 2 +- lib/src/routing/app_router.dart | 4 +-- test/job_test.dart | 2 +- 21 files changed, 39 insertions(+), 42 deletions(-) rename lib/src/features/entries/{model => domain}/daily_jobs_details.dart (98%) rename lib/src/features/entries/{model => domain}/entries_list_tile_model.dart (100%) rename lib/src/features/entries/{model => domain}/entry_job.dart (80%) rename lib/src/features/jobs/{models => domain}/entry.dart (100%) rename lib/src/features/jobs/{models => domain}/job.dart (100%) diff --git a/lib/src/common_widgets/list_items_builder.dart b/lib/src/common_widgets/list_items_builder.dart index c8213803..bbee2b61 100644 --- a/lib/src/common_widgets/list_items_builder.dart +++ b/lib/src/common_widgets/list_items_builder.dart @@ -16,8 +16,18 @@ class ListItemsBuilder extends StatelessWidget { @override Widget build(BuildContext context) { return data.when( - data: (items) => - items.isNotEmpty ? _buildList(items) : const EmptyContent(), + data: (items) => items.isNotEmpty + ? ListView.separated( + itemCount: items.length + 2, + separatorBuilder: (context, index) => const Divider(height: 0.5), + itemBuilder: (context, index) { + if (index == 0 || index == items.length + 1) { + return SizedBox.shrink(); + } + return itemBuilder(context, items[index - 1]); + }, + ) + : const EmptyContent(), loading: () => const Center(child: CircularProgressIndicator()), error: (_, __) => const EmptyContent( title: 'Something went wrong', @@ -25,17 +35,4 @@ class ListItemsBuilder extends StatelessWidget { ), ); } - - Widget _buildList(List items) { - return ListView.separated( - itemCount: items.length + 2, - separatorBuilder: (context, index) => const Divider(height: 0.5), - itemBuilder: (context, index) { - if (index == 0 || index == items.length + 1) { - return SizedBox.shrink(); // zero height: not visible - } - return itemBuilder(context, items[index - 1]); - }, - ); - } } diff --git a/lib/src/features/entries/application/entries_service.dart b/lib/src/features/entries/application/entries_service.dart index cbd2dbee..354a9ea0 100644 --- a/lib/src/features/entries/application/entries_service.dart +++ b/lib/src/features/entries/application/entries_service.dart @@ -2,13 +2,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:rxdart/rxdart.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/domain/app_user.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/entries/model/daily_jobs_details.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/entries/model/entries_list_tile_model.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/entries/model/entry_job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/entries/domain/daily_jobs_details.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/entries/domain/entries_list_tile_model.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/entries/domain/entry_job.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/data/firestore_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/format.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; // TODO: Clean up this code a bit more class EntriesService { diff --git a/lib/src/features/entries/model/daily_jobs_details.dart b/lib/src/features/entries/domain/daily_jobs_details.dart similarity index 98% rename from lib/src/features/entries/model/daily_jobs_details.dart rename to lib/src/features/entries/domain/daily_jobs_details.dart index 6020b4f8..3cfaa4d5 100644 --- a/lib/src/features/entries/model/daily_jobs_details.dart +++ b/lib/src/features/entries/domain/daily_jobs_details.dart @@ -1,4 +1,4 @@ -import 'package:starter_architecture_flutter_firebase/src/features/entries/model/entry_job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/entries/domain/entry_job.dart'; /// Temporary model class to store the time tracked and pay for a job class JobDetails { diff --git a/lib/src/features/entries/model/entries_list_tile_model.dart b/lib/src/features/entries/domain/entries_list_tile_model.dart similarity index 100% rename from lib/src/features/entries/model/entries_list_tile_model.dart rename to lib/src/features/entries/domain/entries_list_tile_model.dart diff --git a/lib/src/features/entries/model/entry_job.dart b/lib/src/features/entries/domain/entry_job.dart similarity index 80% rename from lib/src/features/entries/model/entry_job.dart rename to lib/src/features/entries/domain/entry_job.dart index 0526b69e..1f02a96f 100644 --- a/lib/src/features/entries/model/entry_job.dart +++ b/lib/src/features/entries/domain/entry_job.dart @@ -1,5 +1,5 @@ -import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; class EntryJob { EntryJob(this.entry, this.job); diff --git a/lib/src/features/entries/presentation/entries_screen.dart b/lib/src/features/entries/presentation/entries_screen.dart index 272619cf..a4bc3b03 100644 --- a/lib/src/features/entries/presentation/entries_screen.dart +++ b/lib/src/features/entries/presentation/entries_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/entries/model/entries_list_tile_model.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/entries/domain/entries_list_tile_model.dart'; import 'package:starter_architecture_flutter_firebase/src/features/entries/application/entries_service.dart'; import 'package:starter_architecture_flutter_firebase/src/common_widgets/list_items_builder.dart'; diff --git a/lib/src/features/jobs/data/firestore_repository.dart b/lib/src/features/jobs/data/firestore_repository.dart index cf9f6d95..445dffc8 100644 --- a/lib/src/features/jobs/data/firestore_repository.dart +++ b/lib/src/features/jobs/data/firestore_repository.dart @@ -3,8 +3,8 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/domain/app_user.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/data/firestore_data_source.dart'; String documentIdFromCurrentDate() { diff --git a/lib/src/features/jobs/models/entry.dart b/lib/src/features/jobs/domain/entry.dart similarity index 100% rename from lib/src/features/jobs/models/entry.dart rename to lib/src/features/jobs/domain/entry.dart diff --git a/lib/src/features/jobs/models/job.dart b/lib/src/features/jobs/domain/job.dart similarity index 100% rename from lib/src/features/jobs/models/job.dart rename to lib/src/features/jobs/domain/job.dart diff --git a/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen.dart b/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen.dart index b02e4628..ce76a1f1 100644 --- a/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen.dart +++ b/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/edit_job_screen/edit_job_screen_controller.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/async_value_ui.dart'; diff --git a/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen_controller.dart b/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen_controller.dart index bf2c2288..1281097b 100644 --- a/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen_controller.dart +++ b/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen_controller.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/data/firestore_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/edit_job_screen/job_submit_exception.dart'; class EditJobScreenController extends AutoDisposeAsyncNotifier { diff --git a/lib/src/features/jobs/presentation/entry_screen/entry_screen.dart b/lib/src/features/jobs/presentation/entry_screen/entry_screen.dart index 92bc8e30..407f16f8 100644 --- a/lib/src/features/jobs/presentation/entry_screen/entry_screen.dart +++ b/lib/src/features/jobs/presentation/entry_screen/entry_screen.dart @@ -5,8 +5,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:starter_architecture_flutter_firebase/src/common_widgets/date_time_picker.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/data/firestore_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/entry_screen/entry_screen_controller.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/async_value_ui.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/format.dart'; diff --git a/lib/src/features/jobs/presentation/entry_screen/entry_screen_controller.dart b/lib/src/features/jobs/presentation/entry_screen/entry_screen_controller.dart index 1138b2ef..7bd91d79 100644 --- a/lib/src/features/jobs/presentation/entry_screen/entry_screen_controller.dart +++ b/lib/src/features/jobs/presentation/entry_screen/entry_screen_controller.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/data/firestore_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/entry.dart'; class EntryScreenController extends AutoDisposeAsyncNotifier { @override diff --git a/lib/src/features/jobs/presentation/job_entries_screen/entry_list_item.dart b/lib/src/features/jobs/presentation/job_entries_screen/entry_list_item.dart index 16dbb801..1c962a89 100644 --- a/lib/src/features/jobs/presentation/job_entries_screen/entry_list_item.dart +++ b/lib/src/features/jobs/presentation/job_entries_screen/entry_list_item.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:starter_architecture_flutter_firebase/src/utils/format.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; class EntryListItem extends StatelessWidget { const EntryListItem({ diff --git a/lib/src/features/jobs/presentation/job_entries_screen/job_entries_list.dart b/lib/src/features/jobs/presentation/job_entries_screen/job_entries_list.dart index 19e9b460..f4b15b62 100644 --- a/lib/src/features/jobs/presentation/job_entries_screen/job_entries_list.dart +++ b/lib/src/features/jobs/presentation/job_entries_screen/job_entries_list.dart @@ -3,8 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:starter_architecture_flutter_firebase/src/common_widgets/list_items_builder.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/data/firestore_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/job_entries_screen/entry_list_item.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/job_entries_screen/job_entries_list_controller.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; diff --git a/lib/src/features/jobs/presentation/job_entries_screen/job_entries_list_controller.dart b/lib/src/features/jobs/presentation/job_entries_screen/job_entries_list_controller.dart index 68c1eea9..9423382c 100644 --- a/lib/src/features/jobs/presentation/job_entries_screen/job_entries_list_controller.dart +++ b/lib/src/features/jobs/presentation/job_entries_screen/job_entries_list_controller.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/data/firestore_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/entry.dart'; class JobsEntriesListController extends AutoDisposeAsyncNotifier { @override diff --git a/lib/src/features/jobs/presentation/job_entries_screen/job_entries_screen.dart b/lib/src/features/jobs/presentation/job_entries_screen/job_entries_screen.dart index 9493b3f1..b470edd9 100644 --- a/lib/src/features/jobs/presentation/job_entries_screen/job_entries_screen.dart +++ b/lib/src/features/jobs/presentation/job_entries_screen/job_entries_screen.dart @@ -3,7 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:starter_architecture_flutter_firebase/src/common_widgets/async_value_widget.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/data/firestore_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/job_entries_screen/job_entries_list.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; diff --git a/lib/src/features/jobs/presentation/jobs_screen/jobs_screen.dart b/lib/src/features/jobs/presentation/jobs_screen/jobs_screen.dart index 5f6f649b..a9d01c8c 100644 --- a/lib/src/features/jobs/presentation/jobs_screen/jobs_screen.dart +++ b/lib/src/features/jobs/presentation/jobs_screen/jobs_screen.dart @@ -3,7 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:starter_architecture_flutter_firebase/src/constants/strings.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/data/firestore_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; import 'package:starter_architecture_flutter_firebase/src/common_widgets/list_items_builder.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/jobs_screen/jobs_screen_controller.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; diff --git a/lib/src/features/jobs/presentation/jobs_screen/jobs_screen_controller.dart b/lib/src/features/jobs/presentation/jobs_screen/jobs_screen_controller.dart index b1da933a..5a1647ae 100644 --- a/lib/src/features/jobs/presentation/jobs_screen/jobs_screen_controller.dart +++ b/lib/src/features/jobs/presentation/jobs_screen/jobs_screen_controller.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/data/firestore_repository.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; class JobsScreenController extends AutoDisposeAsyncNotifier { @override diff --git a/lib/src/routing/app_router.dart b/lib/src/routing/app_router.dart index 03b06cb3..ddb20e72 100644 --- a/lib/src/routing/app_router.dart +++ b/lib/src/routing/app_router.dart @@ -6,8 +6,8 @@ import 'package:starter_architecture_flutter_firebase/src/features/authenticatio import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/email_password/email_password_sign_in_screen.dart'; import 'package:starter_architecture_flutter_firebase/src/features/authentication/presentation/sign_in/sign_in_screen.dart'; import 'package:starter_architecture_flutter_firebase/src/features/entries/presentation/entries_screen.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/entry.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/entry.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/entry_screen/entry_screen.dart'; import 'package:starter_architecture_flutter_firebase/src/features/jobs/presentation/job_entries_screen/job_entries_screen.dart'; import 'package:go_router/go_router.dart'; diff --git a/test/job_test.dart b/test/job_test.dart index e29315d5..9f34e98c 100644 --- a/test/job_test.dart +++ b/test/job_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:starter_architecture_flutter_firebase/src/features/jobs/models/job.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/job.dart'; void main() { group('fromMap', () { From f00f97cf9e0dc6e2e23ec9eb9bed8cc64a91737f Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Mon, 28 Nov 2022 16:34:28 +0000 Subject: [PATCH 33/45] Fixed redirect code --- lib/src/routing/app_router.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/routing/app_router.dart b/lib/src/routing/app_router.dart index ddb20e72..43ea3164 100644 --- a/lib/src/routing/app_router.dart +++ b/lib/src/routing/app_router.dart @@ -55,7 +55,7 @@ final goRouterProvider = Provider((ref) { } final isLoggedIn = authRepository.currentUser != null; if (isLoggedIn) { - if (state.subloc == '/signIn') { + if (state.subloc.startsWith('/signIn')) { return '/jobs'; } } else { From e1d21bc8626e1420e5eac99c5495006cbe382c5e Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Tue, 29 Nov 2022 14:59:08 +0000 Subject: [PATCH 34/45] Minor fixes, revert to GoRouter 5.1.1 --- ios/Podfile.lock | 2 +- lib/src/features/entries/application/entries_service.dart | 2 +- lib/src/features/jobs/data/firestore_repository.dart | 6 +++--- lib/src/utils/async_value_ui.dart | 4 ++-- pubspec.lock | 2 +- pubspec.yaml | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 7d9f7878..f933b3c6 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -111,7 +111,7 @@ SPEC CHECKSUMS: FirebaseAuth: 19a85b8a42e7c1104a2ffa6987c748daa79a5e64 FirebaseCore: 55e7ae35991ccca4db03ff8d8df6ed5f17a3e4c7 FirebaseCoreInternal: 96d75228e10fd369564da51bd898414eb0f54df5 - FirebaseFirestore: 741ba791488b46b4b84425fe3981e51598aabce7 + FirebaseFirestore: c55b29bb38afeed2fe73c241e55c7f8230ba7abc Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 GoogleUtilities: bad72cb363809015b1f7f19beb1f1cd23c589f95 GTMSessionFetcher: 3a63d75eecd6aa32c2fc79f578064e1214dfdec2 diff --git a/lib/src/features/entries/application/entries_service.dart b/lib/src/features/entries/application/entries_service.dart index 354a9ea0..7137199b 100644 --- a/lib/src/features/entries/application/entries_service.dart +++ b/lib/src/features/entries/application/entries_service.dart @@ -18,7 +18,7 @@ class EntriesService { /// combine List, List into List Stream> _allEntriesStream(UserID uid) => CombineLatestStream.combine2( - database.entriesStream(uid: uid), + database.watchEntries(uid: uid), database.watchJobs(uid: uid), _entriesJobsCombiner, ); diff --git a/lib/src/features/jobs/data/firestore_repository.dart b/lib/src/features/jobs/data/firestore_repository.dart index 445dffc8..9edeff26 100644 --- a/lib/src/features/jobs/data/firestore_repository.dart +++ b/lib/src/features/jobs/data/firestore_repository.dart @@ -32,7 +32,7 @@ class FirestoreRepository { Future deleteJob({required UserID uid, required Job job}) async { // delete where entry.jobId == job.jobId - final allEntries = await entriesStream(uid: uid, job: job).first; + final allEntries = await watchEntries(uid: uid, job: job).first; for (final entry in allEntries) { if (entry.jobId == job.id) { await deleteEntry(uid: uid, entry: entry); @@ -69,7 +69,7 @@ class FirestoreRepository { Future deleteEntry({required UserID uid, required Entry entry}) => _dataSource.deleteData(path: FirestorePath.entry(uid, entry.id)); - Stream> entriesStream({required UserID uid, Job? job}) => + Stream> watchEntries({required UserID uid, Job? job}) => _dataSource.watchCollection( path: FirestorePath.entries(uid), queryBuilder: job != null @@ -110,5 +110,5 @@ final jobEntriesStreamProvider = throw AssertionError('User can\'t be null when fetching jobs'); } final database = ref.watch(databaseProvider); - return database.entriesStream(uid: user.uid, job: job); + return database.watchEntries(uid: user.uid, job: job); }); diff --git a/lib/src/utils/async_value_ui.dart b/lib/src/utils/async_value_ui.dart index 95238401..4fc1f997 100644 --- a/lib/src/utils/async_value_ui.dart +++ b/lib/src/utils/async_value_ui.dart @@ -5,8 +5,8 @@ import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.da extension AsyncValueUI on AsyncValue { void showAlertDialogOnError(BuildContext context) { - debugPrint('isRefreshing: $isRefreshing, hasError: $hasError'); - if (!isRefreshing && hasError) { + debugPrint('isLoading: $isLoading, hasError: $hasError'); + if (!isLoading && hasError) { final message = error.toString(); showExceptionAlertDialog( context: context, diff --git a/pubspec.lock b/pubspec.lock index cccc77ad..1bb4623b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -330,7 +330,7 @@ packages: name: go_router url: "https://pub.dartlang.org" source: hosted - version: "5.1.6" + version: "5.1.1" graphs: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e6c9600b..c5f88253 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: flutter_riverpod: ^2.1.1 flutter_svg: ^1.1.6 freezed_annotation: ^2.2.0 - go_router: ^5.1.1 + go_router: 5.1.1 intl: ^0.17.0 logger: ^1.1.0 rxdart: ^0.27.6 From 9fc421d59ad0a72db8052271e9fcf3cc45b8d9bb Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Tue, 29 Nov 2022 14:59:27 +0000 Subject: [PATCH 35/45] Fix to prevent too many redirects on app startup --- lib/main.dart | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/main.dart b/lib/main.dart index d9bd4be0..7dab2a26 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:starter_architecture_flutter_firebase/firebase_options.dart'; import 'package:starter_architecture_flutter_firebase/src/app.dart'; +import 'package:starter_architecture_flutter_firebase/src/features/authentication/data/firebase_auth_repository.dart'; import 'package:starter_architecture_flutter_firebase/src/localization/string_hardcoded.dart'; import 'package:starter_architecture_flutter_firebase/src/features/onboarding/data/onboarding_repository.dart'; @@ -19,12 +20,19 @@ Future main() async { // * https://docs.flutter.dev/testing/errors registerErrorHandlers(); // * Entry point of the app - runApp(ProviderScope( + + final container = ProviderContainer( overrides: [ onboardingRepositoryProvider.overrideWithValue( OnboardingRepository(sharedPreferences), ), ], + ); + // await until auth state is determined + // this will prevent unnecessary redirects inside GoRouter when the app starts + await container.read(authStateChangesProvider.future); + runApp(UncontrolledProviderScope( + container: container, child: MyApp(), )); } From add3efcd82ba4f9d6b814d567e593be86eda8ea0 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Sat, 7 Jan 2023 15:44:18 +0000 Subject: [PATCH 36/45] Delete old tests --- test/mocks.dart | 16 ----- test/onboarding_view_model_test.dart | 26 -------- test/shared_preferences_service_test.dart | 41 ------------ test/sign_in_page_test.dart | 62 ------------------- test/sign_in_view_model_test.dart | 50 --------------- .../account_screen_controller_test.dart | 58 +++++++++++------ .../features/jobs/domain}/job_test.dart | 0 test/widget_test.dart | 30 --------- 8 files changed, 39 insertions(+), 244 deletions(-) delete mode 100644 test/mocks.dart delete mode 100644 test/onboarding_view_model_test.dart delete mode 100644 test/shared_preferences_service_test.dart delete mode 100644 test/sign_in_page_test.dart delete mode 100644 test/sign_in_view_model_test.dart rename test/{ => src/features/jobs/domain}/job_test.dart (100%) delete mode 100644 test/widget_test.dart diff --git a/test/mocks.dart b/test/mocks.dart deleted file mode 100644 index 5c53ef28..00000000 --- a/test/mocks.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:firebase_auth/firebase_auth.dart'; -import 'package:flutter/material.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:starter_architecture_flutter_firebase/services/shared_preferences_service.dart'; - -class MockFirebaseAuth extends Mock implements FirebaseAuth {} - -class MockUserCredential extends Mock implements UserCredential {} - -class MockNavigatorObserver extends Mock implements NavigatorObserver {} - -class MockSharedPreferences extends Mock implements SharedPreferences {} - -class MockSharedPreferencesService extends Mock - implements SharedPreferencesService {} diff --git a/test/onboarding_view_model_test.dart b/test/onboarding_view_model_test.dart deleted file mode 100644 index b7bc2f1c..00000000 --- a/test/onboarding_view_model_test.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:starter_architecture_flutter_firebase/app/onboarding/onboarding_view_model.dart'; -import 'package:mocktail/mocktail.dart'; -import 'mocks.dart'; - -void main() { - group('OnboardingViewModel', () { - test('OnboardingViewModel loads state from service', () { - final mockService = MockSharedPreferencesService(); - when(() => mockService.isOnboardingComplete()).thenReturn(true); - final vm = OnboardingViewModel(mockService); - expect(vm.isOnboardingComplete, true); - }); - - test('OnboardingViewModel.completeOnboarding() sets state', () async { - final mockService = MockSharedPreferencesService(); - when(() => mockService.isOnboardingComplete()).thenReturn(false); - when(() => mockService.setOnboardingComplete()) - .thenAnswer((_) => Future.value()); - final vm = OnboardingViewModel(mockService); - await vm.completeOnboarding(); - expect(vm.isOnboardingComplete, true); - verify(() => mockService.setOnboardingComplete()).called(1); - }); - }); -} diff --git a/test/shared_preferences_service_test.dart b/test/shared_preferences_service_test.dart deleted file mode 100644 index 3b6d7520..00000000 --- a/test/shared_preferences_service_test.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:starter_architecture_flutter_firebase/services/shared_preferences_service.dart'; -import 'package:mocktail/mocktail.dart'; -import 'mocks.dart'; - -void main() { - group('SharedPreferencesService', () { - test('writes to SharedPreferences', () async { - final preferences = MockSharedPreferences(); - when(() => preferences.setBool( - SharedPreferencesService.onboardingCompleteKey, true)) - .thenAnswer((_) => Future.value(true)); - final service = SharedPreferencesService(preferences); - await service.setOnboardingComplete(); - verify(() => preferences.setBool( - SharedPreferencesService.onboardingCompleteKey, true)); - }); - - test('reads from SharedPreferences (null)', () { - final preferences = MockSharedPreferences(); - when(() => preferences.getBool( - SharedPreferencesService.onboardingCompleteKey)).thenReturn(null); - final service = SharedPreferencesService(preferences); - expect(service.isOnboardingComplete(), false); - }); - test('reads from SharedPreferences (false)', () { - final preferences = MockSharedPreferences(); - when(() => preferences.getBool( - SharedPreferencesService.onboardingCompleteKey)).thenReturn(false); - final service = SharedPreferencesService(preferences); - expect(service.isOnboardingComplete(), false); - }); - test('reads from SharedPreferences (true)', () { - final preferences = MockSharedPreferences(); - when(() => preferences.getBool( - SharedPreferencesService.onboardingCompleteKey)).thenReturn(true); - final service = SharedPreferencesService(preferences); - expect(service.isOnboardingComplete(), true); - }); - }); -} diff --git a/test/sign_in_page_test.dart b/test/sign_in_page_test.dart deleted file mode 100644 index 775761a9..00000000 --- a/test/sign_in_page_test.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:starter_architecture_flutter_firebase/app/top_level_providers.dart'; -import 'package:starter_architecture_flutter_firebase/app/sign_in/sign_in_page.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:starter_architecture_flutter_firebase/routing/app_router.dart'; -import 'mocks.dart'; - -void main() { - setUpAll(() { - registerFallbackValue>( - MaterialPageRoute(builder: (_) => Container())); - }); - - group('sign-in page', () { - late MockFirebaseAuth mockFirebaseAuth; - late MockNavigatorObserver mockNavigatorObserver; - - setUp(() { - mockFirebaseAuth = MockFirebaseAuth(); - mockNavigatorObserver = MockNavigatorObserver(); - }); - - Future pumpSignInPage(WidgetTester tester) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - firebaseAuthProvider.overrideWithValue(mockFirebaseAuth), - ], - child: Consumer(builder: (context, ref, __) { - final firebaseAuth = ref.watch(firebaseAuthProvider); - return MaterialApp( - home: SignInPage(), - onGenerateRoute: (settings) => - AppRouter.onGenerateRoute(settings, firebaseAuth), - navigatorObservers: [mockNavigatorObserver], - ); - }), - ), - ); - // didPush is called once when the widget is first built - verify(() => mockNavigatorObserver.didPush(any(), any())).called(1); - } - - testWidgets('email & password navigation', (tester) async { - await pumpSignInPage(tester); - - final emailPasswordButton = - find.byKey(SignInPageContents.emailPasswordButtonKey); - expect(emailPasswordButton, findsOneWidget); - - await tester.tap(emailPasswordButton); - await tester.pumpAndSettle(); - - verify(() => mockNavigatorObserver.didPush(captureAny(), captureAny())) - .called(1); - }); - }); -} diff --git a/test/sign_in_view_model_test.dart b/test/sign_in_view_model_test.dart deleted file mode 100644 index f133cfc9..00000000 --- a/test/sign_in_view_model_test.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'dart:async'; - -import 'package:starter_architecture_flutter_firebase/app/sign_in/sign_in_view_model.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'mocks.dart'; - -void main() { - late MockFirebaseAuth mockFirebaseAuth; - late SignInViewModel viewModel; - - setUp(() { - mockFirebaseAuth = MockFirebaseAuth(); - viewModel = SignInViewModel(auth: mockFirebaseAuth); - }); - - void stubSignInAnonymouslyReturnsUser() { - when(() => mockFirebaseAuth.signInAnonymously()) - .thenAnswer((_) => Future.value(MockUserCredential())); - } - - void stubSignInAnonymouslyThrows(Exception exception) { - when(() => mockFirebaseAuth.signInAnonymously()).thenThrow(exception); - } - - test( - 'WHEN view model signs in anonymously' - 'AND auth returns valid user' - 'THEN isLoading is false', () async { - stubSignInAnonymouslyReturnsUser(); - - await viewModel.signInAnonymously(); - - expect(viewModel.isLoading, false); - }); - - test( - 'WHEN view model signs in anonymously' - 'AND auth throws an exception' - 'THEN view model throws an exception' - 'THEN isLoading is false', () async { - final exception = PlatformException(code: 'ERROR_MISSING_PERMISSIONS'); - stubSignInAnonymouslyThrows(exception); - - expect(() => viewModel.signInAnonymously(), throwsA(exception)); - - expect(viewModel.isLoading, false); - }); -} diff --git a/test/src/features/authentication/presentation/account/account_screen_controller_test.dart b/test/src/features/authentication/presentation/account/account_screen_controller_test.dart index 8912be17..691ea2a1 100644 --- a/test/src/features/authentication/presentation/account/account_screen_controller_test.dart +++ b/test/src/features/authentication/presentation/account/account_screen_controller_test.dart @@ -18,46 +18,65 @@ void main() { return container; } + setUpAll(() { + registerFallbackValue(const AsyncLoading()); + }); + group('AccountScreenController', () { - test('initial state is AsyncValue.data', () { + test('initial state is AsyncData', () { final authRepository = MockAuthRepository(); + // create the ProviderContainer with the mock auth repository final container = makeProviderContainer(authRepository); + // create a listener final listener = Listener>(); + // listen to the provider and call [listener] whenever its value changes container.listen( accountScreenControllerProvider, listener, fireImmediately: true, ); - // verify initial value from build method - verify(() => listener(null, const AsyncData(null))); - // no more interactions after that + // verify + verify( + // the build method returns a value immediately, so we expect AsyncData + () => listener(null, const AsyncData(null)), + ); + // verify that the listener is no longer called verifyNoMoreInteractions(listener); + // verify that [signInAnonymously] was not called during initialization verifyNever(authRepository.signOut); }); test('signOut success', () async { // setup final authRepository = MockAuthRepository(); + // stub method to return success when(authRepository.signOut).thenAnswer((_) => Future.value()); + // create the ProviderContainer with the mock auth repository final container = makeProviderContainer(authRepository); - final controller = - container.read(accountScreenControllerProvider.notifier); + // create a listener final listener = Listener>(); + // listen to the provider and call [listener] whenever its value changes container.listen( accountScreenControllerProvider, listener, fireImmediately: true, ); + // sto + const data = AsyncData(null); // verify initial value from build method - verify(() => listener(null, const AsyncData(null))); + verify(() => listener(null, data)); // run + final controller = + container.read(accountScreenControllerProvider.notifier); await controller.signOut(); // verify verifyInOrder([ // set loading state - () => listener(const AsyncData(null), const AsyncLoading()), + // * use a matcher since AsyncLoading != AsyncLoading with data + // * https://codewithandrea.com/articles/unit-test-async-notifier-riverpod/ + () => listener(data, any(that: isA())), // data when complete - () => listener(const AsyncLoading(), const AsyncData(null)), + () => listener(any(that: isA()), data), ]); verifyNoMoreInteractions(listener); verify(authRepository.signOut).called(1); @@ -65,33 +84,34 @@ void main() { test('signOut failure', () async { // setup final authRepository = MockAuthRepository(); + // stub method to return success final exception = Exception('Connection failed'); when(authRepository.signOut).thenThrow(exception); + // create the ProviderContainer with the mock auth repository final container = makeProviderContainer(authRepository); - final controller = - container.read(accountScreenControllerProvider.notifier); + // create a listener final listener = Listener>(); + // listen to the provider and call [listener] whenever its value changes container.listen( accountScreenControllerProvider, listener, fireImmediately: true, ); + const data = AsyncData(null); // verify initial value from build method - verify(() => listener(null, const AsyncData(null))); + verify(() => listener(null, data)); // run + final controller = + container.read(accountScreenControllerProvider.notifier); await controller.signOut(); // verify verifyInOrder([ // set loading state - () => listener(const AsyncData(null), const AsyncLoading()), + // * use a matcher since AsyncLoading != AsyncLoading with data + () => listener(data, any(that: isA())), // error when complete () => listener( - const AsyncLoading(), - any(that: predicate>((value) { - expect(value.hasError, true); - return true; - })), - ), + any(that: isA()), any(that: isA())), ]); verifyNoMoreInteractions(listener); verify(authRepository.signOut).called(1); diff --git a/test/job_test.dart b/test/src/features/jobs/domain/job_test.dart similarity index 100% rename from test/job_test.dart rename to test/src/features/jobs/domain/job_test.dart diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index 35b2a9f9..00000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:starter_architecture_flutter_firebase/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -} From 295f0020c80c551d0c62643edaa73ac31b46477c Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Sat, 7 Jan 2023 15:47:36 +0000 Subject: [PATCH 37/45] Update .gitignore and commit launch.json --- .gitignore | 8 +++++--- .vscode/launch.json | 25 +++++++++++++++++++++++++ ios/firebase_app_id_file.json | 7 ------- 3 files changed, 30 insertions(+), 10 deletions(-) create mode 100644 .vscode/launch.json delete mode 100644 ios/firebase_app_id_file.json diff --git a/.gitignore b/.gitignore index ae6f670b..bd62e555 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,7 @@ .idea/ # Visual Studio Code related -.vscode/ +#.vscode/ # Flutter repo-specific /bin/cache/ @@ -96,7 +96,9 @@ web/firebase-config.js !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages # Firebase configuration files -ios/Runner/GoogleService-Info.plist -android/app/google-services.json lib/firebase_options.dart +ios/Runner/GoogleService-Info.plist ios/firebase_app_id_file.json +macos/Runner/GoogleService-Info.plist +macos/firebase_app_id_file.json +android/app/google-services.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..6a64ca34 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,25 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Run", + "request": "launch", + "type": "dart" + }, + { + "name": "Run (profile mode)", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "Run (release mode)", + "request": "launch", + "type": "dart", + "flutterMode": "release" + } + ] +} \ No newline at end of file diff --git a/ios/firebase_app_id_file.json b/ios/firebase_app_id_file.json deleted file mode 100644 index 3b1eef50..00000000 --- a/ios/firebase_app_id_file.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "file_generated_by": "FlutterFire CLI", - "purpose": "FirebaseAppID & ProjectID for this Firebase app in this directory", - "GOOGLE_APP_ID": "1:204483935261:ios:df913fb4eeda0a29779af4", - "FIREBASE_PROJECT_ID": "starter-architecture-flutter", - "GCM_SENDER_ID": "204483935261" -} \ No newline at end of file From e6ca2c57561fd6c9ef4f2d6dad2abd86dd461190 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Sat, 7 Jan 2023 15:53:02 +0000 Subject: [PATCH 38/45] Make app runnable on Android (gradle fixes) --- android/app/build.gradle | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 89300300..848684db 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -29,7 +29,7 @@ apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion flutter.compileSdkVersion + compileSdkVersion localProperties.getProperty('flutter.compileSdkVersion').toInteger() ndkVersion flutter.ndkVersion compileOptions { @@ -50,8 +50,8 @@ android { applicationId "com.example.starter_architecture_flutter_firebase" // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration. - minSdkVersion flutter.minSdkVersion - targetSdkVersion flutter.targetSdkVersion + minSdkVersion localProperties.getProperty('flutter.minSdkVersion').toInteger() + targetSdkVersion localProperties.getProperty('flutter.targetSdkVersion').toInteger() versionCode flutterVersionCode.toInteger() versionName flutterVersionName } From 6192c61d39395bb0e1271908c3ffdb9c3cc2b660 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Sat, 7 Jan 2023 16:01:34 +0000 Subject: [PATCH 39/45] Updated linter rules and fixed analysis warnings --- analysis_options.yaml | 103 +++--------------- lib/src/app.dart | 4 +- lib/src/common_widgets/avatar.dart | 1 + .../common_widgets/list_items_builder.dart | 2 +- lib/src/common_widgets/segmented_control.dart | 1 + .../presentation/account/account_screen.dart | 2 + .../presentation/sign_in/sign_in_screen.dart | 6 +- .../entries/presentation/entries_screen.dart | 4 +- .../jobs/data/firestore_data_source.dart | 2 +- lib/src/features/jobs/domain/job.dart | 2 +- .../edit_job_screen/edit_job_screen.dart | 4 +- .../edit_job_screen_controller.dart | 2 +- .../edit_job_screen/job_submit_exception.dart | 2 +- .../entry_screen/entry_screen.dart | 4 +- .../job_entries_screen/entry_list_item.dart | 2 + .../job_entries_screen/job_entries_list.dart | 2 +- .../job_entries_screen.dart | 2 +- .../presentation/jobs_screen/jobs_screen.dart | 2 + .../presentation/onboarding_controller.dart | 2 +- .../presentation/onboarding_screen.dart | 3 + lib/src/routing/app_router.dart | 12 +- pubspec.lock | 14 +++ pubspec.yaml | 1 + 23 files changed, 69 insertions(+), 110 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index 6332f066..61b6c4de 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -9,92 +9,21 @@ # packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml -analyzer: - strong-mode: - implicit-casts: false - implicit-dynamic: false - errors: - # Otherwise cause the import of all_lint_rules to warn because of some rules conflicts. - # The conflicts are fixed in this file instead, so we can safely ignore the warning. - included_file_warning: ignore - # treat missing required parameters as a warning (not a hint) - missing_required_param: warning - # treat missing returns as a warning (not a hint) - missing_return: warning - # allow having TODOs in the code - todo: ignore - # Ignore analyzer hints for updating pubspecs when using Future or - # Stream and not importing dart:async - # Please see https://github.com/flutter/flutter/pull/24528 for details. - sdk_version_async_exported_from_core: ignore - # Custom errors to be ignored - implicit_dynamic_type: ignore - #invalid_assignment: ignore - implicit_dynamic_map_literal: ignore - always_put_control_body_on_new_line: ignore - exclude: - - "bin/cache/**" - # the following two are relative to the stocks example and the flutter package respectively - # see https://github.com/dart-lang/sdk/issues/28463 - - "lib/i18n/messages_*.dart" - - "lib/src/http/**" - linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at + # https://dart-lang.github.io/linter/lints/index.html. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. rules: - # navigator.pop inside a closure requires this - unnecessary_lambdas: false - # It's ok - cascade_invocations: false - # Ignored in tests - missing_whitespace_between_adjacent_strings: false - # In the future - type_annotate_public_apis: false - # It's ok - avoid_print: false - # Not including author name in small projects - flutter_style_todos: false - # Sometimes used - avoid_as: false - # Not sure how to address this - use_raw_strings: false - # Need to fix this - comment_references: false - # Ok to use a class - one_member_abstracts: false - # Need to fix this - avoid_annotating_with_dynamic: false - # Disabled for type inference - always_specify_types: false - # non-required Key often precedes required named parameters - always_put_required_named_parameters_first: false - # Catch all often used in project - avoid_catches_without_on_clauses: false - # Ok to be explicit - avoid_redundant_argument_values: false - # Still some instances of this in the project - lines_longer_than_80_chars: false - # Often used in local variables - unnecessary_final: false - # Not always done with factory constructors - sort_constructors_first: false - # Ok to be explicit - omit_local_variable_types: false - # For build methods - prefer_expression_function_bodies: false - # I use double out of habit - prefer_int_literals: false - # sometimes using `with` syntax - prefer_mixin: false - # Codebase uses mostly single quotes - avoid_escaping_inner_quotes: false - prefer_double_quotes: false - # Need to fix this sometime (easier to copy paste files across projects) - prefer_relative_imports: false - # A lot of documentation missing - public_member_api_docs: false - # Not enforced - sort_child_properties_last: false - # Many constructors not declared for widgets that take no arguments - use_key_in_widget_constructors: false - # Disable: DO reference all public properties in debug method implementations. - diagnostic_describe_all_properties: false + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/lib/src/app.dart b/lib/src/app.dart index efee48d9..4103c8a4 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; class MyApp extends ConsumerWidget { + const MyApp({super.key}); + @override Widget build(BuildContext context, WidgetRef ref) { final goRouter = ref.watch(goRouterProvider); @@ -11,7 +13,7 @@ class MyApp extends ConsumerWidget { theme: ThemeData( primarySwatch: Colors.indigo, unselectedWidgetColor: Colors.grey, - appBarTheme: AppBarTheme( + appBarTheme: const AppBarTheme( elevation: 2.0, centerTitle: true, ), diff --git a/lib/src/common_widgets/avatar.dart b/lib/src/common_widgets/avatar.dart index 91dcac1f..d4f1d169 100644 --- a/lib/src/common_widgets/avatar.dart +++ b/lib/src/common_widgets/avatar.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; class Avatar extends StatelessWidget { const Avatar({ + super.key, this.photoUrl, required this.radius, this.borderColor, diff --git a/lib/src/common_widgets/list_items_builder.dart b/lib/src/common_widgets/list_items_builder.dart index bbee2b61..d4866ca0 100644 --- a/lib/src/common_widgets/list_items_builder.dart +++ b/lib/src/common_widgets/list_items_builder.dart @@ -22,7 +22,7 @@ class ListItemsBuilder extends StatelessWidget { separatorBuilder: (context, index) => const Divider(height: 0.5), itemBuilder: (context, index) { if (index == 0 || index == items.length + 1) { - return SizedBox.shrink(); + return const SizedBox.shrink(); } return itemBuilder(context, items[index - 1]); }, diff --git a/lib/src/common_widgets/segmented_control.dart b/lib/src/common_widgets/segmented_control.dart index 4422906e..c3486343 100644 --- a/lib/src/common_widgets/segmented_control.dart +++ b/lib/src/common_widgets/segmented_control.dart @@ -2,6 +2,7 @@ import 'package:flutter/cupertino.dart'; class SegmentedControl extends StatelessWidget { const SegmentedControl({ + super.key, required this.header, required this.value, required this.children, diff --git a/lib/src/features/authentication/presentation/account/account_screen.dart b/lib/src/features/authentication/presentation/account/account_screen.dart index 4c47f091..25b31c0a 100644 --- a/lib/src/features/authentication/presentation/account/account_screen.dart +++ b/lib/src/features/authentication/presentation/account/account_screen.dart @@ -9,6 +9,8 @@ import 'package:starter_architecture_flutter_firebase/src/utils/alert_dialogs.da import 'package:starter_architecture_flutter_firebase/src/utils/async_value_ui.dart'; class AccountScreen extends ConsumerWidget { + const AccountScreen({super.key}); + @override Widget build(BuildContext context, WidgetRef ref) { ref.listen( diff --git a/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart b/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart index b580b121..260ee8de 100644 --- a/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart +++ b/lib/src/features/authentication/presentation/sign_in/sign_in_screen.dart @@ -25,7 +25,7 @@ class SignInScreen extends ConsumerWidget { final state = ref.watch(signInScreenControllerProvider); return Scaffold( appBar: AppBar( - title: Text('Sign In'), + title: const Text('Sign In'), ), body: Center( child: LayoutBuilder(builder: (context, constraints) { @@ -41,8 +41,8 @@ class SignInScreen extends ConsumerWidget { SizedBox( height: 50.0, child: state.isLoading - ? Center(child: CircularProgressIndicator()) - : Text( + ? const Center(child: CircularProgressIndicator()) + : const Text( Strings.signIn, textAlign: TextAlign.center, style: TextStyle( diff --git a/lib/src/features/entries/presentation/entries_screen.dart b/lib/src/features/entries/presentation/entries_screen.dart index a4bc3b03..73e9571e 100644 --- a/lib/src/features/entries/presentation/entries_screen.dart +++ b/lib/src/features/entries/presentation/entries_screen.dart @@ -6,6 +6,8 @@ import 'package:starter_architecture_flutter_firebase/src/features/entries/appli import 'package:starter_architecture_flutter_firebase/src/common_widgets/list_items_builder.dart'; class EntriesScreen extends ConsumerWidget { + const EntriesScreen({super.key}); + @override Widget build(BuildContext context, WidgetRef ref) { return Scaffold( @@ -27,7 +29,7 @@ class EntriesScreen extends ConsumerWidget { } class EntriesListTile extends StatelessWidget { - const EntriesListTile({required this.model}); + const EntriesListTile({super.key, required this.model}); final EntriesListTileModel model; @override diff --git a/lib/src/features/jobs/data/firestore_data_source.dart b/lib/src/features/jobs/data/firestore_data_source.dart index 9ad7665d..d6874cd9 100644 --- a/lib/src/features/jobs/data/firestore_data_source.dart +++ b/lib/src/features/jobs/data/firestore_data_source.dart @@ -89,5 +89,5 @@ class FirestoreDataSource { } final firestoreDataSourceProvider = Provider((ref) { - return FirestoreDataSource._(); + return const FirestoreDataSource._(); }); diff --git a/lib/src/features/jobs/domain/job.dart b/lib/src/features/jobs/domain/job.dart index 8350b3b2..8e5bdc3e 100644 --- a/lib/src/features/jobs/domain/job.dart +++ b/lib/src/features/jobs/domain/job.dart @@ -1,5 +1,5 @@ import 'package:equatable/equatable.dart'; -import 'package:meta/meta.dart'; +import 'package:flutter/foundation.dart'; typedef JobID = String; diff --git a/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen.dart b/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen.dart index ce76a1f1..55c83055 100644 --- a/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen.dart +++ b/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen.dart @@ -48,7 +48,7 @@ class _EditJobPageState extends ConsumerState { name: _name ?? '', ratePerHour: _ratePerHour ?? 0, ); - if (success) { + if (success && mounted) { context.pop(); } } @@ -66,11 +66,11 @@ class _EditJobPageState extends ConsumerState { title: Text(widget.job == null ? 'New Job' : 'Edit Job'), actions: [ TextButton( + onPressed: state.isLoading ? null : _submit, child: const Text( 'Save', style: TextStyle(fontSize: 18, color: Colors.white), ), - onPressed: state.isLoading ? null : _submit, ), ], ), diff --git a/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen_controller.dart b/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen_controller.dart index 1281097b..6f65055e 100644 --- a/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen_controller.dart +++ b/lib/src/features/jobs/presentation/edit_job_screen/edit_job_screen_controller.dart @@ -19,7 +19,7 @@ class EditJobScreenController extends AutoDisposeAsyncNotifier { throw AssertionError('User can\'t be null'); } // set loading state - state = AsyncLoading().copyWithPrevious(state); + state = const AsyncLoading().copyWithPrevious(state); // check if name is already in use final database = ref.read(databaseProvider); final jobs = await database.fetchJobs(uid: currentUser.uid); diff --git a/lib/src/features/jobs/presentation/edit_job_screen/job_submit_exception.dart b/lib/src/features/jobs/presentation/edit_job_screen/job_submit_exception.dart index aef6f19a..e8f19329 100644 --- a/lib/src/features/jobs/presentation/edit_job_screen/job_submit_exception.dart +++ b/lib/src/features/jobs/presentation/edit_job_screen/job_submit_exception.dart @@ -4,6 +4,6 @@ class JobSubmitException { @override String toString() { - return '${title}. ${description}.'; + return '$title. $description.'; } } diff --git a/lib/src/features/jobs/presentation/entry_screen/entry_screen.dart b/lib/src/features/jobs/presentation/entry_screen/entry_screen.dart index 407f16f8..c8aa219e 100644 --- a/lib/src/features/jobs/presentation/entry_screen/entry_screen.dart +++ b/lib/src/features/jobs/presentation/entry_screen/entry_screen.dart @@ -12,7 +12,7 @@ import 'package:starter_architecture_flutter_firebase/src/utils/async_value_ui.d import 'package:starter_architecture_flutter_firebase/src/utils/format.dart'; class EntryScreen extends ConsumerStatefulWidget { - const EntryScreen({required this.jobId, this.entryId, this.entry}); + const EntryScreen({super.key, required this.jobId, this.entryId, this.entry}); final JobID jobId; final EntryID? entryId; final Entry? entry; @@ -61,7 +61,7 @@ class _EntryPageState extends ConsumerState { final entry = _entryFromState(); final success = await ref.read(entryScreenControllerProvider.notifier).setEntry(entry); - if (success) { + if (success && mounted) { context.pop(); } } diff --git a/lib/src/features/jobs/presentation/job_entries_screen/entry_list_item.dart b/lib/src/features/jobs/presentation/job_entries_screen/entry_list_item.dart index 1c962a89..76903629 100644 --- a/lib/src/features/jobs/presentation/job_entries_screen/entry_list_item.dart +++ b/lib/src/features/jobs/presentation/job_entries_screen/entry_list_item.dart @@ -5,6 +5,7 @@ import 'package:starter_architecture_flutter_firebase/src/features/jobs/domain/j class EntryListItem extends StatelessWidget { const EntryListItem({ + super.key, required this.entry, required this.job, this.onTap, @@ -77,6 +78,7 @@ class EntryListItem extends StatelessWidget { class DismissibleEntryListItem extends StatelessWidget { const DismissibleEntryListItem({ + super.key, required this.dismissibleKey, required this.entry, required this.job, diff --git a/lib/src/features/jobs/presentation/job_entries_screen/job_entries_list.dart b/lib/src/features/jobs/presentation/job_entries_screen/job_entries_list.dart index f4b15b62..ab4b1a05 100644 --- a/lib/src/features/jobs/presentation/job_entries_screen/job_entries_list.dart +++ b/lib/src/features/jobs/presentation/job_entries_screen/job_entries_list.dart @@ -11,7 +11,7 @@ import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dar import 'package:starter_architecture_flutter_firebase/src/utils/async_value_ui.dart'; class JobEntriesList extends ConsumerWidget { - const JobEntriesList({required this.job}); + const JobEntriesList({super.key, required this.job}); final Job job; @override diff --git a/lib/src/features/jobs/presentation/job_entries_screen/job_entries_screen.dart b/lib/src/features/jobs/presentation/job_entries_screen/job_entries_screen.dart index b470edd9..be0c3232 100644 --- a/lib/src/features/jobs/presentation/job_entries_screen/job_entries_screen.dart +++ b/lib/src/features/jobs/presentation/job_entries_screen/job_entries_screen.dart @@ -8,7 +8,7 @@ import 'package:starter_architecture_flutter_firebase/src/features/jobs/presenta import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; class JobEntriesScreen extends ConsumerWidget { - const JobEntriesScreen({required this.jobId}); + const JobEntriesScreen({super.key, required this.jobId}); final JobID jobId; @override diff --git a/lib/src/features/jobs/presentation/jobs_screen/jobs_screen.dart b/lib/src/features/jobs/presentation/jobs_screen/jobs_screen.dart index a9d01c8c..515ca8fd 100644 --- a/lib/src/features/jobs/presentation/jobs_screen/jobs_screen.dart +++ b/lib/src/features/jobs/presentation/jobs_screen/jobs_screen.dart @@ -10,6 +10,8 @@ import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dar import 'package:starter_architecture_flutter_firebase/src/utils/async_value_ui.dart'; class JobsScreen extends StatelessWidget { + const JobsScreen({super.key}); + @override Widget build(BuildContext context) { return Scaffold( diff --git a/lib/src/features/onboarding/presentation/onboarding_controller.dart b/lib/src/features/onboarding/presentation/onboarding_controller.dart index 768889d5..0b0c88c0 100644 --- a/lib/src/features/onboarding/presentation/onboarding_controller.dart +++ b/lib/src/features/onboarding/presentation/onboarding_controller.dart @@ -11,7 +11,7 @@ class OnboardingController extends AutoDisposeAsyncNotifier { Future completeOnboarding() async { final onboardingRepository = ref.watch(onboardingRepositoryProvider); - state = AsyncLoading(); + state = const AsyncLoading(); state = await AsyncValue.guard(onboardingRepository.setOnboardingComplete); } } diff --git a/lib/src/features/onboarding/presentation/onboarding_screen.dart b/lib/src/features/onboarding/presentation/onboarding_screen.dart index 8592d511..fc9e5943 100644 --- a/lib/src/features/onboarding/presentation/onboarding_screen.dart +++ b/lib/src/features/onboarding/presentation/onboarding_screen.dart @@ -8,6 +8,8 @@ import 'package:starter_architecture_flutter_firebase/src/localization/string_ha import 'package:starter_architecture_flutter_firebase/src/routing/app_router.dart'; class OnboardingScreen extends ConsumerWidget { + const OnboardingScreen({super.key}); + @override Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(onboardingControllerProvider); @@ -37,6 +39,7 @@ class OnboardingScreen extends ConsumerWidget { await ref .read(onboardingControllerProvider.notifier) .completeOnboarding(); + // TODO: Check if mounted // go to sign in page after completing onboarding context.goNamed(AppRoute.signIn.name); }, diff --git a/lib/src/routing/app_router.dart b/lib/src/routing/app_router.dart index 43ea3164..de559027 100644 --- a/lib/src/routing/app_router.dart +++ b/lib/src/routing/app_router.dart @@ -74,7 +74,7 @@ final goRouterProvider = Provider((ref) { name: AppRoute.onboarding.name, pageBuilder: (context, state) => NoTransitionPage( key: state.pageKey, - child: OnboardingScreen(), + child: const OnboardingScreen(), ), ), GoRoute( @@ -91,7 +91,7 @@ final goRouterProvider = Provider((ref) { pageBuilder: (context, state) => MaterialPage( key: state.pageKey, fullscreenDialog: true, - child: EmailPasswordSignInScreen( + child: const EmailPasswordSignInScreen( formType: EmailPasswordSignInFormType.signIn, ), ), @@ -109,7 +109,7 @@ final goRouterProvider = Provider((ref) { name: AppRoute.jobs.name, pageBuilder: (context, state) => NoTransitionPage( key: state.pageKey, - child: JobsScreen(), + child: const JobsScreen(), ), routes: [ GoRoute( @@ -120,7 +120,7 @@ final goRouterProvider = Provider((ref) { return MaterialPage( key: state.pageKey, fullscreenDialog: true, - child: EditJobScreen(), + child: const EditJobScreen(), ); }, ), @@ -189,7 +189,7 @@ final goRouterProvider = Provider((ref) { name: AppRoute.entries.name, pageBuilder: (context, state) => NoTransitionPage( key: state.pageKey, - child: EntriesScreen(), + child: const EntriesScreen(), ), ), GoRoute( @@ -197,7 +197,7 @@ final goRouterProvider = Provider((ref) { name: AppRoute.account.name, pageBuilder: (context, state) => NoTransitionPage( key: state.pageKey, - child: AccountScreen(), + child: const AccountScreen(), ), ), ], diff --git a/pubspec.lock b/pubspec.lock index 1bb4623b..1bc41565 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -272,6 +272,13 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" flutter_riverpod: dependency: "direct main" description: @@ -380,6 +387,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.7.0" + lints: + dependency: transitive + description: + name: lints + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" logger: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index c5f88253..f0e5950e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,6 +31,7 @@ dev_dependencies: freezed: ^2.2.0 mocktail: ^0.3.0 random_string: + flutter_lints: ^2.0.0 # https://stackoverflow.com/a/74234079/436422 #dependency_overrides: From c3b19d31d2a38577f3b2ae285d536644cd7a3f6f Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Sat, 7 Jan 2023 16:01:45 +0000 Subject: [PATCH 40/45] Removed old flutter driver tests --- test_driver/app.dart | 34 ---- test_driver/app_test.dart | 71 ------- test_driver/fake_auth_service.dart | 287 ----------------------------- 3 files changed, 392 deletions(-) delete mode 100644 test_driver/app.dart delete mode 100644 test_driver/app_test.dart delete mode 100644 test_driver/fake_auth_service.dart diff --git a/test_driver/app.dart b/test_driver/app.dart deleted file mode 100644 index 487ac679..00000000 --- a/test_driver/app.dart +++ /dev/null @@ -1,34 +0,0 @@ -// import 'package:firebase_core/firebase_core.dart'; -// import 'package:flutter_riverpod/flutter_riverpod.dart'; -// import 'package:mockito/mockito.dart'; -// import 'package:starter_architecture_flutter_firebase/app/top_level_providers.dart'; -// import 'package:starter_architecture_flutter_firebase/main.dart'; -// import 'package:flutter/material.dart'; -// import 'package:flutter_driver/driver_extension.dart'; -// import 'package:starter_architecture_flutter_firebase/services/firestore_database.dart'; - -// import 'fake_auth_service.dart'; - -// class MockDatabase extends Mock implements FirestoreDatabase {} - -// // Run with: -// // flutter drive --target=test_driver/app.dart -// Future main() async { -// // This line enables the extension. -// enableFlutterDriverExtension(); - -// // TODO: Somehow Firebase.initializeApp() is required when running this driver test -// // Need to figure out what code path triggers this as both FirebaseAuth and Firestore are mocked -// WidgetsFlutterBinding.ensureInitialized(); -// await Firebase.initializeApp(); -// // Call the `main()` function of the app, or call `runApp` with -// // any widget you are interested in testing. -// runApp(ProviderScope( -// overrides: [ -// firebaseAuthProvider -// .overrideWithProvider(Provider((ref) => FakeAuthService())), -// databaseProvider.overrideWithProvider(Provider((ref) => MockDatabase())), -// ], -// child: MyApp(), -// )); -// } diff --git a/test_driver/app_test.dart b/test_driver/app_test.dart deleted file mode 100644 index d430c27f..00000000 --- a/test_driver/app_test.dart +++ /dev/null @@ -1,71 +0,0 @@ -// // Some introductory articles about integration tests: -// // http://cogitas.net/write-integration-test-flutter/ -// // https://medium.com/flutter-community/testing-flutter-ui-with-flutter-driver-c1583681e337 -// // https://stackoverflow.com/questions/52462646/how-to-solve-not-found-dartui-error-while-running-integration-tests-on-flutt -// // -// // Issues with opening the drawer with flutter driver -// // https://github.com/flutter/flutter/issues/9002 -// // -// // Rules: -// // - Don't import any flutter code (e.g. material.dart) -// // - Don't import flutter_test.dart - -// import 'package:starter_architecture_flutter_firebase/constants/keys.dart'; -// // Imports the Flutter Driver API. -// import 'package:flutter_driver/flutter_driver.dart'; -// import 'package:test/test.dart'; - -// void main() { -// FlutterDriver driver; -// Future delay([int milliseconds = 250]) async { -// await Future.delayed(Duration(milliseconds: milliseconds)); -// } - -// // Connect to the Flutter driver before running any tests. -// setUpAll(() async { -// driver = await FlutterDriver.connect(); -// }); - -// // Close the connection to the driver after the tests have completed. -// tearDownAll(() async { -// if (driver != null) { -// await driver.close(); -// } -// }); - -// test('check flutter driver health', () async { -// final health = await driver.checkHealth(); -// expect(health.status, HealthStatus.ok); -// }); - -// test('sign in anonymously, sign out', () async { -// // find and tap anonymous sign in button -// final anonymousSignInButton = find.byValueKey(Keys.anonymous); -// // Check to fail early if the auth state is authenticated -// await driver.waitFor(anonymousSignInButton); -// await delay(1000); // for video capture -// await driver.tap(anonymousSignInButton); - -// // Find tab bar and tap on account tab -// // TODO This does not work. See: https://stackoverflow.com/questions/55460993/flutter-driver-test-bottomnavigationbaritem -// final tabBar = find.byValueKey(Keys.tabBar); -// await driver.waitFor(tabBar); -// await delay(1000); // for video capture -// await driver.tap(find.byValueKey(Keys.accountTab)); - -// // find and tap logout button -// final logoutButton = find.byValueKey(Keys.logout); -// await driver.waitFor(logoutButton); -// await delay(1000); // for video capture -// await driver.tap(logoutButton); - -// // find and tap confirm logout button -// final confirmLogoutButton = find.byValueKey(Keys.alertDefault); -// await driver.waitFor(confirmLogoutButton); -// await delay(1000); // for video capture -// await driver.tap(confirmLogoutButton); - -// // try to find anonymous sign in button again -// await driver.waitFor(anonymousSignInButton); -// }); -// } diff --git a/test_driver/fake_auth_service.dart b/test_driver/fake_auth_service.dart deleted file mode 100644 index cfb5b9dd..00000000 --- a/test_driver/fake_auth_service.dart +++ /dev/null @@ -1,287 +0,0 @@ -// import 'dart:async'; - -// import 'package:firebase_auth/firebase_auth.dart'; -// import 'package:firebase_core/firebase_core.dart'; -// import 'package:firebase_auth_platform_interface/src/auth_provider.dart'; -// import 'package:flutter/services.dart'; -// import 'package:meta/meta.dart'; -// import 'package:mockito/mockito.dart'; -// import 'package:random_string/random_string.dart' as random; - -// class MockUser extends Mock implements User {} - -// class MockUserCredential extends Mock implements UserCredential {} - -// /// Fake authentication service to be used for testing the UI -// /// Keeps an in-memory store of registered accounts so that registration and sign in flows can be tested. -// class FakeAuthService implements FirebaseAuth { -// FakeAuthService({ -// this.startupTime = const Duration(milliseconds: 250), -// this.responseTime = const Duration(seconds: 2), -// }) { -// Future.delayed(responseTime).then((_) { -// _add(null); -// }); -// } -// final Duration startupTime; -// final Duration responseTime; - -// final Map _usersStore = {}; - -// User _currentUser; - -// final StreamController _onAuthStateChangedController = -// StreamController(); -// @override -// Stream authStateChanges() => _onAuthStateChangedController.stream; - -// @override -// User get currentUser => _currentUser; - -// UserCredential _mockUserCredential( -// {String uid, String email, String password}) { -// final user = MockUser(); -// when(user.uid).thenReturn(uid); -// when(user.email).thenReturn(email); -// final userCredential = MockUserCredential(); -// when(userCredential.user).thenReturn(user); -// return userCredential; -// } - -// @override -// Future createUserWithEmailAndPassword( -// {required String email, required String password}) async { -// await Future.delayed(responseTime); -// if (_usersStore.keys.contains(email)) { -// throw PlatformException( -// code: 'ERROR_EMAIL_ALREADY_IN_USE', -// message: 'The email address is already registered. Sign in instead?', -// ); -// } -// final userCredential = _mockUserCredential( -// uid: random.randomAlphaNumeric(32), -// email: email, -// password: password, -// ); -// _usersStore[email] = -// _UserData(password: password, user: userCredential.user); -// _add(userCredential.user); -// return userCredential; -// } - -// @override -// Future signInWithCredential(AuthCredential credential) async { -// if (credential is EmailAuthCredential) { -// return signInWithEmailAndPassword( -// email: credential.email, password: credential.password); -// } -// // TODO: implement signInWithCredential -// throw UnimplementedError(); -// } - -// @override -// Future signInWithEmailAndPassword( -// {String email, String password}) async { -// // TODO: implement signInWithEmailAndPassword -// await Future.delayed(responseTime); -// if (!_usersStore.keys.contains(email)) { -// throw PlatformException( -// code: 'ERROR_USER_NOT_FOUND', -// message: 'The email address is not registered. Need an account?', -// ); -// } -// final _UserData _userData = _usersStore[email]; -// if (_userData.password != password) { -// throw PlatformException( -// code: 'ERROR_WRONG_PASSWORD', -// message: 'The password is incorrect. Please try again.', -// ); -// } -// _add(_userData.user); -// final userCredential = _mockUserCredential( -// uid: _userData.user.uid, -// email: email, -// password: password, -// ); -// return userCredential; -// } - -// @override -// Future signOut() async { -// _add(null); -// } - -// void _add(User user) { -// _currentUser = user; -// _onAuthStateChangedController.add(user); -// } - -// @override -// Future signInAnonymously() async { -// await Future.delayed(responseTime); -// final userCredential = _mockUserCredential( -// uid: random.randomAlphaNumeric(32), -// email: null, -// password: null, -// ); -// _add(userCredential.user); -// return userCredential; -// } - -// void dispose() { -// _onAuthStateChangedController.close(); -// } - -// @override -// FirebaseApp app; - -// @override -// Future applyActionCode(String code) { -// // TODO: implement applyActionCode -// throw UnimplementedError(); -// } - -// @override -// Future checkActionCode(String code) { -// // TODO: implement checkActionCode -// throw UnimplementedError(); -// } - -// @override -// Future confirmPasswordReset({String code, String newPassword}) { -// // TODO: implement confirmPasswordReset -// throw UnimplementedError(); -// } - -// @override -// Future> fetchSignInMethodsForEmail(String email) { -// // TODO: implement fetchSignInMethodsForEmail -// throw UnimplementedError(); -// } - -// @override -// Future getRedirectResult() { -// // TODO: implement getRedirectResult -// throw UnimplementedError(); -// } - -// @override -// Stream idTokenChanges() { -// // TODO: implement idTokenChanges -// throw UnimplementedError(); -// } - -// @override -// bool isSignInWithEmailLink(String emailLink) { -// // TODO: implement isSignInWithEmailLink -// throw UnimplementedError(); -// } - -// @override -// // TODO: implement languageCode -// String get languageCode => throw UnimplementedError(); - -// @override -// // TODO: implement onAuthStateChanged -// Stream get onAuthStateChanged => throw UnimplementedError(); - -// @override -// // TODO: implement pluginConstants -// Map get pluginConstants => throw UnimplementedError(); - -// @override -// Future sendSignInLinkToEmail( -// {String email, ActionCodeSettings actionCodeSettings}) { -// // TODO: implement sendSignInLinkToEmail -// throw UnimplementedError(); -// } - -// @override -// Future setLanguageCode(String languageCode) { -// // TODO: implement setLanguageCode -// throw UnimplementedError(); -// } - -// @override -// Future setPersistence(Persistence persistence) { -// // TODO: implement setPersistence -// throw UnimplementedError(); -// } - -// @override -// Future setSettings( -// {bool appVerificationDisabledForTesting, String userAccessGroup}) { -// // TODO: implement setSettings -// throw UnimplementedError(); -// } - -// @override -// Future signInWithCustomToken(String token) { -// // TODO: implement signInWithCustomToken -// throw UnimplementedError(); -// } - -// @override -// Future signInWithEmailLink({String email, String emailLink}) { -// // TODO: implement signInWithEmailLink -// throw UnimplementedError(); -// } - -// @override -// Future signInWithPhoneNumber( -// String phoneNumber, RecaptchaVerifier verifier) { -// // TODO: implement signInWithPhoneNumber -// throw UnimplementedError(); -// } - -// @override -// Future signInWithPopup(AuthProvider provider) { -// // TODO: implement signInWithPopup -// throw UnimplementedError(); -// } - -// @override -// Future signInWithRedirect(AuthProvider provider) { -// // TODO: implement signInWithRedirect -// throw UnimplementedError(); -// } - -// @override -// Stream userChanges() { -// // TODO: implement userChanges -// throw UnimplementedError(); -// } - -// @override -// Future verifyPasswordResetCode(String code) { -// // TODO: implement verifyPasswordResetCode -// throw UnimplementedError(); -// } - -// @override -// Future verifyPhoneNumber( -// {String phoneNumber, -// verificationCompleted, -// verificationFailed, -// codeSent, -// codeAutoRetrievalTimeout, -// String autoRetrievedSmsCodeForTesting, -// Duration timeout = const Duration(seconds: 30), -// int forceResendingToken}) { -// // TODO: implement verifyPhoneNumber -// throw UnimplementedError(); -// } - -// @override -// Future sendPasswordResetEmail( -// {String email, ActionCodeSettings actionCodeSettings}) { -// // TODO: implement sendPasswordResetEmail -// throw UnimplementedError(); -// } -// } - -// class _UserData { -// _UserData({required this.password, required this.user}); -// final String password; -// final User user; -// } From 3e962637db9d40b9ccc66e5fd928118a9762ebdc Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Sat, 7 Jan 2023 16:02:34 +0000 Subject: [PATCH 41/45] More linter fixes --- lib/generated_plugin_registrant.dart | 22 ---------------------- lib/main.dart | 2 +- 2 files changed, 1 insertion(+), 23 deletions(-) delete mode 100644 lib/generated_plugin_registrant.dart diff --git a/lib/generated_plugin_registrant.dart b/lib/generated_plugin_registrant.dart deleted file mode 100644 index dac9ba6e..00000000 --- a/lib/generated_plugin_registrant.dart +++ /dev/null @@ -1,22 +0,0 @@ -// -// Generated file. Do not edit. -// - -// ignore_for_file: directives_ordering -// ignore_for_file: lines_longer_than_80_chars - -import 'package:cloud_firestore_web/cloud_firestore_web.dart'; -import 'package:firebase_auth_web/firebase_auth_web.dart'; -import 'package:firebase_core_web/firebase_core_web.dart'; -import 'package:shared_preferences_web/shared_preferences_web.dart'; - -import 'package:flutter_web_plugins/flutter_web_plugins.dart'; - -// ignore: public_member_api_docs -void registerPlugins(Registrar registrar) { - FirebaseFirestoreWeb.registerWith(registrar); - FirebaseAuthWeb.registerWith(registrar); - FirebaseCoreWeb.registerWith(registrar); - SharedPreferencesPlugin.registerWith(registrar); - registrar.registerMessageHandler(); -} diff --git a/lib/main.dart b/lib/main.dart index 7dab2a26..0098de20 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -33,7 +33,7 @@ Future main() async { await container.read(authStateChangesProvider.future); runApp(UncontrolledProviderScope( container: container, - child: MyApp(), + child: const MyApp(), )); } From fcf8d1a3d38672c7ffccd4353f2260c2a8e2d905 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Sat, 7 Jan 2023 16:19:12 +0000 Subject: [PATCH 42/45] Removed Freezed and build_runner --- pubspec.lock | 140 --------------------------------------------------- pubspec.yaml | 3 -- 2 files changed, 143 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 1bc41565..6bd5922e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -43,62 +43,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.0" - build: - dependency: transitive - description: - name: build - url: "https://pub.dartlang.org" - source: hosted - version: "2.3.1" - build_config: - dependency: transitive - description: - name: build_config - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.1" - build_daemon: - dependency: transitive - description: - name: build_daemon - url: "https://pub.dartlang.org" - source: hosted - version: "3.1.0" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.10" - build_runner: - dependency: "direct dev" - description: - name: build_runner - url: "https://pub.dartlang.org" - source: hosted - version: "2.3.0" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - url: "https://pub.dartlang.org" - source: hosted - version: "7.2.7" - built_collection: - dependency: transitive - description: - name: built_collection - url: "https://pub.dartlang.org" - source: hosted - version: "5.1.1" - built_value: - dependency: transitive - description: - name: built_value - url: "https://pub.dartlang.org" - source: hosted - version: "8.4.2" characters: dependency: transitive description: @@ -106,13 +50,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.2.1" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" clock: dependency: transitive description: @@ -141,13 +78,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.5" - code_builder: - dependency: transitive - description: - name: code_builder - url: "https://pub.dartlang.org" - source: hosted - version: "4.3.0" collection: dependency: transitive description: @@ -183,13 +113,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.5" - dart_style: - dependency: transitive - description: - name: dart_style - url: "https://pub.dartlang.org" - source: hosted - version: "2.2.4" equatable: dependency: "direct main" description: @@ -260,13 +183,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.1" - fixnum: - dependency: transitive - description: - name: fixnum - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" flutter: dependency: "direct main" description: flutter @@ -303,20 +219,6 @@ packages: description: flutter source: sdk version: "0.0.0" - freezed: - dependency: "direct dev" - description: - name: freezed - url: "https://pub.dartlang.org" - source: hosted - version: "2.2.0" - freezed_annotation: - dependency: "direct main" - description: - name: freezed_annotation - url: "https://pub.dartlang.org" - source: hosted - version: "2.2.0" frontend_server_client: dependency: transitive description: @@ -338,13 +240,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "5.1.1" - graphs: - dependency: transitive - description: - name: graphs - url: "https://pub.dartlang.org" - source: hosted - version: "2.2.0" http_multi_server: dependency: transitive description: @@ -380,13 +275,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.6.4" - json_annotation: - dependency: transitive - description: - name: json_annotation - url: "https://pub.dartlang.org" - source: hosted - version: "4.7.0" lints: dependency: transitive description: @@ -541,13 +429,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.3" - pubspec_parse: - dependency: transitive - description: - name: pubspec_parse - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.1" random_string: dependency: "direct dev" description: @@ -658,13 +539,6 @@ packages: description: flutter source: sdk version: "0.0.99" - source_gen: - dependency: transitive - description: - name: source_gen - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.6" source_map_stack_trace: dependency: transitive description: @@ -707,13 +581,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.0" - stream_transform: - dependency: transitive - description: - name: stream_transform - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" string_scanner: dependency: transitive description: @@ -749,13 +616,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.4.16" - timing: - dependency: transitive - description: - name: timing - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f0e5950e..4421dec9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,6 @@ dependencies: sdk: flutter flutter_riverpod: ^2.1.1 flutter_svg: ^1.1.6 - freezed_annotation: ^2.2.0 go_router: 5.1.1 intl: ^0.17.0 logger: ^1.1.0 @@ -25,10 +24,8 @@ dependencies: shared_preferences: ^2.0.15 dev_dependencies: - build_runner: ^2.3.0 flutter_test: sdk: flutter - freezed: ^2.2.0 mocktail: ^0.3.0 random_string: flutter_lints: ^2.0.0 From 4d65a1b37fa62bdaa7ac0cc6e592a2113163e207 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Sat, 7 Jan 2023 16:20:52 +0000 Subject: [PATCH 43/45] Removed logger package --- lib/src/utils/logger_provider.dart | 9 --------- pubspec.lock | 7 ------- pubspec.yaml | 1 - 3 files changed, 17 deletions(-) delete mode 100644 lib/src/utils/logger_provider.dart diff --git a/lib/src/utils/logger_provider.dart b/lib/src/utils/logger_provider.dart deleted file mode 100644 index d7c7c6d5..00000000 --- a/lib/src/utils/logger_provider.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:logger/logger.dart'; - -final loggerProvider = Provider((ref) => Logger( - printer: PrettyPrinter( - methodCount: 1, - printEmojis: false, - ), - )); diff --git a/pubspec.lock b/pubspec.lock index 6bd5922e..cff821d8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -282,13 +282,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.1" - logger: - dependency: "direct main" - description: - name: logger - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" logging: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4421dec9..3796f3aa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,7 +19,6 @@ dependencies: flutter_svg: ^1.1.6 go_router: 5.1.1 intl: ^0.17.0 - logger: ^1.1.0 rxdart: ^0.27.6 shared_preferences: ^2.0.15 From 176a4a8ef30af25e3a6be728527fb1994e2b7aae Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Sat, 7 Jan 2023 16:25:14 +0000 Subject: [PATCH 44/45] Cleanup README --- README.md | 379 +++++------------------------------------------------- 1 file changed, 29 insertions(+), 350 deletions(-) diff --git a/README.md b/README.md index cee76bfa..9438ce28 100644 --- a/README.md +++ b/README.md @@ -1,339 +1,49 @@ -# Starter Architecture Demo for Flutter & Firebase Realtime Apps +# Time Tracking app with Flutter & Firebase -This is a **reference architecture demo** that can be used as a **starting point** for apps using Flutter & Firebase. - -*Also see my [codewithandrea_flutter_packages repo](https://github.com/bizz84/codewithandrea_flutter_packages), which contains the most reusable parts of this project as packages.* - -## Motivation - -Flutter & Firebase are a great combo for getting apps to market in record time. - -Without a sound architecture, codebases can quickly become hard to test, maintain, and **reason about**. This **severely** impacts the development speed, and results in buggy products, sad developers and unhappy users. - -I have already witnessed this first-hand with various client projects, where the lack of a formal architecture led to days, weeks - even **months** of extra work. - -Is "architecture" hard? How can one find the "right" or "correct" architecture in the ever-changing landscape of front-end development? - -Every app has different requirements, so does the "right" architecture even exist in the first place? - -While I don't claim to have a silver bullet, I have refined and fine-tuned a **production-ready** architecture that I have deployed successfully into multiple Flutter & Firebase apps. - -I call this "**Stream-based** Architecture for Flutter & Firebase **Realtime** Apps". - -## Stream-based Architecture for Flutter & Firebase Realtime Apps - -Two words are key here: **Stream** and **Realtime**. - -Unlike with traditional REST APIs, with Firebase we can build **realtime** apps. - -That's because Firebase can **push** updates directly to **subscribed** clients when something changes. - -For example, widgets can **rebuild** themselves when certain Firestore *documents* or *collections* are updated. - -Many Firebase APIs are **inherently** stream-based. As a result, the **simplest** way of making our widgets reactive is to use [`StreamProvider`](https://pub.dev/documentation/riverpod/latest/all/StreamProvider-class.html) from the [Riverpod package](https://riverpod.dev). This provides a convenient way of **watching** changes in your Firebase streams, and automatically rebuilding widgets with minimal boilerplate code. - -Yes, you could use [`ChangeNotifier`](https://api.flutter.dev/flutter/foundation/ChangeNotifier-class.html) or other state management techniques that implement observables/listeners. But you would need additional "glue" code if you want to "convert" your input streams into reactive models based on `ChangeNotifier`. - -> Note: streams are the default way of pushing changes not only with Firebase, but with many other services as well. For example, you can get location updates with the `onLocationChanged()` stream of the [location](https://pub.dev/packages/location) package. Whether you use Firestore, or want to get data from your device's input sensors, streams are the most convenient way of delivering **asynchronous** data over time. - -A more detailed overview of this architecture is outlined below. But first, here are the goals for this project. - -## Project Goals - -Define a reference architecture that can be used as the **foundation** for Flutter apps using Firebase (or other streaming APIs). - -This architecture should: - -- **minimize mutable state** by adopting an **unidirectional data flow** -- clearly define application layers and their boundaries -- require little boilerplate code - -The resulting code should be: - -- clear -- reusable -- scalable -- testable -- performant -- maintainable - -These are all nice properties, but how do they all fit together in practice? - -By introducing application layers with clear boundaries, and defining how the data flows through them. - -## The Application Layers - -![](media/application-layers.png) - -To ensure a good separation of concerns, this architecture defines three main application layers. - -- **UI Layer**: where the widgets live -- **Logic & Presentation Layer**: this contains the application's business and presentation logic -- **Domain Layer**: this contains domain-specific services for interacting with 3rd party APIs - -*These layers may be named differently in other literature.* - -What matters here is that the data flows from the services into the widgets, and the call flow goes in the opposite direction. - -Widgets **subscribe** themselves as listeners, while view models **publish** updates when something changes. - ---- - -The publish/subscribe pattern comes in many variants (e.g. ChangeNotifier, BLoC), and this architecture does not prescribe which one to use. - -By using Riverpod, we can easily use the most convenient pattern on a case-by-case bsis. In practice, this means using `Stream`s with `StreamProvider` when reading and manipulating data from Firestore. But when dealing with **local** application state, `StatefulWidget`+`setState` or `ChangeNotifier` are sometimes used. - -Let's look at the three application layers in more detail. - -### Domain Layer: Services - -Services are **pure**, functional components that don't hold any state. - -Services serve as an **abstraction** from external data sources, and provide domain-specific APIs to the rest of the app (more on this below). - -Because service APIs return **strongly-typed**, **immutable**, **domain-specific** model objects, the rest of the app doesn't directly manipulate the raw data from the outside world (e.g. Firestore documents represented as key-value pairs). - -As a bonus, breaking changes in external packages are easier to deal with, because they only affect the corresponding service classes. - -### Presentation & Logic Layer: View Models - -View models abstract the widgets' **state** and **presentation**. - -View models **do not have any reference** to the widgets themselves. Rather, they define an **interface** for **publishing** updates when something changes. - -View models can talk directly to service classes to read or write data, and access other domain-specific APIs. - -But unlike service classes, they can **hold and modify state**, according to some business logic. - -View models can also be used to hold **local** state. This is common when converting a `StatefulWidget` into a `StatelessWidget` - -> NOTE: View models are **completely independent from the UI**. View model classes never import Flutter code (e.g. `material.dart`) - -### UI Layer: Widgets - -Widgets are used to specify how the application UI looks like, and provide callbacks in response to user interaction. - -Strictly speaking, we can introduce a distinction: - -- **pure UI widgets**: these are the usual buttons, texts, containers -- **logic or presentational widgets**: these are used to decide what widget to return, based on some condition (e.g. to return the home page or sign page based on the authentication status of the user). - ------ - -This project contains a demo app as a practical implementation of this architecture. - -## Demo App: Time Tracker - -The demo app is a time tracking application. It is complex enough to capture the various nuances of state management across multiple features. Here is a preview of the main screens: +A time tracking application built with Flutter & Firebase: ![](media/time-tracker-screenshots.png) -After signing in, users can view, create, edit and delete their jobs. For each job they can view, create, edit and delete the corresponding entries. - -A separate screen shows a daily breakdown of all jobs, hours worked and pay, along with the totals. - -All the data is persisted with Firestore, and is kept in sync across multiple devices. +This is intended as a **reference app** based on my [Riverpod Architecture](https://codewithandrea.com/articles/flutter-app-architecture-riverpod-introduction/). -## Riverpod +> **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. -[Riverpod](https://pub.dev/packages/riverpod) is a rewrite of the popular [Provider package](https://pub.dev/packages/provider), and improves on its weaknesses. It is a natural fit for this app. +## Features -Riverpod can be used to create **global** providers that are not tied to the widget tree, and these can then be accessed by **reference**. In this sense, Riverpod works more like a **service locator**. +- **Simple onboarding page** +- **Full authentication flow** (using email & password) +- **Jobs**: users can view, create, edit, and delete their own private jobs (each job has a name and hourly rate) +- **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) +- **A report page** that shows a daily breakdown of all jobs, hours worked and pay, along with the totals. -### Creating Providers with Riverpod +All the data is persisted with Firestore and is kept in sync across multiple devices. -For example, here are some providers that are created using Riverpod: +## Relevant Articles -```dart -// 1 -final firebaseAuthProvider = - Provider((ref) => FirebaseAuth.instance); +The app is based on my Flutter Riverpod architecture, which is explained in detail here: -// 2 -final authStateChangesProvider = StreamProvider( - (ref) => ref.watch(firebaseAuthProvider).authStateChanges()); +- [Flutter App Architecture with Riverpod: An Introduction](https://codewithandrea.com/articles/flutter-app-architecture-riverpod-introduction/) +- [Flutter Project Structure: Feature-first or Layer-first?](https://codewithandrea.com/articles/flutter-project-structure/) +- [Flutter App Architecture: The Repository Pattern](https://codewithandrea.com/articles/flutter-repository-pattern/) -// 3 -final databaseProvider = Provider((ref) { - final auth = ref.watch(authStateChangesProvider); +More more info on Riverpod, read this: - // we only have a valid DB if the user is signed in - if (auth.asData?.value?.uid != null) { - return FirestoreDatabase(uid: auth.asData!.value!.uid); - } - return null; -}); -``` - -As we can see, `authStateChangesProvider` **depends** on `firebaseAuthProvider`, and can get access to it using `ref.watch`. - -Similarly, `databaseProvider` **depends** on `authStateChangesProvider`. - -One powerful feature of Riverpod is that we can **watch** a provider's value and rebuild all dependent providers and widgets **when the value changes**. - -An example of this is the `databaseProvider` above. This provider's value is rebuilt every time the `authStateChangesProvider`'s value **changes**. This is used to return either a `FirestoreDatabase` object or `null` depending on the authentication state. - -### Using Riverpod inside widgets - -Widgets can access these providers with a `ScopedReader`, either via `Consumer` or `ConsumerWidget`. - -For example, here is some sample code demonstrating how to use `StreamProvider` to read some data from a stream: - -```dart -final jobStreamProvider = - StreamProvider.autoDispose.family((ref, jobId) { - final database = ref.watch(databaseProvider)!; - return database.jobStream(jobId: jobId); -}); -``` - -In this case the `StreamProvider` can auto-dispose itself when all its listeners unsubscribe. And we're using `.family` to read a `jobId` parameter that is only known at runtime. - -Here's a widget that *watches* this `StreamProvider` and uses it to show some UI based on the stream's latest state (data available / loading / error): - -```dart -class JobEntriesAppBarTitle extends ConsumerWidget { - const JobEntriesAppBarTitle({required this.job}); - final Job job; - - @override - Widget build(BuildContext context, WidgetRef ref) { - // 1: watch changes in the stream - final jobAsyncValue = ref.watch(jobStreamProvider(job.id)); - // 2: return the correct widget depending on the stream value - return jobAsyncValue.when( - data: (job) => Text(job.name), - loading: () => Container(), - error: (_, __) => Container(), - ); - } -} -``` +- [Flutter Riverpod 2.0: The Ultimate Guide](https://codewithandrea.com/articles/flutter-state-management-riverpod/) -This widget class is as simple as it can be, as it only needs to **watch** for changes in the stream (step 1), and return the correct widget depending on the stream value (step 2). +## Packages in use -This is great because all the logic for setting up the `StreamProvider` lives inside the provider itself, and is completely separate from the UI code. +These are the main packages used in the app: --------- - -In addition to the top-level providers and the `StreamProvider`s that read data from Firestore, Riverpod is also used to create and configure view models for widgets that require local state. - -These view models can hold any app-specific business logic, and if they're based on `ChangeNotifier` or `StateNotifier`, they can be easily hooked up to their widgets with corresponding providers. See the [SignInViewModel](https://github.com/bizz84/starter_architecture_flutter_firebase/blob/master/lib/app/sign_in/sign_in_view_model.dart) and [SignInPage](https://github.com/bizz84/starter_architecture_flutter_firebase/blob/master/lib/app/sign_in/sign_in_page.dart) widget for an example of this. - -## Project structure - -Folders are grouped by feature/page. Each feature may define its own models and view models. - -Services and routing classes are defined at the root, along with constants and common widgets shared by multiple features. - -``` -/lib - /app - /home - /account - /entries - /job_entries - /jobs - /models - /sign_in - /common_widgets - /constants - /routing - /services -``` - -This is an arbitrary structure. Choose what works best for **your** project. - -## Use Case: Firestore Service - -Widgets can subscribe to updates from Firestore data via streams. -Equally, write operations can be issued with Future-based APIs. - -Here's the entire Database API for the demo app, showing all the supported CRUD operations: - -```dart -class FirestoreDatabase { // implementation omitted for brevity - Future setJob(Job job); // create / update - Future deleteJob(Job job); // delete - Stream> jobsStream(); // read - Stream jobStream({required String jobId}); // read - - Future setEntry(Entry entry); // create / update - Future deleteEntry(Entry entry); // delete - Stream> entriesStream({Job job}); // read -} -``` +- [Flutter Riverpod](https://pub.dev/packages/flutter_riverpod) for data caching, dependency injection, and more +- [GoRouter](https://pub.dev/packages/go_router) for navigation +- [Firebase Auth](https://pub.dev/packages/firebase_auth) for authentication +- [Cloud Firestore](https://pub.dev/packages/cloud_firestore) as a realtime database +- [RxDart](https://pub.dev/packages/rxdart) for combining multiple Firestore collections as needed +- [Intl](https://pub.dev/packages/intl) for currency, date, time formatting +- [Mocktail](https://pub.dev/packages/mocktail) for testing +- [Equatable](https://pub.dev/packages/equatable) to reduce boilerplate code in model classes -As shown above, widgets can read these input streams via `StreamProvider`s, and use a _watch_ to reactively rebuild the UI. - -For convenience, all available collections and documents are listed in a single class: - -```dart -class APIPath { - static String job(String uid, String jobId) => 'users/$uid/jobs/$jobId'; - static String jobs(String uid) => 'users/$uid/jobs'; - static String entry(String uid, String entryId) => - 'users/$uid/entries/$entryId'; - static String entries(String uid) => 'users/$uid/entries'; -} -``` - -Domain-level model classes are defined, along with `fromMap()` and `toMap()` methods for serialization. -These classes are strongly-typed and immutable. - -See the [FirestoreDatabase](https://github.com/bizz84/starter_architecture_flutter_firebase/blob/master/lib/services/firestore_database.dart) and [FirestoreService](https://github.com/bizz84/starter_architecture_flutter_firebase/blob/master/lib/services/firestore_service.dart) classes for a full picture of how everything fits together. - -## Routing - -The app uses named routes, which are defined in a `Routes` class: - -```dart -class AppRoutes { - static const emailPasswordSignInPage = '/email-password-sign-in-page'; - static const editJobPage = '/edit-job-page'; - static const entryPage = '/entry-page'; -} -``` - -A `AppRouter` is then used to generate all the routes with a switch statement: - -```dart -class AppRouter { - static Route onGenerateRoute(RouteSettings settings) { - final args = settings.arguments; - switch (settings.name) { - // all cases here - } - } -} -``` - -Given a page that needs to be presented inside a route, we can call `pushNamed` with the name of the route, and pass all required arguments. If more than one argument is needed, we can use a map: - -```dart -class EntryPage extends ConsumerStatefulWidget { - const EntryPage({required this.job, this.entry}); - final Job job; - final Entry? entry; - - static Future show( - {required BuildContext context, required Job job, Entry? entry}) async { - await Navigator.of(context, rootNavigator: true).pushNamed( - AppRoutes.entryPage, - arguments: { - 'job': job, - 'entry': entry, - }, - ); - } - - @override - _EntryPageState createState() => _EntryPageState(); -} - -``` - -Note: previously the app was using [`auto_route`](https://pub.dev/packages/auto_route), which uses code generation to make routes **strongly-typed**. This has caused subtle issues, that took some time to investigate. So the project now uses manual routes, which are much more predictable. +See the [pubspec.yaml](pubspec.yaml) file for the complete list. ## Running the project with Firebase @@ -385,35 +95,4 @@ This is then imported in the `index.html` file: ``` -## Packages - -- `firebase_auth` for authentication -- `cloud_firestore` for the remote database -- `flutter_riverpod` for state management -- `rxdart` for combining multiple Firestore collections as needed -- `intl` for currency, date, time formatting -- `mocktail` for testing -- `equatable` to reduce boilerplate code in model classes - -Also imported from my [flutter_core_packages repo](https://github.com/bizz84/flutter_core_packages): - -- `firestore_service` -- `custom_buttons` -- `alert_dialogs` -- `email_password_sign_in_ui` - -## References - -This project borrows many ideas from my [Flutter & Firebase Course](https://nnbd.me/ff), as well as my [Reference Authentication Flow with Flutter & Firebase](https://github.com/bizz84/firebase_auth_demo_flutter), and takes them to the next level by using Riverpod. - -Here are some other GitHub projects that also attempt to formalize a good approach to Flutter development: - -- [Beyond - An approach to scalable Flutter development](https://github.com/MisterJimson/beyond) -- This [starter app](https://github.com/gregertw/actingweb_firstapp) that includes many different production app features. Related articles: [A Production-Quality Flutter Starter App](https://stuff.greger.io/2019/07/production-quality-flutter-starter-app.html), and [this follow up](https://stuff.greger.io/2020/01/production-quality-flutter-starter-app-take-two.html). - -Other relevant articles about app architecture: - -- [Widget-Async-Bloc-Service: A Practical Architecture for Flutter Apps](https://codewithandrea.com/articles/2019-05-21-wabs-practical-architecture-flutter-apps/) -- [Flutter TDD Clean Architecture Course [1] – Explanation & Project Structure](https://resocoder.com/2019/08/27/flutter-tdd-clean-architecture-course-1-explanation-project-structure/) - ## [License: MIT](LICENSE.md) From c9cebc66a4603b5f15dd055f891384594be98282 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Sat, 7 Jan 2023 16:36:10 +0000 Subject: [PATCH 45/45] Updated screenshots --- .github/FUNDING.yml | 1 - .../images}/time-tracker-screenshots.png | Bin README.md | 19 ++++++++++++++---- media/application-layers.png | Bin 133722 -> 0 bytes media/time-tracker-widget-tree.png | Bin 197528 -> 0 bytes 5 files changed, 15 insertions(+), 5 deletions(-) delete mode 100644 .github/FUNDING.yml rename {media => .github/images}/time-tracker-screenshots.png (100%) delete mode 100644 media/application-layers.png delete mode 100644 media/time-tracker-widget-tree.png diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 934a7a22..00000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -github: bizz84 diff --git a/media/time-tracker-screenshots.png b/.github/images/time-tracker-screenshots.png similarity index 100% rename from media/time-tracker-screenshots.png rename to .github/images/time-tracker-screenshots.png diff --git a/README.md b/README.md index 9438ce28..760f9359 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A time tracking application built with Flutter & Firebase: -![](media/time-tracker-screenshots.png) +![](/.github/images/time-tracker-screenshots.png) This is intended as a **reference app** based on my [Riverpod Architecture](https://codewithandrea.com/articles/flutter-app-architecture-riverpod-introduction/). @@ -16,7 +16,18 @@ This is intended as a **reference app** based on my [Riverpod Architecture](http - **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) - **A report page** that shows a daily breakdown of all jobs, hours worked and pay, along with the totals. -All the data is persisted with Firestore and is kept in sync across multiple devices. +All the data is persisted with Firestore and is kept in sync across multiple devices. + +## Roadmap + +- [ ] Add missing tests +- [ ] Stateful Nested Navigation with GoRouter (once [this PR](https://github.com/flutter/packages/pull/2650) is merged) +- [ ] Use controllers / notifiers consistently across the app (some code still needs to be updated) +- [ ] Add localization +- [ ] Use the new Firebase UI packages where useful +- [ ] Responsive UI + +> 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. ## Relevant Articles @@ -57,9 +68,9 @@ To use this project with Firebase, some configuration steps are required. - 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. - finally, enable the Email/Password Authentication Sign-in provider in the Firebase Console (Authentication > Sign-in method > Email/Password > Edit > Enable > Save) -See this page for full instructions: +To speed up the process, you can use the [FlutterFire CLI](https://pub.dev/packages/flutterfire_cli) as explained here: -- [FlutterFire Overview](https://firebase.flutter.dev/docs/overview) +- [How to add Firebase to a Flutter app with FlutterFire CLI](https://codewithandrea.com/articles/flutter-firebase-flutterfire-cli/) ## Running on Flutter Web diff --git a/media/application-layers.png b/media/application-layers.png deleted file mode 100644 index c8c3274a78e79728f4c01ed8485491df5881cf44..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 133722 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≧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%0AfT}H*J6Usx9Mp7hrnY?S~Iz&!+GhmUEZ?) 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%0AxAV-Q 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

RS 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 Q0r-=Dt1MIb#wh6j0HgwNt^fc4