diff --git a/khelo/assets/locales/app_en.arb b/khelo/assets/locales/app_en.arb index 77f7101b..548d3b93 100644 --- a/khelo/assets/locales/app_en.arb +++ b/khelo/assets/locales/app_en.arb @@ -161,8 +161,8 @@ "search_team_search_placeholder_title": "Search team name", "search_team_your_teams_title": "Your Teams", "search_team_select_title": "Select", - "search_team_member_title": "{count, plural, =0{{count} Members} =1{{count} Member} other{{count} Members}}", - "@search_team_member_title": { + "search_team_player_title": "{count, plural, =0{{count} Players} =1{{count} Player} other{{count} Players}}", + "@search_team_player_title": { "placeholders": { "count": {} } diff --git a/khelo/lib/ui/flow/team/search_team/components/team_member_dialog.dart b/khelo/lib/ui/flow/team/search_team/components/team_member_dialog.dart index 7de30f8e..fbc75473 100644 --- a/khelo/lib/ui/flow/team/search_team/components/team_member_dialog.dart +++ b/khelo/lib/ui/flow/team/search_team/components/team_member_dialog.dart @@ -2,25 +2,28 @@ import 'package:data/api/team/team_model.dart'; import 'package:data/api/user/user_models.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:khelo/components/image_avatar.dart'; +import 'package:khelo/components/user_detail_cell.dart'; import 'package:khelo/domain/extensions/context_extensions.dart'; -import 'package:khelo/domain/extensions/enum_extensions.dart'; -import 'package:khelo/domain/formatter/string_formatter.dart'; import 'package:khelo/ui/flow/team/add_team_member/components/verify_add_team_member_dialog.dart'; import 'package:style/animations/on_tap_scale.dart'; import 'package:style/extensions/context_extensions.dart'; import 'package:style/text/app_text_style.dart'; -class TeamMemberDialog extends StatelessWidget { +class TeamMemberSheet extends StatelessWidget { static Future show( BuildContext context, { required TeamModel team, bool isForVerification = false, }) { - return showDialog( + return showModalBottomSheet( context: context, + isScrollControlled: true, + enableDrag: false, + showDragHandle: true, + useRootNavigator: true, + backgroundColor: context.colorScheme.surface, builder: (context) { - return TeamMemberDialog( + return TeamMemberSheet( team: team, isForVerification: isForVerification, ); @@ -31,7 +34,7 @@ class TeamMemberDialog extends StatelessWidget { final TeamModel team; final bool isForVerification; - const TeamMemberDialog({ + const TeamMemberSheet({ super.key, required this.team, this.isForVerification = false, @@ -39,70 +42,38 @@ class TeamMemberDialog extends StatelessWidget { @override Widget build(BuildContext context) { - return AlertDialog( - backgroundColor: context.colorScheme.containerLowOnSurface, - title: Text( - team.name, - style: AppTextStyle.subtitle1 - .copyWith(color: context.colorScheme.textPrimary), - ), - content: SingleChildScrollView( - child: Wrap( - runSpacing: 16, + return Container( + padding: context.mediaQueryPadding + + const EdgeInsets.only(bottom: 24, left: 16, right: 16), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - for (final member in team.players != null ? team.players! : []) ...[ - _userProfileCell(context, member), - ], + Text( + team.name, + style: AppTextStyle.subtitle1 + .copyWith(color: context.colorScheme.textPrimary), + ), + const SizedBox(height: 16), + Wrap( + runSpacing: 16, + children: (team.players ?? []) + .map( + (member) => UserDetailCell( + user: member, + trailing: isForVerification + ? _selectButton(context, member) + : null, + ), + ) + .toList()), ], ), ), ); } - Widget _userProfileCell(BuildContext context, UserModel user) { - return Row( - children: [ - ImageAvatar( - initial: user.nameInitial, - imageUrl: user.profile_img_url, - size: 50, - ), - const SizedBox( - width: 8, - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - user.name ?? context.l10n.common_anonymous_title, - style: AppTextStyle.header4 - .copyWith(color: context.colorScheme.textPrimary), - ), - Text( - user.player_role != null - ? user.player_role!.getString(context) - : context.l10n.common_not_specified_title, - style: AppTextStyle.subtitle2 - .copyWith(color: context.colorScheme.textSecondary)), - if (user.phone != null) ...[ - const SizedBox( - height: 2, - ), - Text( - user.phone.format(context, StringFormats.obscurePhoneNumber), - style: AppTextStyle.subtitle2 - .copyWith(color: context.colorScheme.textSecondary), - ), - ], - ], - ), - ), - if (isForVerification) ...[_selectButton(context, user)] - ], - ); - } - Widget _selectButton(BuildContext context, UserModel user) { return OnTapScale( onTap: () async { diff --git a/khelo/lib/ui/flow/team/search_team/search_team_screen.dart b/khelo/lib/ui/flow/team/search_team/search_team_screen.dart index 34355a86..d91080dd 100644 --- a/khelo/lib/ui/flow/team/search_team/search_team_screen.dart +++ b/khelo/lib/ui/flow/team/search_team/search_team_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:khelo/components/app_page.dart'; import 'package:khelo/components/error_screen.dart'; +import 'package:khelo/components/error_snackbar.dart'; import 'package:khelo/components/image_avatar.dart'; import 'package:khelo/domain/extensions/context_extensions.dart'; import 'package:khelo/domain/extensions/widget_extension.dart'; @@ -12,6 +13,7 @@ import 'package:khelo/ui/flow/team/search_team/search_team_view_model.dart'; import 'package:style/animations/on_tap_scale.dart'; import 'package:style/extensions/context_extensions.dart'; import 'package:style/indicator/progress_indicator.dart'; +import 'package:style/text/app_text_field.dart'; import 'package:style/text/app_text_style.dart'; class SearchTeamScreen extends ConsumerStatefulWidget { @@ -41,8 +43,8 @@ class _SearchTeamScreenState extends ConsumerState { @override Widget build(BuildContext context) { - notifier = ref.watch(searchTeamViewStateProvider.notifier); final state = ref.watch(searchTeamViewStateProvider); + _observeActionError(); return AppPage( title: context.l10n.search_team_screen_title, @@ -55,7 +57,7 @@ class _SearchTeamScreenState extends ConsumerState { .contains(state.selectedTeam?.id)) { context.pop(state.selectedTeam); } else { - final res = await TeamMemberDialog.show( + final res = await TeamMemberSheet.show( context, team: state.selectedTeam!, isForVerification: true, @@ -66,16 +68,21 @@ class _SearchTeamScreenState extends ConsumerState { } } : null, - icon: const Icon(Icons.check)), + icon: Icon( + Icons.check, + color: state.selectedTeam != null + ? context.colorScheme.primary + : context.colorScheme.textDisabled, + )), ], body: SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Column( children: [ - _searchTextField(context, notifier, state), + _searchTextField(context, state), Expanded( - child: _teamList(context, notifier, state), + child: _teamList(context, state), ), ], ), @@ -86,76 +93,46 @@ class _SearchTeamScreenState extends ConsumerState { Widget _searchTextField( BuildContext context, - SearchTeamViewNotifier notifier, SearchTeamState state, ) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Material( - type: MaterialType.transparency, - child: TextField( - controller: state.searchController, - onChanged: (value) => notifier.onSearchChanged(), - decoration: InputDecoration( - hintText: context.l10n.search_team_search_placeholder_title, - contentPadding: const EdgeInsets.all(16), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(30.0), - borderSide: BorderSide.none, - ), - filled: true, - fillColor: context.colorScheme.containerLowOnSurface, - hintStyle: TextStyle(color: context.colorScheme.textDisabled), - prefixIcon: Icon( - Icons.search, - color: context.colorScheme.textDisabled, - size: 24, - ), - suffix: state.searchInProgress - ? const AppProgressIndicator( - radius: 8, - ) - : const SizedBox( - height: 16, - ), - ), - onTapOutside: (event) { - FocusManager.instance.primaryFocus?.unfocus(); - }, - style: AppTextStyle.body2.copyWith( - color: context.colorScheme.textPrimary, - ), - ), + return AppTextField( + controller: state.searchController, + borderRadius: BorderRadius.circular(30), + contentPadding: const EdgeInsets.all(16), + borderType: AppTextFieldBorderType.outline, + onChanged: (value) => notifier.onSearchChanged(), + backgroundColor: context.colorScheme.containerLowOnSurface, + hintText: context.l10n.search_team_search_placeholder_title, + style: AppTextStyle.body2.copyWith( + color: context.colorScheme.textPrimary, ), - ); - } - - Widget _sectionTitle(BuildContext context, String title) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox( - height: 24, - ), - Text( - title, - style: AppTextStyle.header1 - .copyWith(color: context.colorScheme.textSecondary), - ), - const SizedBox( - height: 16, - ), - ], + hintStyle: AppTextStyle.subtitle2.copyWith( + color: context.colorScheme.textDisabled, + ), + borderColor: BorderColor( + focusColor: Colors.transparent, + unFocusColor: Colors.transparent, + ), + onTapOutside: (event) { + FocusManager.instance.primaryFocus?.unfocus(); + }, + prefixIcon: Icon( + Icons.search, + color: context.colorScheme.textDisabled, + size: 24, + ), + suffixIcon: state.searchInProgress + ? const UnconstrainedBox(child: AppProgressIndicator(radius: 8)) + : const SizedBox(), ); } Widget _teamList( BuildContext context, - SearchTeamViewNotifier notifier, SearchTeamState state, ) { if (state.loading) { - return const AppProgressIndicator(); + return const Center(child: AppProgressIndicator()); } if (state.error != null) { @@ -168,18 +145,19 @@ class _SearchTeamScreenState extends ConsumerState { return ListView( children: [ for (final team in state.searchResults) ...[ - const SizedBox( - height: 16, - ), - _teamProfileCell(context, notifier, state, team) + _teamProfileCell(context, state, team), + Divider(color: context.colorScheme.outline), ], if (state.userTeams.isNotEmpty) ...[ - _sectionTitle(context, context.l10n.search_team_your_teams_title), + const SizedBox(height: 24), + Text( + context.l10n.search_team_your_teams_title, + style: AppTextStyle.header2 + .copyWith(color: context.colorScheme.textSecondary), + ), for (final team in state.userTeams) ...[ - _teamProfileCell(context, notifier, state, team), - const SizedBox( - height: 16, - ), + _teamProfileCell(context, state, team), + Divider(color: context.colorScheme.outline), ], ], ], @@ -188,55 +166,69 @@ class _SearchTeamScreenState extends ConsumerState { Widget _teamProfileCell( BuildContext context, - SearchTeamViewNotifier notifier, SearchTeamState state, TeamModel team, ) { return OnTapScale( - onTap: () { - notifier.onTeamCellTap(team); - }, - onLongTap: () { - // TODO: play haptic feedback. - TeamMemberDialog.show(context, team: team); - }, - child: Row( - children: [ - ImageAvatar( - initial: team.name[0].toUpperCase(), - imageUrl: team.profile_img_url, - size: 50, - ), - const SizedBox( - width: 8, - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - team.name, - style: AppTextStyle.header4 - .copyWith(color: context.colorScheme.textPrimary), - ), - Text.rich(TextSpan( - text: team.city != null - ? team.city! - : context.l10n.common_not_specified_title, - style: AppTextStyle.subtitle2 - .copyWith(color: context.colorScheme.textSecondary), - children: [ - TextSpan( - text: - " - ${context.l10n.search_team_member_title(team.players?.length ?? 0)}") - ])), - ], - ), + onTap: () => notifier.onTeamCellTap(team), + onLongTap: () => TeamMemberSheet.show(context, team: team), + child: Material( + type: MaterialType.transparency, + child: ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: ImageAvatar( + initial: team.name[0].toUpperCase(), + imageUrl: team.profile_img_url, + size: 40, + ), + title: Text( + team.name, + style: AppTextStyle.subtitle2 + .copyWith(color: context.colorScheme.textPrimary), + ), + subtitle: Text.rich( + TextSpan( + text: team.city != null + ? team.city! + : context.l10n.common_not_specified_title, + style: AppTextStyle.body2 + .copyWith(color: context.colorScheme.textSecondary), + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 8), + constraints: + const BoxConstraints(maxHeight: 4, maxWidth: 4), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: context.colorScheme.textDisabled, + ), + )), + TextSpan( + text: context.l10n + .search_team_player_title(team.players?.length ?? 0), + ), + ], ), - if (team.id == state.selectedTeam?.id) ...[ - const Icon(Icons.check_circle_sharp) - ] - ], - )); + ), + trailing: (team.id == state.selectedTeam?.id) + ? const Icon(Icons.check_circle_sharp) + : null, + ), + ), + ); + } + + void _observeActionError() { + ref.listen( + searchTeamViewStateProvider.select((value) => value.actionError), + (previous, next) { + if (next != null) { + showErrorSnackBar(context: context, error: next); + } + }, + ); } } diff --git a/khelo/lib/ui/flow/team/search_team/search_team_view_model.dart b/khelo/lib/ui/flow/team/search_team/search_team_view_model.dart index 8ea6fc4a..a58d6238 100644 --- a/khelo/lib/ui/flow/team/search_team/search_team_view_model.dart +++ b/khelo/lib/ui/flow/team/search_team/search_team_view_model.dart @@ -47,30 +47,32 @@ class SearchTeamViewNotifier extends StateNotifier { } Future search(String searchKey) async { + state = state.copyWith(searchInProgress: true); + try { - state = state.copyWith(searchInProgress: true); + List filteredResult; if (onlyUserTeams) { - final filteredResult = state.userTeams - .where((element) => element.name.caseAndSpaceInsensitive + filteredResult = state.userTeams + .where((team) => team.name.caseAndSpaceInsensitive .startsWith(searchKey.caseAndSpaceInsensitive)) .toList(); - state = state.copyWith( - searchResults: filteredResult, - error: null, - searchInProgress: false); } else { final teams = await _teamService.searchTeam(searchKey); - final filteredResult = teams - .where((element) => !excludedIds.contains(element.id)) - .toList(); - state = state.copyWith( - searchResults: filteredResult, - error: null, - searchInProgress: false); + filteredResult = + teams.where((team) => !excludedIds.contains(team.id)).toList(); } + + state = state.copyWith( + searchResults: filteredResult, + error: null, + searchInProgress: false, + ); } catch (e) { - state = state.copyWith(searchInProgress: false, error: e); - debugPrint("SearchTeamViewNotifier: error while search team -> $e"); + state = state.copyWith( + searchInProgress: false, + error: e, + ); + debugPrint("SearchTeamViewNotifier: error while searching team -> $e"); } } @@ -87,7 +89,14 @@ class SearchTeamViewNotifier extends StateNotifier { } void onTeamCellTap(TeamModel team) { - state = state.copyWith(selectedTeam: team); + state = state.copyWith(actionError: null); + final playersCount = (team.players ?? []).length; + if (playersCount >= 2) { + state = state.copyWith(selectedTeam: team); + } else { + state = + state.copyWith(actionError: "The team must have at least 2 players."); + } } @override @@ -102,6 +111,7 @@ class SearchTeamState with _$SearchTeamState { const factory SearchTeamState({ required TextEditingController searchController, Object? error, + String? actionError, TeamModel? selectedTeam, @Default([]) List searchResults, @Default([]) List userTeams, diff --git a/khelo/lib/ui/flow/team/search_team/search_team_view_model.freezed.dart b/khelo/lib/ui/flow/team/search_team/search_team_view_model.freezed.dart index 364ee40c..5f48426b 100644 --- a/khelo/lib/ui/flow/team/search_team/search_team_view_model.freezed.dart +++ b/khelo/lib/ui/flow/team/search_team/search_team_view_model.freezed.dart @@ -19,6 +19,7 @@ mixin _$SearchTeamState { TextEditingController get searchController => throw _privateConstructorUsedError; Object? get error => throw _privateConstructorUsedError; + String? get actionError => throw _privateConstructorUsedError; TeamModel? get selectedTeam => throw _privateConstructorUsedError; List get searchResults => throw _privateConstructorUsedError; List get userTeams => throw _privateConstructorUsedError; @@ -39,6 +40,7 @@ abstract class $SearchTeamStateCopyWith<$Res> { $Res call( {TextEditingController searchController, Object? error, + String? actionError, TeamModel? selectedTeam, List searchResults, List userTeams, @@ -63,6 +65,7 @@ class _$SearchTeamStateCopyWithImpl<$Res, $Val extends SearchTeamState> $Res call({ Object? searchController = null, Object? error = freezed, + Object? actionError = freezed, Object? selectedTeam = freezed, Object? searchResults = null, Object? userTeams = null, @@ -75,6 +78,10 @@ class _$SearchTeamStateCopyWithImpl<$Res, $Val extends SearchTeamState> : searchController // ignore: cast_nullable_to_non_nullable as TextEditingController, error: freezed == error ? _value.error : error, + actionError: freezed == actionError + ? _value.actionError + : actionError // ignore: cast_nullable_to_non_nullable + as String?, selectedTeam: freezed == selectedTeam ? _value.selectedTeam : selectedTeam // ignore: cast_nullable_to_non_nullable @@ -122,6 +129,7 @@ abstract class _$$SearchTeamStateImplCopyWith<$Res> $Res call( {TextEditingController searchController, Object? error, + String? actionError, TeamModel? selectedTeam, List searchResults, List userTeams, @@ -145,6 +153,7 @@ class __$$SearchTeamStateImplCopyWithImpl<$Res> $Res call({ Object? searchController = null, Object? error = freezed, + Object? actionError = freezed, Object? selectedTeam = freezed, Object? searchResults = null, Object? userTeams = null, @@ -157,6 +166,10 @@ class __$$SearchTeamStateImplCopyWithImpl<$Res> : searchController // ignore: cast_nullable_to_non_nullable as TextEditingController, error: freezed == error ? _value.error : error, + actionError: freezed == actionError + ? _value.actionError + : actionError // ignore: cast_nullable_to_non_nullable + as String?, selectedTeam: freezed == selectedTeam ? _value.selectedTeam : selectedTeam // ignore: cast_nullable_to_non_nullable @@ -187,6 +200,7 @@ class _$SearchTeamStateImpl implements _SearchTeamState { const _$SearchTeamStateImpl( {required this.searchController, this.error, + this.actionError, this.selectedTeam, final List searchResults = const [], final List userTeams = const [], @@ -200,6 +214,8 @@ class _$SearchTeamStateImpl implements _SearchTeamState { @override final Object? error; @override + final String? actionError; + @override final TeamModel? selectedTeam; final List _searchResults; @override @@ -228,7 +244,7 @@ class _$SearchTeamStateImpl implements _SearchTeamState { @override String toString() { - return 'SearchTeamState(searchController: $searchController, error: $error, selectedTeam: $selectedTeam, searchResults: $searchResults, userTeams: $userTeams, loading: $loading, searchInProgress: $searchInProgress)'; + return 'SearchTeamState(searchController: $searchController, error: $error, actionError: $actionError, selectedTeam: $selectedTeam, searchResults: $searchResults, userTeams: $userTeams, loading: $loading, searchInProgress: $searchInProgress)'; } @override @@ -239,6 +255,8 @@ class _$SearchTeamStateImpl implements _SearchTeamState { (identical(other.searchController, searchController) || other.searchController == searchController) && const DeepCollectionEquality().equals(other.error, error) && + (identical(other.actionError, actionError) || + other.actionError == actionError) && (identical(other.selectedTeam, selectedTeam) || other.selectedTeam == selectedTeam) && const DeepCollectionEquality() @@ -255,6 +273,7 @@ class _$SearchTeamStateImpl implements _SearchTeamState { runtimeType, searchController, const DeepCollectionEquality().hash(error), + actionError, selectedTeam, const DeepCollectionEquality().hash(_searchResults), const DeepCollectionEquality().hash(_userTeams), @@ -273,6 +292,7 @@ abstract class _SearchTeamState implements SearchTeamState { const factory _SearchTeamState( {required final TextEditingController searchController, final Object? error, + final String? actionError, final TeamModel? selectedTeam, final List searchResults, final List userTeams, @@ -284,6 +304,8 @@ abstract class _SearchTeamState implements SearchTeamState { @override Object? get error; @override + String? get actionError; + @override TeamModel? get selectedTeam; @override List get searchResults;