From 50d4a1bf010db946a887e1e5e8187acdf19a002d Mon Sep 17 00:00:00 2001 From: cp-ishita-g <154948719+cp-ishita-g@users.noreply.github.com> Date: Wed, 5 Jun 2024 17:23:09 +0530 Subject: [PATCH] Join space with invitation code (#13) * temp * join space with invitation code and get invitation of selected space * live data streaming * remove unwanted code * stream member live data * temp * revert stream * manage selection with id * add snack bar error and show selected id always at 1st index * show congratulation prompt on joining space --- app/assets/locales/app_en.arb | 18 ++ app/lib/gen/assets.gen.dart | 32 +- app/lib/ui/app_route.dart | 9 +- app/lib/ui/components/alert.dart | 122 ++++++++ app/lib/ui/components/error_snakebar.dart | 10 + .../phone_verification_screen.dart | 30 +- .../auth/sign_in/sign_in_method_screen.dart | 11 + .../ui/flow/home/components/home_top_bar.dart | 131 ++++---- app/lib/ui/flow/home/home_screen.dart | 37 ++- .../ui/flow/home/home_screen_viewmodel.dart | 64 +++- .../home/home_screen_viewmodel.freezed.dart | 145 ++++++--- app/lib/ui/flow/onboard/pick_name_screen.dart | 12 +- .../space/create/create_space_screen.dart | 30 +- .../space/create/create_space_view_model.dart | 2 + .../create_space_view_model.freezed.dart | 45 ++- .../ui/flow/space/join/join_space_screen.dart | 171 +++++++++-- .../space/join/join_space_view_model.dart | 73 +++++ .../join/join_space_view_model.freezed.dart | 288 ++++++++++++++++++ .../space/api_space_invitation_service.dart | 18 +- data/lib/service/space_service.dart | 70 +++-- 20 files changed, 1080 insertions(+), 238 deletions(-) create mode 100644 app/lib/ui/components/alert.dart create mode 100644 app/lib/ui/components/error_snakebar.dart create mode 100644 app/lib/ui/flow/space/join/join_space_view_model.dart create mode 100644 app/lib/ui/flow/space/join/join_space_view_model.freezed.dart diff --git a/app/assets/locales/app_en.arb b/app/assets/locales/app_en.arb index 6f778208..12b01f2e 100644 --- a/app/assets/locales/app_en.arb +++ b/app/assets/locales/app_en.arb @@ -2,10 +2,16 @@ "app_title": "YourSpace", "app_tag_line": "Stay Close, Anywhere!", + "alert_confirm_default_title": "Are you sure", + "@_COMMON": { }, "common_get_started": "Get Started", "common_next": "Next", + "common_yes": "Yes", + "common_cancel": "Cancel", + "common_delete": "Delete", + "common_okay": "Okay", "common_verify": "Verify","commonMembers": "{memberCount} {memberCount, plural, =1{Member} other{Members}}", "@commonMembers": { "placeholders": { @@ -48,6 +54,7 @@ } } }, + "home_select_space_text": "Select a space", "create_space_give_your_space_name_title": "Give your space name", "create_space_tip_text": "Tips: You create dedicated spaces for work, family, friends, and more.", @@ -67,6 +74,17 @@ "join_space_title": "Join space", "join_space_invite_code_title": "Enter the invite code", "join_space_get_code_from_space_text": "Get the code from the space creator to join.", + "join_space_already_joined_error_text": "You are already member of this space", + "join_space_invalid_code_error_text": "It appears the code is no longer valid or has expired. please review and enter valid code.", + "join_space_congratulation_title": "Congratulation", + "join_space_congratulation_subtitle": "You’re are now member of {spaceName} group for sharing your real-time location.", + "@join_space_congratulation_subtitle": { + "placeholders": { + "spaceName": { + "type": "String" + } + } + }, "invite_code_title": "Invite code", "invite_code_invite_member_title": "Invite members to the {spaceName}", diff --git a/app/lib/gen/assets.gen.dart b/app/lib/gen/assets.gen.dart index 2610fb2b..8ce7a12f 100644 --- a/app/lib/gen/assets.gen.dart +++ b/app/lib/gen/assets.gen.dart @@ -22,15 +22,6 @@ class $AssetsImagesGen { /// File path: assets/images/ic_google_logo.svg String get icGoogleLogo => 'assets/images/ic_google_logo.svg'; - /// File path: assets/images/intro_1.svg - String get intro1 => 'assets/images/intro_1.svg'; - - /// File path: assets/images/intro_2.svg - String get intro2 => 'assets/images/intro_2.svg'; - - /// File path: assets/images/intro_3.svg - String get intro3 => 'assets/images/intro_3.svg'; - /// File path: assets/images/ic_location.svg String get icLocation => 'assets/images/ic_location.svg'; @@ -40,13 +31,32 @@ class $AssetsImagesGen { /// File path: assets/images/ic_setting.svg String get icSetting => 'assets/images/ic_setting.svg'; + /// File path: assets/images/intro_1.svg + String get intro1 => 'assets/images/intro_1.svg'; + + /// File path: assets/images/intro_2.svg + String get intro2 => 'assets/images/intro_2.svg'; + + /// File path: assets/images/intro_3.svg + String get intro3 => 'assets/images/intro_3.svg'; + /// File path: assets/images/intro_bg.jpg AssetGenImage get introBg => const AssetGenImage('assets/images/intro_bg.jpg'); /// List of all assets - List get values => - [appLogo, icAddMember, icLocation, icMessage, icSetting, icGoogleLogo, intro1, intro2, intro3, introBg]; + List get values => [ + appLogo, + icAddMember, + icGoogleLogo, + icLocation, + icMessage, + icSetting, + intro1, + intro2, + intro3, + introBg + ]; } class Assets { diff --git a/app/lib/ui/app_route.dart b/app/lib/ui/app_route.dart index 6eea54a7..78ef25c7 100644 --- a/app/lib/ui/app_route.dart +++ b/app/lib/ui/app_route.dart @@ -117,13 +117,8 @@ class AppRoute { static AppRoute get createSpace => AppRoute(pathCreateSpace, builder: (_) => const CreateSpace()); - static AppRoute joinSpace({ - required String invitationCode, required String spaceName}) { - return AppRoute( - pathJoinSpace, - builder: (_) => JoinSpace(invitationCode: invitationCode, spaceName: spaceName), - ); - } + static AppRoute get joinSpace => + AppRoute(pathJoinSpace, builder: (_) => const JoinSpace()); static AppRoute inviteCode({ required String code, required String spaceName}) { diff --git a/app/lib/ui/components/alert.dart b/app/lib/ui/components/alert.dart new file mode 100644 index 00000000..1ab1cd40 --- /dev/null +++ b/app/lib/ui/components/alert.dart @@ -0,0 +1,122 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; +import 'package:style/extenstions/context_extenstions.dart'; +import 'package:yourspace_flutter/domain/extenstions/context_extenstions.dart'; + + +Future showConfirmation( + BuildContext context, { + String? title, + String? message, + String? confirmBtnText, + required VoidCallback onConfirm, + bool isDestructiveAction = true, + String? cancelButtonText, + VoidCallback? onCancel, + }) { + HapticFeedback.mediumImpact(); + return showAdaptiveDialog( + context: context, + builder: (context) { + return AlertDialog.adaptive( + surfaceTintColor: context.colorScheme.containerNormalOnSurface, + title: (title != null) ? Text(title) : null, + content: Text(message ?? context.l10n.alert_confirm_default_title), + actions: [ + adaptiveAction( + context: context, + onPressed: () async { + context.pop(); + onCancel?.call(); + }, + child: Text(cancelButtonText ?? context.l10n.common_cancel), + ), + adaptiveAction( + context: context, + isDestructiveAction: isDestructiveAction, + onPressed: () async { + context.pop(); + onConfirm(); + }, + child: Text( + confirmBtnText ?? context.l10n.common_yes, + ), + ), + ], + ); + }, + ); +} + +Future showOkayConfirmation( + BuildContext context, { + required String title, + required String message, + bool isDestructiveAction = true, + VoidCallback? onOkay, + }) { + HapticFeedback.mediumImpact(); + return showAdaptiveDialog( + context: context, + builder: (context) { + return AlertDialog.adaptive( + surfaceTintColor: context.colorScheme.containerNormalOnSurface, + title: Text(title), + content: Text(message), + actions: [ + adaptiveAction( + context: context, + onPressed: () async { + context.pop(); + onOkay?.call(); + }, + child: Text(context.l10n.common_okay), + ), + ], + ); + }, + ); +} + +Widget adaptiveAction({ + required BuildContext context, + required VoidCallback onPressed, + required Widget child, + bool isDestructiveAction = false, +}) { + final ThemeData theme = Theme.of(context); + switch (theme.platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return TextButton( + onPressed: onPressed, + child: DefaultTextStyle( + style: TextStyle( + inherit: true, + color: isDestructiveAction + ? context.colorScheme.alert + : context.colorScheme.textSecondary, + ), + child: child, + ), + ); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return CupertinoDialogAction( + onPressed: onPressed, + isDestructiveAction: isDestructiveAction, + textStyle: isDestructiveAction + ? null + : TextStyle( + color: isDestructiveAction + ? context.colorScheme.textPrimary + : context.colorScheme.textSecondary, + ), + child: child, + ); + } +} \ No newline at end of file diff --git a/app/lib/ui/components/error_snakebar.dart b/app/lib/ui/components/error_snakebar.dart new file mode 100644 index 00000000..8cb3611c --- /dev/null +++ b/app/lib/ui/components/error_snakebar.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +void showErrorSnackBar(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: Colors.red, + ), + ); +} \ No newline at end of file diff --git a/app/lib/ui/flow/auth/sign_in/phone/verification/phone_verification_screen.dart b/app/lib/ui/flow/auth/sign_in/phone/verification/phone_verification_screen.dart index de3cc8f8..1d8ade70 100644 --- a/app/lib/ui/flow/auth/sign_in/phone/verification/phone_verification_screen.dart +++ b/app/lib/ui/flow/auth/sign_in/phone/verification/phone_verification_screen.dart @@ -9,6 +9,7 @@ import 'package:style/text/app_text_dart.dart'; import 'package:yourspace_flutter/domain/extenstions/context_extenstions.dart'; import 'package:yourspace_flutter/domain/extenstions/widget_extensions.dart'; import 'package:yourspace_flutter/ui/components/app_page.dart'; +import 'package:yourspace_flutter/ui/components/error_snakebar.dart'; import 'package:yourspace_flutter/ui/flow/auth/sign_in/phone/verification/phone_verification_view_model.dart'; import '../../../../../components/app_logo.dart'; @@ -38,16 +39,6 @@ class _PhoneVerificationScreenState super.initState(); } - void _navBackAfterVerification() { - ref.listen( - phoneVerificationStateProvider - .select((value) => value.verificationSucceed), (previous, next) { - if (next) { - context.pop([true, ref.read(phoneVerificationStateProvider).isNewUser]); - } - }); - } - @override void dispose() { _otpController.dispose(); @@ -64,6 +55,7 @@ class _PhoneVerificationScreenState ); _navBackAfterVerification(); + _observeError(); return AppPage( title: '', @@ -178,4 +170,22 @@ class _PhoneVerificationScreenState ), ), ); + + void _navBackAfterVerification() { + ref.listen( + phoneVerificationStateProvider + .select((value) => value.verificationSucceed), (previous, next) { + if (next) { + context.pop([true, ref.read(phoneVerificationStateProvider).isNewUser]); + } + }); + } + + void _observeError() { + ref.listen(phoneVerificationStateProvider.select((state) => state.error), (previous, next) { + if (next != null) { + showErrorSnackBar(context, next.toString()); + } + }); + } } diff --git a/app/lib/ui/flow/auth/sign_in/sign_in_method_screen.dart b/app/lib/ui/flow/auth/sign_in/sign_in_method_screen.dart index d033d63a..11c88f20 100644 --- a/app/lib/ui/flow/auth/sign_in/sign_in_method_screen.dart +++ b/app/lib/ui/flow/auth/sign_in/sign_in_method_screen.dart @@ -6,6 +6,7 @@ import 'package:style/extenstions/context_extenstions.dart'; import 'package:yourspace_flutter/domain/extenstions/context_extenstions.dart'; import 'package:yourspace_flutter/ui/components/app_logo.dart'; import 'package:yourspace_flutter/ui/components/app_page.dart'; +import 'package:yourspace_flutter/ui/components/error_snakebar.dart'; import 'package:yourspace_flutter/ui/flow/auth/sign_in/sign_in_method_viewmodel.dart'; import '../../../../gen/assets.gen.dart'; @@ -31,6 +32,8 @@ class _SignInMethodScreenState extends ConsumerState { @override Widget build(BuildContext context) { _listenSignInSuccess(); + _observeError(); + final bgDecoration = BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, @@ -115,4 +118,12 @@ class _SignInMethodScreenState extends ConsumerState { } }); } + + void _observeError() { + ref.listen(signInMethodsStateProvider.select((state) => state.error), (previous, next) { + if (next != null) { + showErrorSnackBar(context, next.toString()); + } + }); + } } diff --git a/app/lib/ui/flow/home/components/home_top_bar.dart b/app/lib/ui/flow/home/components/home_top_bar.dart index fa45e909..6a86b791 100644 --- a/app/lib/ui/flow/home/components/home_top_bar.dart +++ b/app/lib/ui/flow/home/components/home_top_bar.dart @@ -12,17 +12,21 @@ import 'package:yourspace_flutter/ui/app_route.dart'; import '../../../../gen/assets.gen.dart'; class HomeTopBar extends StatefulWidget { - final void Function(String) onSpaceItemTap; + final void Function(SpaceInfo) onSpaceItemTap; + final void Function() onAddMemberTap; final List spaces; - final String title; + final SpaceInfo? selectedSpace; final bool loading; + final bool fetchingInviteCode; const HomeTopBar({ super.key, required this.spaces, required this.onSpaceItemTap, - required this.title, + required this.onAddMemberTap, + required this.selectedSpace, this.loading = false, + this.fetchingInviteCode = false, }); @override @@ -64,7 +68,6 @@ class _HomeTopBarState extends State with TickerProviderStateMixin { } } - @override Widget build(BuildContext context) { return _body(context); @@ -81,38 +84,48 @@ class _HomeTopBarState extends State with TickerProviderStateMixin { return IntrinsicHeight( child: Container( color: expand ? context.colorScheme.surface : null, - child: Column( - children: [ - Row( - children: [ - _iconButton( + child: SingleChildScrollView( + child: Column( + children: [ + Row( + children: [ + _iconButton( context: context, icon: Assets.images.icSetting, - visibility: !expand), - const SizedBox(width: 8), - _spaceSelection( - context: context, - spaceName: widget.title, - ), - const SizedBox(width: 8), - _iconButton( + visibility: !expand, + onTap: () {}, + ), + const SizedBox(width: 8), + _spaceSelection( + context: context, + spaceName: widget.selectedSpace?.space.name ?? context.l10n.home_select_space_text, + ), + const SizedBox(width: 8), + _iconButton( context: context, icon: Assets.images.icMessage, - visibility: !expand), - SizedBox(width: expand ? 0 : 8), - _iconButton( + visibility: !expand, + onTap: () {}, + ), + SizedBox(width: expand ? 0 : 8), + _iconButton( context: context, icon: Assets.images.icLocation, - visibility: !expand), - _iconButton( + visibility: !expand, + onTap: () {}, + ), + _iconButton( context: context, icon: Assets.images.icAddMember, visibility: expand, - color: context.colorScheme.textPrimary), - ], - ), - _dropDown(context), - ], + color: context.colorScheme.textPrimary, + onTap: () => widget.onAddMemberTap(), + ), + ], + ), + _dropDown(context), + ], + ), ), ), ); @@ -142,22 +155,24 @@ class _HomeTopBarState extends State with TickerProviderStateMixin { padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), child: Row( children: [ - if (widget.loading) ...[ - const AppProgressIndicator(size: AppProgressIndicatorSize.small), + Text( + widget.loading + ? context.l10n.home_select_space_text + : spaceName, + style: AppTextStyle.subtitle2 + .copyWith(color: context.colorScheme.textPrimary), + ), + const Spacer(), + if (widget.fetchingInviteCode || widget.selectedSpace == null) ...[ + const AppProgressIndicator(size: AppProgressIndicatorSize.small) ] else ...[ - Text( - spaceName, - style: AppTextStyle.subtitle2 - .copyWith(color: context.colorScheme.textPrimary), + Icon( + expand + ? Icons.keyboard_arrow_down_rounded + : Icons.keyboard_arrow_up_rounded, + color: context.colorScheme.textPrimary, ), ], - const Spacer(), - Icon( - expand - ? Icons.keyboard_arrow_down_rounded - : Icons.keyboard_arrow_up_rounded, - color: context.colorScheme.textPrimary, - ) ], ), ), @@ -171,11 +186,12 @@ class _HomeTopBarState extends State with TickerProviderStateMixin { required String icon, Color? color, required bool visibility, + required Function() onTap, }) { return Visibility( visible: visibility, child: IconPrimaryButton( - onTap: () => {}, + onTap: () => onTap(), icon: SvgPicture.asset( height: 16, width: 14, @@ -189,7 +205,8 @@ class _HomeTopBarState extends State with TickerProviderStateMixin { ); } - Widget _spaceList(BuildContext context, List spaces, Function(String) onSpaceSelected) { + Widget _spaceList(BuildContext context, List spaces, + Function(SpaceInfo) onSpaceSelected) { if (widget.loading) { return const AppProgressIndicator(size: AppProgressIndicatorSize.small); } @@ -206,26 +223,29 @@ class _HomeTopBarState extends State with TickerProviderStateMixin { setState(() { selectedIndex = index; }); - onSpaceSelected(space.space.name); + onSpaceSelected(space); }, - child: _spaceListItem(context, space, index, widget.title == space.space.name), + child: _spaceListItem( + context, space, index, widget.selectedSpace?.space.id == space.space.id), ), ); }).toList(), ); } - - Widget _spaceListItem(BuildContext context, SpaceInfo space, int index, bool isSelected) { - final admin = space.members.firstWhere( + Widget _spaceListItem( + BuildContext context, SpaceInfo space, int index, bool isSelected) { + final admin = space.members + .firstWhere( (member) => member.user.id == space.space.admin_id, - ).user; + ) + .user; return GestureDetector( onTap: () { setState(() { selectedIndex = index; - widget.onSpaceItemTap(space.space.name); + widget.onSpaceItemTap(space); }); }, child: Container( @@ -233,7 +253,8 @@ class _HomeTopBarState extends State with TickerProviderStateMixin { borderRadius: BorderRadius.circular(8), color: context.colorScheme.containerLow, border: Border.all( - color: isSelected ? context.colorScheme.primary : Colors.transparent, + color: + isSelected ? context.colorScheme.primary : Colors.transparent, ), ), padding: const EdgeInsets.symmetric(vertical: 12), @@ -246,7 +267,7 @@ class _HomeTopBarState extends State with TickerProviderStateMixin { onChanged: (val) { setState(() { selectedIndex = index; - widget.onSpaceItemTap(space.space.name); + widget.onSpaceItemTap(space); }); }, activeColor: context.colorScheme.primary, @@ -310,14 +331,16 @@ class _HomeTopBarState extends State with TickerProviderStateMixin { Row( children: [ Expanded( - child: PrimaryButton(context.l10n.home_create_space_title, onPressed: () { + child: PrimaryButton(context.l10n.home_create_space_title, + onPressed: () { AppRoute.createSpace.push(context); }), ), const SizedBox(width: 16), Expanded( - child: PrimaryButton(context.l10n.home_join_space_title, onPressed: () { - // AppRoute.joinSpace.push(context); + child: PrimaryButton(context.l10n.home_join_space_title, + onPressed: () { + AppRoute.joinSpace.push(context); }), ), ], diff --git a/app/lib/ui/flow/home/home_screen.dart b/app/lib/ui/flow/home/home_screen.dart index 754f4fdc..044a4d58 100644 --- a/app/lib/ui/flow/home/home_screen.dart +++ b/app/lib/ui/flow/home/home_screen.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:style/extenstions/context_extenstions.dart'; import 'package:yourspace_flutter/domain/extenstions/widget_extensions.dart'; +import 'package:yourspace_flutter/ui/app_route.dart'; import 'package:yourspace_flutter/ui/components/app_page.dart'; +import 'package:yourspace_flutter/ui/components/error_snakebar.dart'; import 'package:yourspace_flutter/ui/components/resume_detector.dart'; import 'package:yourspace_flutter/ui/flow/home/home_screen_viewmodel.dart'; @@ -30,19 +32,21 @@ class _HomeScreenState extends ConsumerState { @override Widget build(BuildContext context) { + final state = ref.watch(homeViewStateProvider); + _observeNavigation(state); + _observeError(); + return AppPage( body: ResumeDetector( onResume: () { notifier.getAllSpace(); }, - child: _body(context), + child: _body(context, state), ), ); } - Widget _body(BuildContext context) { - final state = ref.watch(homeViewStateProvider); - + Widget _body(BuildContext context, HomeViewState state) { return Padding( padding: context.mediaQueryPadding, child: Stack( @@ -50,15 +54,32 @@ class _HomeScreenState extends ConsumerState { const MapView(), HomeTopBar( spaces: state.spaceList, - onSpaceItemTap: (name) { - notifier.updateSelectedSpaceName(name); - }, - title: state.selectedSpaceName, + onSpaceItemTap: (name) => notifier.updateSelectedSpace(name), + onAddMemberTap: () => notifier.onAddMemberTap(), + selectedSpace: state.selectedSpace, loading: state.loading, + fetchingInviteCode: state.fetchingInviteCode, ), // SpaceUserFooter() ], ), ); } + + void _observeNavigation(HomeViewState state) { + ref.listen(homeViewStateProvider.select((state) => state.spaceInvitationCode), + (_, next) { + if (next.isNotEmpty) { + AppRoute.inviteCode(code: next, spaceName: state.selectedSpace?.space.name ?? '').push(context); + } + }); + } + + void _observeError() { + ref.listen(homeViewStateProvider.select((state) => state.error), (previous, next) { + if (next != null) { + showErrorSnackBar(context, next.toString()); + } + }); + } } diff --git a/app/lib/ui/flow/home/home_screen_viewmodel.dart b/app/lib/ui/flow/home/home_screen_viewmodel.dart index 6193767a..39d2c517 100644 --- a/app/lib/ui/flow/home/home_screen_viewmodel.dart +++ b/app/lib/ui/flow/home/home_screen_viewmodel.dart @@ -1,5 +1,6 @@ import 'package:data/api/space/space_models.dart'; import 'package:data/log/logger.dart'; +import 'package:data/storage/app_preferences.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:data/service/space_service.dart'; @@ -10,42 +11,73 @@ final homeViewStateProvider = StateNotifierProvider.autoDispose< HomeViewNotifier, HomeViewState>( (ref) => HomeViewNotifier( ref.read(spaceServiceProvider), + ref.read(currentUserSessionJsonPod.notifier), ), ); class HomeViewNotifier extends StateNotifier { final SpaceService spaceService; + final StateController _currentSpaceIdController; - HomeViewNotifier(this.spaceService) : super(const HomeViewState()); + HomeViewNotifier(this.spaceService, this._currentSpaceIdController) : super(const HomeViewState()); + + String? get currentSpaceId => _currentSpaceIdController.state; + + set currentSpaceId(String? value) { + _currentSpaceIdController.state = value; + } void getAllSpace() async { try { state = state.copyWith(loading: state.spaceList.isEmpty); final spaces = await spaceService.getAllSpaceInfo(); - state = state.copyWith(loading: false, spaceList: spaces); - if (state.selectedSpaceName.isEmpty) { - updateSelectedSpaceName(state.spaceList.first.space.name); + + final sortedSpaces = spaces.toList(); + if (currentSpaceId != null) { + final selectedSpaceIndex = sortedSpaces.indexWhere((space) => space.space.id == currentSpaceId); + if (selectedSpaceIndex > -1) { + final selectedSpace = sortedSpaces.removeAt(selectedSpaceIndex); + sortedSpaces.insert(0, selectedSpace); + } + } + + state = state.copyWith(loading: false, spaceList: sortedSpaces); + + if (currentSpaceId != null && sortedSpaces.isNotEmpty) { + final selectedSpace = sortedSpaces.first; + updateSelectedSpace(selectedSpace); } } catch (error, stack) { + state = state.copyWith(error: error); logger.e( - 'HomeViewNotifier: error while get all place', + 'HomeViewNotifier: error while getting all spaces', error: error, stackTrace: stack, ); } } - void updateSelectedSpaceName(String name) { - if (name != state.selectedSpaceName) { - state = state.copyWith( - selectedSpaceName: name, - ); - } else { - state = state.copyWith( - selectedSpaceName: state.spaceList.first.space.name, + void onAddMemberTap() async { + try { + state = state.copyWith(fetchingInviteCode: true); + final code = await spaceService.getInviteCode(state.selectedSpace?.space.id ?? ''); + state = state.copyWith(spaceInvitationCode: code ?? '', fetchingInviteCode: false); + } catch (error, stack) { + state = state.copyWith(error: error); + logger.e( + 'HomeViewNotifier: Error while getting invitation code', + error: error, + stackTrace: stack, ); } } + + void updateSelectedSpace(SpaceInfo space) { + if (space != state.selectedSpace) { + state = state.copyWith(selectedSpace: space); + currentSpaceId = space.space.id; + } + } } @freezed @@ -54,8 +86,10 @@ class HomeViewState with _$HomeViewState { @Default(false) bool allowSave, @Default(false) bool isCreating, @Default(false) bool loading, - @Default('') String selectedSpaceName, - @Default('') String invitationCode, + @Default(false) bool fetchingInviteCode, + SpaceInfo? selectedSpace, + @Default('') String spaceInvitationCode, @Default([]) List spaceList, + Object? error, }) = _HomeViewState; } diff --git a/app/lib/ui/flow/home/home_screen_viewmodel.freezed.dart b/app/lib/ui/flow/home/home_screen_viewmodel.freezed.dart index e0d1ed79..6fc49f77 100644 --- a/app/lib/ui/flow/home/home_screen_viewmodel.freezed.dart +++ b/app/lib/ui/flow/home/home_screen_viewmodel.freezed.dart @@ -19,9 +19,11 @@ mixin _$HomeViewState { bool get allowSave => throw _privateConstructorUsedError; bool get isCreating => throw _privateConstructorUsedError; bool get loading => throw _privateConstructorUsedError; - String get selectedSpaceName => throw _privateConstructorUsedError; - String get invitationCode => throw _privateConstructorUsedError; + bool get fetchingInviteCode => throw _privateConstructorUsedError; + SpaceInfo? get selectedSpace => throw _privateConstructorUsedError; + String get spaceInvitationCode => throw _privateConstructorUsedError; List get spaceList => throw _privateConstructorUsedError; + Object? get error => throw _privateConstructorUsedError; @JsonKey(ignore: true) $HomeViewStateCopyWith get copyWith => @@ -38,9 +40,13 @@ abstract class $HomeViewStateCopyWith<$Res> { {bool allowSave, bool isCreating, bool loading, - String selectedSpaceName, - String invitationCode, - List spaceList}); + bool fetchingInviteCode, + SpaceInfo? selectedSpace, + String spaceInvitationCode, + List spaceList, + Object? error}); + + $SpaceInfoCopyWith<$Res>? get selectedSpace; } /// @nodoc @@ -59,9 +65,11 @@ class _$HomeViewStateCopyWithImpl<$Res, $Val extends HomeViewState> Object? allowSave = null, Object? isCreating = null, Object? loading = null, - Object? selectedSpaceName = null, - Object? invitationCode = null, + Object? fetchingInviteCode = null, + Object? selectedSpace = freezed, + Object? spaceInvitationCode = null, Object? spaceList = null, + Object? error = freezed, }) { return _then(_value.copyWith( allowSave: null == allowSave @@ -76,20 +84,37 @@ class _$HomeViewStateCopyWithImpl<$Res, $Val extends HomeViewState> ? _value.loading : loading // ignore: cast_nullable_to_non_nullable as bool, - selectedSpaceName: null == selectedSpaceName - ? _value.selectedSpaceName - : selectedSpaceName // ignore: cast_nullable_to_non_nullable - as String, - invitationCode: null == invitationCode - ? _value.invitationCode - : invitationCode // ignore: cast_nullable_to_non_nullable + fetchingInviteCode: null == fetchingInviteCode + ? _value.fetchingInviteCode + : fetchingInviteCode // ignore: cast_nullable_to_non_nullable + as bool, + selectedSpace: freezed == selectedSpace + ? _value.selectedSpace + : selectedSpace // ignore: cast_nullable_to_non_nullable + as SpaceInfo?, + spaceInvitationCode: null == spaceInvitationCode + ? _value.spaceInvitationCode + : spaceInvitationCode // ignore: cast_nullable_to_non_nullable as String, spaceList: null == spaceList ? _value.spaceList : spaceList // ignore: cast_nullable_to_non_nullable as List, + error: freezed == error ? _value.error : error, ) as $Val); } + + @override + @pragma('vm:prefer-inline') + $SpaceInfoCopyWith<$Res>? get selectedSpace { + if (_value.selectedSpace == null) { + return null; + } + + return $SpaceInfoCopyWith<$Res>(_value.selectedSpace!, (value) { + return _then(_value.copyWith(selectedSpace: value) as $Val); + }); + } } /// @nodoc @@ -104,9 +129,14 @@ abstract class _$$HomeViewStateImplCopyWith<$Res> {bool allowSave, bool isCreating, bool loading, - String selectedSpaceName, - String invitationCode, - List spaceList}); + bool fetchingInviteCode, + SpaceInfo? selectedSpace, + String spaceInvitationCode, + List spaceList, + Object? error}); + + @override + $SpaceInfoCopyWith<$Res>? get selectedSpace; } /// @nodoc @@ -123,9 +153,11 @@ class __$$HomeViewStateImplCopyWithImpl<$Res> Object? allowSave = null, Object? isCreating = null, Object? loading = null, - Object? selectedSpaceName = null, - Object? invitationCode = null, + Object? fetchingInviteCode = null, + Object? selectedSpace = freezed, + Object? spaceInvitationCode = null, Object? spaceList = null, + Object? error = freezed, }) { return _then(_$HomeViewStateImpl( allowSave: null == allowSave @@ -140,18 +172,23 @@ class __$$HomeViewStateImplCopyWithImpl<$Res> ? _value.loading : loading // ignore: cast_nullable_to_non_nullable as bool, - selectedSpaceName: null == selectedSpaceName - ? _value.selectedSpaceName - : selectedSpaceName // ignore: cast_nullable_to_non_nullable - as String, - invitationCode: null == invitationCode - ? _value.invitationCode - : invitationCode // ignore: cast_nullable_to_non_nullable + fetchingInviteCode: null == fetchingInviteCode + ? _value.fetchingInviteCode + : fetchingInviteCode // ignore: cast_nullable_to_non_nullable + as bool, + selectedSpace: freezed == selectedSpace + ? _value.selectedSpace + : selectedSpace // ignore: cast_nullable_to_non_nullable + as SpaceInfo?, + spaceInvitationCode: null == spaceInvitationCode + ? _value.spaceInvitationCode + : spaceInvitationCode // ignore: cast_nullable_to_non_nullable as String, spaceList: null == spaceList ? _value._spaceList : spaceList // ignore: cast_nullable_to_non_nullable as List, + error: freezed == error ? _value.error : error, )); } } @@ -163,9 +200,11 @@ class _$HomeViewStateImpl implements _HomeViewState { {this.allowSave = false, this.isCreating = false, this.loading = false, - this.selectedSpaceName = '', - this.invitationCode = '', - final List spaceList = const []}) + this.fetchingInviteCode = false, + this.selectedSpace, + this.spaceInvitationCode = '', + final List spaceList = const [], + this.error}) : _spaceList = spaceList; @override @@ -179,10 +218,12 @@ class _$HomeViewStateImpl implements _HomeViewState { final bool loading; @override @JsonKey() - final String selectedSpaceName; + final bool fetchingInviteCode; + @override + final SpaceInfo? selectedSpace; @override @JsonKey() - final String invitationCode; + final String spaceInvitationCode; final List _spaceList; @override @JsonKey() @@ -192,9 +233,12 @@ class _$HomeViewStateImpl implements _HomeViewState { return EqualUnmodifiableListView(_spaceList); } + @override + final Object? error; + @override String toString() { - return 'HomeViewState(allowSave: $allowSave, isCreating: $isCreating, loading: $loading, selectedSpaceName: $selectedSpaceName, invitationCode: $invitationCode, spaceList: $spaceList)'; + return 'HomeViewState(allowSave: $allowSave, isCreating: $isCreating, loading: $loading, fetchingInviteCode: $fetchingInviteCode, selectedSpace: $selectedSpace, spaceInvitationCode: $spaceInvitationCode, spaceList: $spaceList, error: $error)'; } @override @@ -207,12 +251,15 @@ class _$HomeViewStateImpl implements _HomeViewState { (identical(other.isCreating, isCreating) || other.isCreating == isCreating) && (identical(other.loading, loading) || other.loading == loading) && - (identical(other.selectedSpaceName, selectedSpaceName) || - other.selectedSpaceName == selectedSpaceName) && - (identical(other.invitationCode, invitationCode) || - other.invitationCode == invitationCode) && + (identical(other.fetchingInviteCode, fetchingInviteCode) || + other.fetchingInviteCode == fetchingInviteCode) && + (identical(other.selectedSpace, selectedSpace) || + other.selectedSpace == selectedSpace) && + (identical(other.spaceInvitationCode, spaceInvitationCode) || + other.spaceInvitationCode == spaceInvitationCode) && const DeepCollectionEquality() - .equals(other._spaceList, _spaceList)); + .equals(other._spaceList, _spaceList) && + const DeepCollectionEquality().equals(other.error, error)); } @override @@ -221,9 +268,11 @@ class _$HomeViewStateImpl implements _HomeViewState { allowSave, isCreating, loading, - selectedSpaceName, - invitationCode, - const DeepCollectionEquality().hash(_spaceList)); + fetchingInviteCode, + selectedSpace, + spaceInvitationCode, + const DeepCollectionEquality().hash(_spaceList), + const DeepCollectionEquality().hash(error)); @JsonKey(ignore: true) @override @@ -237,9 +286,11 @@ abstract class _HomeViewState implements HomeViewState { {final bool allowSave, final bool isCreating, final bool loading, - final String selectedSpaceName, - final String invitationCode, - final List spaceList}) = _$HomeViewStateImpl; + final bool fetchingInviteCode, + final SpaceInfo? selectedSpace, + final String spaceInvitationCode, + final List spaceList, + final Object? error}) = _$HomeViewStateImpl; @override bool get allowSave; @@ -248,12 +299,16 @@ abstract class _HomeViewState implements HomeViewState { @override bool get loading; @override - String get selectedSpaceName; + bool get fetchingInviteCode; @override - String get invitationCode; + SpaceInfo? get selectedSpace; + @override + String get spaceInvitationCode; @override List get spaceList; @override + Object? get error; + @override @JsonKey(ignore: true) _$$HomeViewStateImplCopyWith<_$HomeViewStateImpl> get copyWith => throw _privateConstructorUsedError; diff --git a/app/lib/ui/flow/onboard/pick_name_screen.dart b/app/lib/ui/flow/onboard/pick_name_screen.dart index 236b58fc..4200a71a 100644 --- a/app/lib/ui/flow/onboard/pick_name_screen.dart +++ b/app/lib/ui/flow/onboard/pick_name_screen.dart @@ -6,6 +6,7 @@ import 'package:style/text/app_text_dart.dart'; import 'package:yourspace_flutter/domain/extenstions/context_extenstions.dart'; import 'package:yourspace_flutter/ui/app_route.dart'; import 'package:yourspace_flutter/ui/components/app_page.dart'; +import 'package:yourspace_flutter/ui/components/error_snakebar.dart'; import 'package:yourspace_flutter/ui/flow/onboard/pick_name_view_model.dart'; class PickNameScreen extends ConsumerStatefulWidget { @@ -37,8 +38,9 @@ class _PickNameScreenState extends ConsumerState { @override Widget build(BuildContext context) { final state = ref.watch(pickNameStateNotifierProvider); - + _observeError(); _navToHomeAfterSave(); + return AppPage(body: Builder(builder: (context) { return Column( children: [ @@ -120,4 +122,12 @@ class _PickNameScreenState extends ConsumerState { } }); } + + void _observeError() { + ref.listen(pickNameStateNotifierProvider.select((state) => state.error), (previous, next) { + if (next != null) { + showErrorSnackBar(context, next.toString()); + } + }); + } } diff --git a/app/lib/ui/flow/space/create/create_space_screen.dart b/app/lib/ui/flow/space/create/create_space_screen.dart index 639f255c..4c0847ca 100644 --- a/app/lib/ui/flow/space/create/create_space_screen.dart +++ b/app/lib/ui/flow/space/create/create_space_screen.dart @@ -7,6 +7,7 @@ import 'package:style/text/app_text_dart.dart'; import 'package:yourspace_flutter/domain/extenstions/context_extenstions.dart'; import 'package:yourspace_flutter/ui/app_route.dart'; import 'package:yourspace_flutter/ui/components/app_page.dart'; +import 'package:yourspace_flutter/ui/components/error_snakebar.dart'; import 'package:yourspace_flutter/ui/flow/space/create/create_space_view_model.dart'; import 'package:style/button/bottom_sticky_overlay.dart'; @@ -20,21 +21,12 @@ class CreateSpace extends ConsumerStatefulWidget { class _CreateSpaceState extends ConsumerState { late CreateSpaceViewNotifier notifier; - void _observeNavigation(CreateSpaceViewState state) { - ref.listen(createSpaceViewStateProvider.select((state) => state.invitationCode), - (_, next) { - if (next.isNotEmpty) { - AppRoute.inviteCode( - code: next, spaceName: state.spaceName.text).pushReplacement(context); - } - }); - } - @override Widget build(BuildContext context) { notifier = ref.read(createSpaceViewStateProvider.notifier); final state = ref.watch(createSpaceViewStateProvider); _observeNavigation(state); + _observeError(); return AppPage( title: '', @@ -183,4 +175,22 @@ class _CreateSpaceState extends ConsumerState { ), ); } + + void _observeNavigation(CreateSpaceViewState state) { + ref.listen(createSpaceViewStateProvider.select((state) => state.invitationCode), + (_, next) { + if (next.isNotEmpty) { + AppRoute.inviteCode( + code: next, spaceName: state.spaceName.text).pushReplacement(context); + } + }); + } + + void _observeError() { + ref.listen(createSpaceViewStateProvider.select((state) => state.error), (previous, next) { + if (next != null) { + showErrorSnackBar(context, next.toString()); + } + }); + } } diff --git a/app/lib/ui/flow/space/create/create_space_view_model.dart b/app/lib/ui/flow/space/create/create_space_view_model.dart index 39862190..a89b74e2 100644 --- a/app/lib/ui/flow/space/create/create_space_view_model.dart +++ b/app/lib/ui/flow/space/create/create_space_view_model.dart @@ -27,6 +27,7 @@ class CreateSpaceViewNotifier extends StateNotifier { final invitationCode = await spaceService.createSpaceAndGetInviteCode(state.spaceName.text); state = state.copyWith(isCreating: false, invitationCode: invitationCode); } catch (error, stack) { + state = state.copyWith(error: error); logger.e( 'CreateSpaceViewNotifier: $error - error while creating new space', error: error, @@ -62,5 +63,6 @@ class CreateSpaceViewState with _$CreateSpaceViewState { @Default('') String selectedSpaceName, @Default('') String invitationCode, required TextEditingController spaceName, + Object? error, }) = _CreateSpaceViewState; } diff --git a/app/lib/ui/flow/space/create/create_space_view_model.freezed.dart b/app/lib/ui/flow/space/create/create_space_view_model.freezed.dart index b74b6311..607c17ba 100644 --- a/app/lib/ui/flow/space/create/create_space_view_model.freezed.dart +++ b/app/lib/ui/flow/space/create/create_space_view_model.freezed.dart @@ -21,6 +21,7 @@ mixin _$CreateSpaceViewState { String get selectedSpaceName => throw _privateConstructorUsedError; String get invitationCode => throw _privateConstructorUsedError; TextEditingController get spaceName => throw _privateConstructorUsedError; + Object? get error => throw _privateConstructorUsedError; @JsonKey(ignore: true) $CreateSpaceViewStateCopyWith get copyWith => @@ -38,7 +39,8 @@ abstract class $CreateSpaceViewStateCopyWith<$Res> { bool isCreating, String selectedSpaceName, String invitationCode, - TextEditingController spaceName}); + TextEditingController spaceName, + Object? error}); } /// @nodoc @@ -60,6 +62,7 @@ class _$CreateSpaceViewStateCopyWithImpl<$Res, Object? selectedSpaceName = null, Object? invitationCode = null, Object? spaceName = null, + Object? error = freezed, }) { return _then(_value.copyWith( allowSave: null == allowSave @@ -82,6 +85,7 @@ class _$CreateSpaceViewStateCopyWithImpl<$Res, ? _value.spaceName : spaceName // ignore: cast_nullable_to_non_nullable as TextEditingController, + error: freezed == error ? _value.error : error, ) as $Val); } } @@ -99,7 +103,8 @@ abstract class _$$CreateSpaceViewStateImplCopyWith<$Res> bool isCreating, String selectedSpaceName, String invitationCode, - TextEditingController spaceName}); + TextEditingController spaceName, + Object? error}); } /// @nodoc @@ -118,6 +123,7 @@ class __$$CreateSpaceViewStateImplCopyWithImpl<$Res> Object? selectedSpaceName = null, Object? invitationCode = null, Object? spaceName = null, + Object? error = freezed, }) { return _then(_$CreateSpaceViewStateImpl( allowSave: null == allowSave @@ -140,6 +146,7 @@ class __$$CreateSpaceViewStateImplCopyWithImpl<$Res> ? _value.spaceName : spaceName // ignore: cast_nullable_to_non_nullable as TextEditingController, + error: freezed == error ? _value.error : error, )); } } @@ -152,7 +159,8 @@ class _$CreateSpaceViewStateImpl implements _CreateSpaceViewState { this.isCreating = false, this.selectedSpaceName = '', this.invitationCode = '', - required this.spaceName}); + required this.spaceName, + this.error}); @override @JsonKey() @@ -168,10 +176,12 @@ class _$CreateSpaceViewStateImpl implements _CreateSpaceViewState { final String invitationCode; @override final TextEditingController spaceName; + @override + final Object? error; @override String toString() { - return 'CreateSpaceViewState(allowSave: $allowSave, isCreating: $isCreating, selectedSpaceName: $selectedSpaceName, invitationCode: $invitationCode, spaceName: $spaceName)'; + return 'CreateSpaceViewState(allowSave: $allowSave, isCreating: $isCreating, selectedSpaceName: $selectedSpaceName, invitationCode: $invitationCode, spaceName: $spaceName, error: $error)'; } @override @@ -188,12 +198,19 @@ class _$CreateSpaceViewStateImpl implements _CreateSpaceViewState { (identical(other.invitationCode, invitationCode) || other.invitationCode == invitationCode) && (identical(other.spaceName, spaceName) || - other.spaceName == spaceName)); + other.spaceName == spaceName) && + const DeepCollectionEquality().equals(other.error, error)); } @override - int get hashCode => Object.hash(runtimeType, allowSave, isCreating, - selectedSpaceName, invitationCode, spaceName); + int get hashCode => Object.hash( + runtimeType, + allowSave, + isCreating, + selectedSpaceName, + invitationCode, + spaceName, + const DeepCollectionEquality().hash(error)); @JsonKey(ignore: true) @override @@ -206,12 +223,12 @@ class _$CreateSpaceViewStateImpl implements _CreateSpaceViewState { abstract class _CreateSpaceViewState implements CreateSpaceViewState { const factory _CreateSpaceViewState( - {final bool allowSave, - final bool isCreating, - final String selectedSpaceName, - final String invitationCode, - required final TextEditingController spaceName}) = - _$CreateSpaceViewStateImpl; + {final bool allowSave, + final bool isCreating, + final String selectedSpaceName, + final String invitationCode, + required final TextEditingController spaceName, + final Object? error}) = _$CreateSpaceViewStateImpl; @override bool get allowSave; @@ -224,6 +241,8 @@ abstract class _CreateSpaceViewState implements CreateSpaceViewState { @override TextEditingController get spaceName; @override + Object? get error; + @override @JsonKey(ignore: true) _$$CreateSpaceViewStateImplCopyWith<_$CreateSpaceViewStateImpl> get copyWith => throw _privateConstructorUsedError; diff --git a/app/lib/ui/flow/space/join/join_space_screen.dart b/app/lib/ui/flow/space/join/join_space_screen.dart index 5cd438ed..5863077d 100644 --- a/app/lib/ui/flow/space/join/join_space_screen.dart +++ b/app/lib/ui/flow/space/join/join_space_screen.dart @@ -1,28 +1,52 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:style/button/bottom_sticky_overlay.dart'; +import 'package:style/button/primary_button.dart'; import 'package:style/extenstions/context_extenstions.dart'; import 'package:style/text/app_text_dart.dart'; import 'package:yourspace_flutter/domain/extenstions/context_extenstions.dart'; +import 'package:yourspace_flutter/ui/components/alert.dart'; +import 'package:yourspace_flutter/ui/components/error_snakebar.dart'; +import 'package:yourspace_flutter/ui/flow/space/join/join_space_view_model.dart'; import '../../../components/app_page.dart'; class JoinSpace extends ConsumerStatefulWidget { - final String invitationCode; - final String spaceName; - - const JoinSpace({ - super.key, - required this.invitationCode, - required this.spaceName, - }); + const JoinSpace({super.key}); @override ConsumerState createState() => _JoinSpaceState(); } class _JoinSpaceState extends ConsumerState { + late JoinSpaceViewNotifier notifier; + late List _controllers; + late List _focusNodes; + late bool enabled = false; + + @override + void initState() { + _controllers = List.generate(6, (index) => TextEditingController()); + _focusNodes = List.generate(6, (index) => FocusNode( + onKeyEvent: (node, event) { + if(event.logicalKey == LogicalKeyboardKey.backspace + && _controllers[index].text.isEmpty) { + if (index > 0) _focusNodes[index - 1].requestFocus(); + } + return KeyEventResult.ignored; + } + )); + super.initState(); + } + @override Widget build(BuildContext context) { + notifier = ref.watch(joinSpaceViewStateProvider.notifier); + _observeError(); + _showCongratulationPrompt(); + return AppPage( title: context.l10n.join_space_title, body: _body(context), @@ -30,9 +54,10 @@ class _JoinSpaceState extends ConsumerState { } Widget _body(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(16), - child: Column( + final state = ref.watch(joinSpaceViewStateProvider); + return Stack(children: [ + ListView( + padding: const EdgeInsets.all(16), children: [ Text( context.l10n.join_space_invite_code_title, @@ -52,7 +77,8 @@ class _JoinSpaceState extends ConsumerState { ), ], ), - ); + _joinSpaceButton(context, state), + ]); } Widget _inviteCode(BuildContext context) { @@ -63,10 +89,10 @@ class _JoinSpaceState extends ConsumerState { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - _buildCodeBox(context, containerWidth), - _buildCodeBox(context, containerWidth), - _buildCodeBox(context, containerWidth), - const SizedBox(width: 8), + for (int i = 0; i < 3; i++) ...[ + _buildCodeBox(context, containerWidth, i), + const SizedBox(width: 4), + ], Text( '-', style: AppTextStyle.header3.copyWith( @@ -74,32 +100,123 @@ class _JoinSpaceState extends ConsumerState { ), ), const SizedBox(width: 8), - _buildCodeBox(context, containerWidth), - _buildCodeBox(context, containerWidth), - _buildCodeBox(context, containerWidth), + for (int i = 3; i < 6; i++) ...[ + _buildCodeBox(context, containerWidth, i), + const SizedBox(width: 4), + ], ], ); }, ); } - Widget _buildCodeBox(BuildContext context, double width) { + Widget _buildCodeBox(BuildContext context, double width, int index) { return Container( width: width, - height: 60, + height: 64, margin: const EdgeInsets.symmetric(horizontal: 4), decoration: BoxDecoration( color: context.colorScheme.containerLow, borderRadius: BorderRadius.circular(8), ), - child: const TextField( - textAlign: TextAlign.center, - maxLength: 1, - decoration: InputDecoration( - border: InputBorder.none, - counterText: '', + child: Center( + child: TextField( + controller: _controllers[index], + focusNode: _focusNodes[index], + textAlign: TextAlign.center, + maxLength: 1, + decoration: const InputDecoration( + border: InputBorder.none, + counterText: '', + ), + textCapitalization: TextCapitalization.characters, + style: AppTextStyle.header2.copyWith( + color: context.colorScheme.textPrimary, + ), + onTapOutside: (event) { + FocusManager.instance.primaryFocus?.unfocus(); + }, + onChanged: (text) { + if (text.isEmpty) { + if (index > 0) _focusNodes[index - 1].requestFocus(); + } else { + if (index < 5) _focusNodes[index + 1].requestFocus(); + } + _updateJoinSpaceButtonState(); + }, ), ), ); } + + Widget _joinSpaceButton(BuildContext context, JoinSpaceViewState state) { + return BottomStickyOverlay( + child: Column( + children: [ + _joinSpaceError(context, state), + const SizedBox(height: 16), + PrimaryButton( + context.l10n.join_space_title, + progress: state.verifying, + enabled: enabled, + onPressed: () { + final inviteCode = _controllers.map((controller) => controller.text.trim()).join(); + notifier.joinSpace(inviteCode.toUpperCase()); + }, + ), + ], + ), + ); + } + + Widget _joinSpaceError(BuildContext context, JoinSpaceViewState state) { + return Visibility( + visible: state.errorInvalidInvitationCode || state.alreadySpaceMember, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: context.colorScheme.alert + ), + child: Text( + state.errorInvalidInvitationCode + ? context.l10n.join_space_invalid_code_error_text + : context.l10n.join_space_already_joined_error_text, + style: AppTextStyle.subtitle2.copyWith( + color: Colors.white, + ), + ), + ), + ); + } + + void _updateJoinSpaceButtonState() { + setState(() { + enabled = _controllers.every((controller) => controller.text.trim().isNotEmpty); + }); + } + + void _observeError() { + ref.listen(joinSpaceViewStateProvider.select((state) => state.error), (previous, next) { + if (next != null) { + showErrorSnackBar(context, next.toString()); + } + }); + } + + void _showCongratulationPrompt() { + ref.listen(joinSpaceViewStateProvider.select((state) => state.space), + (previous, next) { + if (next != null) { + showOkayConfirmation( + context, + title: context.l10n.join_space_congratulation_title, + message: context.l10n + .join_space_congratulation_subtitle(next.name), + onOkay: () { + context.pop(); + }, + ); + } + }); + } } diff --git a/app/lib/ui/flow/space/join/join_space_view_model.dart b/app/lib/ui/flow/space/join/join_space_view_model.dart new file mode 100644 index 00000000..fc8f0e9d --- /dev/null +++ b/app/lib/ui/flow/space/join/join_space_view_model.dart @@ -0,0 +1,73 @@ +import 'package:data/api/space/api_space_invitation_service.dart'; +import 'package:data/api/space/space_models.dart'; +import 'package:data/service/auth_service.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:data/service/space_service.dart'; +import 'package:data/log/logger.dart'; + +part 'join_space_view_model.freezed.dart'; + +final joinSpaceViewStateProvider = StateNotifierProvider.autoDispose< + JoinSpaceViewNotifier, JoinSpaceViewState>((ref) { + return JoinSpaceViewNotifier( + ref.read(spaceServiceProvider), + ref.read(apiSpaceInvitationServiceProvider), + ref.read(authServiceProvider), + ); +}); + +class JoinSpaceViewNotifier extends StateNotifier { + final SpaceService spaceService; + final ApiSpaceInvitationService spaceInvitationService; + final AuthService authService; + + JoinSpaceViewNotifier( + this.spaceService, + this.spaceInvitationService, + this.authService, + ) : super(const JoinSpaceViewState()); + + Future joinSpace(String code) async { + try { + state = state.copyWith(verifying: true); + final invitation = await spaceInvitationService.getInvitation(code); + if (invitation == null) { + state = + state.copyWith(errorInvalidInvitationCode: true, verifying: false); + return; + } + var spaceId = invitation.space_id; + final userSpaces = authService.currentUser?.space_ids ?? []; + + if (userSpaces.contains(spaceId)) { + state = state.copyWith(verifying: false, alreadySpaceMember: true); + return; + } + + spaceService.joinSpace(spaceId); + final space = await spaceService.getSpace(spaceId); + state = state.copyWith(verifying: false, space: space, spaceJoined: true); + } catch (error, stack) { + state = state.copyWith(error: error); + logger.e( + 'JoinSpaceViewNotifier: Error while join space with invitation code', + error: error, + stackTrace: stack, + ); + } + } +} + +@freezed +class JoinSpaceViewState with _$JoinSpaceViewState { + const factory JoinSpaceViewState({ + @Default(false) bool verifying, + @Default(false) bool spaceJoined, + @Default('') String invitationCode, + @Default(false) bool errorInvalidInvitationCode, + @Default(false) bool alreadySpaceMember, + Object? error, + ApiSpace? space, + }) = _JoinSpaceViewState; +} diff --git a/app/lib/ui/flow/space/join/join_space_view_model.freezed.dart b/app/lib/ui/flow/space/join/join_space_view_model.freezed.dart new file mode 100644 index 00000000..ccaa5109 --- /dev/null +++ b/app/lib/ui/flow/space/join/join_space_view_model.freezed.dart @@ -0,0 +1,288 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'join_space_view_model.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +/// @nodoc +mixin _$JoinSpaceViewState { + bool get verifying => throw _privateConstructorUsedError; + bool get spaceJoined => throw _privateConstructorUsedError; + String get invitationCode => throw _privateConstructorUsedError; + bool get errorInvalidInvitationCode => throw _privateConstructorUsedError; + bool get alreadySpaceMember => throw _privateConstructorUsedError; + Object? get error => throw _privateConstructorUsedError; + ApiSpace? get space => throw _privateConstructorUsedError; + + @JsonKey(ignore: true) + $JoinSpaceViewStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $JoinSpaceViewStateCopyWith<$Res> { + factory $JoinSpaceViewStateCopyWith( + JoinSpaceViewState value, $Res Function(JoinSpaceViewState) then) = + _$JoinSpaceViewStateCopyWithImpl<$Res, JoinSpaceViewState>; + @useResult + $Res call( + {bool verifying, + bool spaceJoined, + String invitationCode, + bool errorInvalidInvitationCode, + bool alreadySpaceMember, + Object? error, + ApiSpace? space}); + + $ApiSpaceCopyWith<$Res>? get space; +} + +/// @nodoc +class _$JoinSpaceViewStateCopyWithImpl<$Res, $Val extends JoinSpaceViewState> + implements $JoinSpaceViewStateCopyWith<$Res> { + _$JoinSpaceViewStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? verifying = null, + Object? spaceJoined = null, + Object? invitationCode = null, + Object? errorInvalidInvitationCode = null, + Object? alreadySpaceMember = null, + Object? error = freezed, + Object? space = freezed, + }) { + return _then(_value.copyWith( + verifying: null == verifying + ? _value.verifying + : verifying // ignore: cast_nullable_to_non_nullable + as bool, + spaceJoined: null == spaceJoined + ? _value.spaceJoined + : spaceJoined // ignore: cast_nullable_to_non_nullable + as bool, + invitationCode: null == invitationCode + ? _value.invitationCode + : invitationCode // ignore: cast_nullable_to_non_nullable + as String, + errorInvalidInvitationCode: null == errorInvalidInvitationCode + ? _value.errorInvalidInvitationCode + : errorInvalidInvitationCode // ignore: cast_nullable_to_non_nullable + as bool, + alreadySpaceMember: null == alreadySpaceMember + ? _value.alreadySpaceMember + : alreadySpaceMember // ignore: cast_nullable_to_non_nullable + as bool, + error: freezed == error ? _value.error : error, + space: freezed == space + ? _value.space + : space // ignore: cast_nullable_to_non_nullable + as ApiSpace?, + ) as $Val); + } + + @override + @pragma('vm:prefer-inline') + $ApiSpaceCopyWith<$Res>? get space { + if (_value.space == null) { + return null; + } + + return $ApiSpaceCopyWith<$Res>(_value.space!, (value) { + return _then(_value.copyWith(space: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$JoinSpaceViewStateImplCopyWith<$Res> + implements $JoinSpaceViewStateCopyWith<$Res> { + factory _$$JoinSpaceViewStateImplCopyWith(_$JoinSpaceViewStateImpl value, + $Res Function(_$JoinSpaceViewStateImpl) then) = + __$$JoinSpaceViewStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {bool verifying, + bool spaceJoined, + String invitationCode, + bool errorInvalidInvitationCode, + bool alreadySpaceMember, + Object? error, + ApiSpace? space}); + + @override + $ApiSpaceCopyWith<$Res>? get space; +} + +/// @nodoc +class __$$JoinSpaceViewStateImplCopyWithImpl<$Res> + extends _$JoinSpaceViewStateCopyWithImpl<$Res, _$JoinSpaceViewStateImpl> + implements _$$JoinSpaceViewStateImplCopyWith<$Res> { + __$$JoinSpaceViewStateImplCopyWithImpl(_$JoinSpaceViewStateImpl _value, + $Res Function(_$JoinSpaceViewStateImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? verifying = null, + Object? spaceJoined = null, + Object? invitationCode = null, + Object? errorInvalidInvitationCode = null, + Object? alreadySpaceMember = null, + Object? error = freezed, + Object? space = freezed, + }) { + return _then(_$JoinSpaceViewStateImpl( + verifying: null == verifying + ? _value.verifying + : verifying // ignore: cast_nullable_to_non_nullable + as bool, + spaceJoined: null == spaceJoined + ? _value.spaceJoined + : spaceJoined // ignore: cast_nullable_to_non_nullable + as bool, + invitationCode: null == invitationCode + ? _value.invitationCode + : invitationCode // ignore: cast_nullable_to_non_nullable + as String, + errorInvalidInvitationCode: null == errorInvalidInvitationCode + ? _value.errorInvalidInvitationCode + : errorInvalidInvitationCode // ignore: cast_nullable_to_non_nullable + as bool, + alreadySpaceMember: null == alreadySpaceMember + ? _value.alreadySpaceMember + : alreadySpaceMember // ignore: cast_nullable_to_non_nullable + as bool, + error: freezed == error ? _value.error : error, + space: freezed == space + ? _value.space + : space // ignore: cast_nullable_to_non_nullable + as ApiSpace?, + )); + } +} + +/// @nodoc + +class _$JoinSpaceViewStateImpl implements _JoinSpaceViewState { + const _$JoinSpaceViewStateImpl( + {this.verifying = false, + this.spaceJoined = false, + this.invitationCode = '', + this.errorInvalidInvitationCode = false, + this.alreadySpaceMember = false, + this.error, + this.space}); + + @override + @JsonKey() + final bool verifying; + @override + @JsonKey() + final bool spaceJoined; + @override + @JsonKey() + final String invitationCode; + @override + @JsonKey() + final bool errorInvalidInvitationCode; + @override + @JsonKey() + final bool alreadySpaceMember; + @override + final Object? error; + @override + final ApiSpace? space; + + @override + String toString() { + return 'JoinSpaceViewState(verifying: $verifying, spaceJoined: $spaceJoined, invitationCode: $invitationCode, errorInvalidInvitationCode: $errorInvalidInvitationCode, alreadySpaceMember: $alreadySpaceMember, error: $error, space: $space)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$JoinSpaceViewStateImpl && + (identical(other.verifying, verifying) || + other.verifying == verifying) && + (identical(other.spaceJoined, spaceJoined) || + other.spaceJoined == spaceJoined) && + (identical(other.invitationCode, invitationCode) || + other.invitationCode == invitationCode) && + (identical(other.errorInvalidInvitationCode, + errorInvalidInvitationCode) || + other.errorInvalidInvitationCode == + errorInvalidInvitationCode) && + (identical(other.alreadySpaceMember, alreadySpaceMember) || + other.alreadySpaceMember == alreadySpaceMember) && + const DeepCollectionEquality().equals(other.error, error) && + (identical(other.space, space) || other.space == space)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + verifying, + spaceJoined, + invitationCode, + errorInvalidInvitationCode, + alreadySpaceMember, + const DeepCollectionEquality().hash(error), + space); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$JoinSpaceViewStateImplCopyWith<_$JoinSpaceViewStateImpl> get copyWith => + __$$JoinSpaceViewStateImplCopyWithImpl<_$JoinSpaceViewStateImpl>( + this, _$identity); +} + +abstract class _JoinSpaceViewState implements JoinSpaceViewState { + const factory _JoinSpaceViewState( + {final bool verifying, + final bool spaceJoined, + final String invitationCode, + final bool errorInvalidInvitationCode, + final bool alreadySpaceMember, + final Object? error, + final ApiSpace? space}) = _$JoinSpaceViewStateImpl; + + @override + bool get verifying; + @override + bool get spaceJoined; + @override + String get invitationCode; + @override + bool get errorInvalidInvitationCode; + @override + bool get alreadySpaceMember; + @override + Object? get error; + @override + ApiSpace? get space; + @override + @JsonKey(ignore: true) + _$$JoinSpaceViewStateImplCopyWith<_$JoinSpaceViewStateImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/data/lib/api/space/api_space_invitation_service.dart b/data/lib/api/space/api_space_invitation_service.dart index e7d18e03..e8559aa5 100644 --- a/data/lib/api/space/api_space_invitation_service.dart +++ b/data/lib/api/space/api_space_invitation_service.dart @@ -14,7 +14,10 @@ class ApiSpaceInvitationService { ApiSpaceInvitationService(this._db); - CollectionReference get _spaceRef => _db.collection("space_invitations"); + CollectionReference get _spaceRef => + _db.collection("space_invitations").withConverter( + fromFirestore: ApiSpaceInvitation.fromFireStore, + toFirestore: (space, options) => space.toJson()); Future createInvitation(String spaceId) async { String invitationCode = generateInvitationCode(); @@ -26,7 +29,7 @@ class ApiSpaceInvitationService { created_at: DateTime.now().millisecondsSinceEpoch, ); - await docRef.set(invitation.toFireStore()); + await docRef.set(invitation); return invitationCode; } @@ -41,7 +44,10 @@ class ApiSpaceInvitationService { Future getSpaceInviteCode(String spaceId) async { final querySnapshot = await _spaceRef.where("space_id", isEqualTo: spaceId).get(); final docSnapshot = querySnapshot.docs.firstOrNull; - return docSnapshot?.data() as ApiSpaceInvitation?; + if (docSnapshot!.exists) { + return docSnapshot.data() as ApiSpaceInvitation; + } + return null; } Future regenerateInvitationCode(String spaceId) async { @@ -58,8 +64,10 @@ class ApiSpaceInvitationService { Future getInvitation(String inviteCode) async { final querySnapshot = await _spaceRef.where("code", isEqualTo: inviteCode.toUpperCase()).get(); final docSnapshot = querySnapshot.docs.firstOrNull; - final invitation = docSnapshot?.data() as ApiSpaceInvitation?; - return invitation?.isExpired ?? false ? null : invitation; + if (docSnapshot?.exists ?? false) { + return docSnapshot?.data() as ApiSpaceInvitation; + } + return null; } Future deleteInvitations(String spaceId) async { diff --git a/data/lib/service/space_service.dart b/data/lib/service/space_service.dart index 9cae93cd..df8ec340 100644 --- a/data/lib/service/space_service.dart +++ b/data/lib/service/space_service.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:data/api/auth/api_user_service.dart'; import 'package:data/api/space/api_space_invitation_service.dart'; import 'package:data/api/space/api_space_service.dart'; @@ -52,32 +54,33 @@ class SpaceService { Future> getAllSpaceInfo() async { final userId = currentUser?.id ?? ''; final spaces = await getUserSpaces(userId); - if (spaces.isEmpty) return []; + final List spaceInfoList = []; - final flows = spaces - .where((space) => space != null) - .map((space) async { - final members = await spaceService.getMembersBySpaceId(space!.id); + if (spaces.isEmpty) { + return spaceInfoList; + } + for (final space in spaces) { + if (space == null) continue; + final members = await spaceService.getMembersBySpaceId(space.id); - final memberInfoList = await Future.wait( - members.map((member) async { - final user = await userService.getUser(member.user_id); - return user != null - ? ApiUserInfo(user: user, isLocationEnabled: member.location_enabled) - : null; - }), - ); + final memberInfoList = await Future.wait( + members.map((member) async { + final user = await userService.getUser(member.user_id); + return user != null + ? ApiUserInfo(user: user, isLocationEnabled: member.location_enabled) + : null; + }), + ); - final nonNullMembers = memberInfoList.whereType().toList(); + final nonNullMembers = memberInfoList.whereType().toList(); - return SpaceInfo( + final spaceInfo = SpaceInfo( space: space, members: nonNullMembers, - ); - }).toList(); - - final spaceInfo = await Future.wait(flows); - return spaceInfo; + ); + spaceInfoList.add(spaceInfo); + } + return spaceInfoList; } Future getCurrentSpaceInfo() async { @@ -88,26 +91,29 @@ class SpaceService { space: currentSpace, members: members .map((member) async { - final user = await userService.getUser(member.user_id); - return user != null - ? ApiUserInfo(user: user, isLocationEnabled: member.location_enabled) - : null; - }) - .whereType() - .toList(), + final user = await userService.getUser(member.user_id); + return user != null + ? ApiUserInfo(user: user, isLocationEnabled: member.location_enabled) + : null; + }) + .whereType() + .toList(), ); } Future getSpaceInfo(String spaceId) async { final space = await getSpace(spaceId); if (space == null) return null; + final members = await spaceService.getMembersBySpaceId(space.id); + final memberInfo = await Future.wait(members.map((member) async { + final user = await userService.getUser(member.user_id); + return user != null ? ApiUserInfo(user: user, isLocationEnabled: member.location_enabled) : null; + }).toList()); + return SpaceInfo( space: space, - members: members.map((member) async { - final user = await userService.getUser(member.user_id); - return user != null ? ApiUserInfo(user: user, isLocationEnabled: member.location_enabled) : null; - }).whereType().toList(), + members: memberInfo.whereType().toList(), ); } @@ -135,7 +141,7 @@ class SpaceService { return spaceService.getSpace(spaceId); } - Future>> getMemberBySpaceId( + Future> getMemberBySpaceId( String spaceId) async { return spaceService.getMembersBySpaceId(spaceId); }