From eee991bc582fa492e90948141d0aa9025786393e Mon Sep 17 00:00:00 2001 From: Giorgio Azzinnaro Date: Thu, 14 Nov 2024 22:16:44 +0100 Subject: [PATCH] feat: members detail and remove members --- ios/Podfile.lock | 2 +- lib/epics/members.dart | 12 + lib/go_router_builder.dart | 14 + lib/go_router_builder.g.dart | 25 ++ lib/l10n/app_de.arb | 7 + lib/l10n/app_en.arb | 15 + lib/l10n/app_es.arb | 7 + lib/l10n/app_fr.arb | 7 + lib/l10n/app_it.arb | 7 + lib/presentation/containers/containers.dart | 1 + .../containers/group_member_details.dart | 79 +++++ .../group_member_details.freezed.dart | 324 ++++++++++++++++++ .../screens/group_member_details.dart | 117 +++++++ .../screens/group_schedule_details.dart | 7 - lib/presentation/screens/screens.dart | 1 + lib/presentation/screens/select_contacts.dart | 2 +- lib/presentation/widgets/group_members.dart | 13 +- lib/presentation/widgets/schedule_form.dart | 2 +- lib/presentation/widgets/schedules_list.dart | 2 +- lib/util/config.freezed.dart | 15 +- supabase/migrations/20230707220325_schema.sql | 14 +- 21 files changed, 648 insertions(+), 25 deletions(-) create mode 100644 lib/presentation/containers/group_member_details.dart create mode 100644 lib/presentation/containers/group_member_details.freezed.dart create mode 100644 lib/presentation/screens/group_member_details.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock index cb43eeb4..ae11f8ee 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -119,7 +119,7 @@ SPEC CHECKSUMS: integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 phone_number: 567a167933138df1080eb0d8ecb0ea44f1dee1ed - PhoneNumberKit: 5b1be7ee4955dfeeb855f51eecdd829ab24b3484 + PhoneNumberKit: 54a4e3f3c776b3277b084acf00e2da18ff548d58 shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 sign_in_with_apple: f3bf75217ea4c2c8b91823f225d70230119b8440 url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe diff --git a/lib/epics/members.dart b/lib/epics/members.dart index 34bbc2d8..22ec34a7 100644 --- a/lib/epics/members.dart +++ b/lib/epics/members.dart @@ -11,6 +11,7 @@ createMembersEpic(MembersRepository members) => combineEpics([ _createUpdateMemberEpic(members), _onNewMembersCreated, _createRetrieveOwnMemberByGroupIdEpic(members), + _createDeleteOneMember(members), ]); Epic _createAddMembersToGroupEpic(MembersRepository members) { @@ -67,3 +68,14 @@ Epic _createRetrieveOwnMemberByGroupIdEpic( FailRetrieveOne(id: groupId.toString(), error: error)); }); } + +Epic _createDeleteOneMember(MembersRepository members) { + return (Stream actions, EpicStore store) => + actions.whereType>().asyncMap((action) { + return members + .removeMember(int.parse(action.id)) + .then((_) => SuccessDeleteOne(action.id)) + .catchError( + (error) => FailDeleteOne(id: action.id, error: error)); + }); +} diff --git a/lib/go_router_builder.dart b/lib/go_router_builder.dart index 48131546..b8668bfb 100644 --- a/lib/go_router_builder.dart +++ b/lib/go_router_builder.dart @@ -20,6 +20,7 @@ part 'go_router_builder.g.dart'; // TODO Should be at root level? TypedGoRoute(path: 'schedule-create'), TypedGoRoute(path: 'schedules/:scheduleId'), + TypedGoRoute(path: 'members/:memberId'), ], ), TypedGoRoute( @@ -121,6 +122,19 @@ class GroupScheduleDetailsRoute extends GoRouteData with AuthenticationGuard { GroupScheduleDetailsContainer(groupId: groupId, scheduleId: scheduleId); } +@immutable +class GroupMemberDetailsRoute extends GoRouteData with AuthenticationGuard { + final String groupId; + final String memberId; + + const GroupMemberDetailsRoute( + {required this.groupId, required this.memberId}); + + @override + Widget build(BuildContext context, GoRouterState state) => + GroupMemberDetailsContainer(groupId: groupId, memberId: memberId); +} + @immutable class SelectContactsRoute extends GoRouteData with AuthenticationGuard { @override diff --git a/lib/go_router_builder.g.dart b/lib/go_router_builder.g.dart index 7491c9bf..92cfe9f9 100644 --- a/lib/go_router_builder.g.dart +++ b/lib/go_router_builder.g.dart @@ -50,6 +50,10 @@ RouteBase get $homeScreenRoute => GoRouteData.$route( path: 'schedules/:scheduleId', factory: $GroupScheduleDetailsRouteExtension._fromState, ), + GoRouteData.$route( + path: 'members/:memberId', + factory: $GroupMemberDetailsRouteExtension._fromState, + ), ], ), GoRouteData.$route( @@ -248,6 +252,27 @@ extension $GroupScheduleDetailsRouteExtension on GroupScheduleDetailsRoute { void replace(BuildContext context) => context.replace(location); } +extension $GroupMemberDetailsRouteExtension on GroupMemberDetailsRoute { + static GroupMemberDetailsRoute _fromState(GoRouterState state) => + GroupMemberDetailsRoute( + groupId: state.pathParameters['groupId']!, + memberId: state.pathParameters['memberId']!, + ); + + String get location => GoRouteData.$location( + '/groups/${Uri.encodeComponent(groupId)}/members/${Uri.encodeComponent(memberId)}', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +} + extension $SettingsRouteExtension on SettingsRoute { static SettingsRoute _fromState(GoRouterState state) => SettingsRoute(); diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index ef17e459..0204a41a 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2,6 +2,7 @@ "@@locale": "de", "appName": "AppFor.it", "addContact": "Kontakt hinzufügen", + "addInvite": "Einladung hinzufügen", "camera": "Kamera", "cancel": "Abbrechen", "changeTheme": "Thema", @@ -25,8 +26,14 @@ "eventName": "Name des Events", "events": "Events", "gallery": "Galerie", + "groupDismissAdmin": "Admin entfernen", + "groupMakeAdmin": "Zum Gruppenadmin machen", "grantPermission": "Berechtigung erteilen", + "groupRemoveMember": "Aus Gruppe entfernen", + "groupRemoveConfirmation": "Bist du sicher, dass du dieses Mitglied aus der Gruppe entfernen möchtest?", + "groupRoles": "{role, select, admin{Admin} member{Mitglied} other{Unbekannt}}", "invite": "Mitglieder einladen", + "invites": "Einladungen", "inviteFromContacts": "Aus Kontakten einladen", "inviteManual": "Manuell einladen", "inviteMembersCTA": "Lad' ein paar Freunde ein!", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 27665cf8..dc73f16c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2,6 +2,7 @@ "@@locale": "en", "appName": "AppFor.it", "addContact": "Add contact", + "addInvite": "Add invite", "camera": "Camera", "cancel": "Cancel", "changeTheme": "Theme", @@ -25,8 +26,22 @@ "eventName": "Event name", "events": "Events", "gallery": "Gallery", + "groupDismissAdmin": "Dismiss as admin", + "groupMakeAdmin": "Make group admin", "grantPermission": "Grant permission", + "groupRemoveMember": "Remove from group", + "groupRemoveConfirmation": "Are you sure you want to remove this member from the group?", + "groupRoles": "{role, select, admin{Admin} member{Member} other{Unknown}}", + "@groupRoles": { + "description": "Member roles in a group", + "placeholders": { + "role": { + "type": "String" + } + } + }, "invite": "Invite members", + "invites": "Invites", "inviteFromContacts": "Invite from contacts", "inviteManual": "Invite manually", "inviteMembersCTA": "Invite some friends!", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index cd082c83..daff1ec7 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2,6 +2,7 @@ "@@locale": "es", "appName": "AppFor.it", "addContact": "Añade un contacto", + "addInvite": "Añade una invitación", "camera": "Cámara", "cancel": "Cancela", "changeTheme": "Tema", @@ -25,8 +26,14 @@ "eventName": "Nombre del evento", "events": "Eventos", "gallery": "Galería", + "groupDismissAdmin": "Descarta como admin", + "groupMakeAdmin": "Haz admin del grupo", "grantPermission": "Da permiso", + "groupRemoveMember": "Quita del grupo", + "groupRemoveConfirmation": "¿Seguro que quieres quitar a este miembro del grupo?", + "groupRoles": "{role, select, admin{Admin} member{Miembro} other{Desconocido}}", "invite": "Invita a la gente", + "invites": "Invitaciones", "inviteFromContacts": "Invita desde tus contactos", "inviteManual": "Invita manualmente", "inviteMembersCTA": "¡Invita a algunos amigos!", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 6c52734f..c169c41a 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2,6 +2,7 @@ "@@locale": "fr", "appName": "AppFor.it", "addContact": "Ajoute un contact", + "addInvite": "Ajoute une invitation", "camera": "Caméra", "cancel": "Annule", "changeTheme": "Thème", @@ -25,8 +26,14 @@ "eventName": "Nom de l'événement", "events": "Événements", "gallery": "Galerie", + "groupDismissAdmin": "Révoque l'admin", + "groupMakeAdmin": "Rend admin du groupe", "grantPermission": "Donne la permission", + "groupRemoveMember": "Retire du groupe", + "groupRemoveConfirmation": "Es-tu sûr de vouloir retirer ce membre du groupe?", + "groupRoles": "{role, select, admin{Admin} member{Membre} other{Inconnu}}", "invite": "Invite des gens", + "invites": "Invitations", "inviteFromContacts": "Invite depuis tes contacts", "inviteManual": "Invite manuellement", "inviteMembersCTA": "Invite quelques amis!", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 2f9aabe0..30820e01 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -2,6 +2,7 @@ "@@locale": "it", "appName": "AppFor.it", "addContact": "Aggiungi contatto", + "addInvite": "Aggiungi invito", "camera": "Fotocamera", "cancel": "Annulla", "changeTheme": "Tema", @@ -25,8 +26,14 @@ "eventName": "Nome dell'evento", "events": "Eventi", "gallery": "Galleria", + "groupDismissAdmin": "Rimuovi come amministratore", + "groupMakeAdmin": "Rendi amministratore del gruppo", "grantPermission": "Dai il permesso", + "groupRemoveMember": "Rimuovi dal gruppo", + "groupRemoveConfirmation": "Sei sicuro di voler rimuovere questo membro dal gruppo?", + "groupRoles": "{role, select, admin{Admin} member{Membro} other{Sconosciuto}}", "invite": "Invita la gente", + "invites": "Inviti", "inviteFromContacts": "Invita dai tuoi contatti", "inviteManual": "Invita manualmente", "inviteMembersCTA": "Invita qualche amico!", diff --git a/lib/presentation/containers/containers.dart b/lib/presentation/containers/containers.dart index 02d43bdb..9ac76139 100644 --- a/lib/presentation/containers/containers.dart +++ b/lib/presentation/containers/containers.dart @@ -3,6 +3,7 @@ export 'group_details.dart'; export 'group_events.dart'; export 'group_form.dart'; export 'group_manage.dart'; +export 'group_member_details.dart'; export 'group_members.dart'; export 'group_schedule_details.dart'; export 'home.dart'; diff --git a/lib/presentation/containers/group_member_details.dart b/lib/presentation/containers/group_member_details.dart new file mode 100644 index 00000000..dcb638db --- /dev/null +++ b/lib/presentation/containers/group_member_details.dart @@ -0,0 +1,79 @@ +import 'package:flutter/foundation.dart'; // ignore: unused_import +import 'package:flutter/material.dart'; +import 'package:flutter_redux/flutter_redux.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:parousia/actions/actions.dart'; +import 'package:parousia/models/models.dart'; +import 'package:parousia/presentation/presentation.dart'; +import 'package:parousia/state/state.dart'; +import 'package:redux/redux.dart'; +import 'package:redux_entity/redux_entity.dart'; + +part 'group_member_details.freezed.dart'; + +class GroupMemberDetailsContainer extends StatelessWidget { + final String groupId; + final String memberId; + + const GroupMemberDetailsContainer({ + super.key, + required this.groupId, + required this.memberId, + }); + + @override + Widget build(BuildContext context) { + return StoreConnector( + distinct: true, + converter: (store) => _ViewModel.fromStore(store, groupId, memberId), + onInit: (store) => store.dispatch(GroupDetailsOpenAction(groupId)), + builder: (context, vm) => GroupMemberDetailsScreen( + loading: vm.loading, + group: vm.group, + member: vm.member, + profile: vm.profile, + invites: vm.invites, + onRemove: vm.onRemove, + ), + ); + } +} + +@freezed +sealed class _ViewModel with _$ViewModel { + const factory _ViewModel({ + required bool loading, + Group? group, + Member? member, + Profile? profile, + List? invites, + OnRemoveFromGroupCallback? onRemove, + }) = __ViewModel; + + static _ViewModel fromStore( + Store store, String groupId, String memberId) { + final group = store.state.groups.entities[groupId]; + + if (group == null) { + return const _ViewModel(loading: true); + } + + // TODO: should use selectors + final member = store.state.members.entities[memberId]; + final profile = store.state.profiles.entities[member?.profileId]; + final invites = store.state.invites.entities.values + .where((invite) => invite.memberId.toString() == memberId) + .toList(); + + return _ViewModel( + loading: store.state.groups.loadingAll || + (store.state.groups.loadingIds[groupId] ?? false), + group: group, + member: member, + profile: profile, + invites: invites, + onRemove: (member) => + store.dispatch(RequestDeleteOne(member.id.toString())), + ); + } +} diff --git a/lib/presentation/containers/group_member_details.freezed.dart b/lib/presentation/containers/group_member_details.freezed.dart new file mode 100644 index 00000000..a0c96758 --- /dev/null +++ b/lib/presentation/containers/group_member_details.freezed.dart @@ -0,0 +1,324 @@ +// 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 'group_member_details.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 _$ViewModel { + bool get loading => throw _privateConstructorUsedError; + Group? get group => throw _privateConstructorUsedError; + Member? get member => throw _privateConstructorUsedError; + Profile? get profile => throw _privateConstructorUsedError; + List? get invites => throw _privateConstructorUsedError; + OnRemoveFromGroupCallback? get onRemove => throw _privateConstructorUsedError; + + /// Create a copy of _ViewModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$ViewModelCopyWith<_ViewModel> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$ViewModelCopyWith<$Res> { + factory _$ViewModelCopyWith( + _ViewModel value, $Res Function(_ViewModel) then) = + __$ViewModelCopyWithImpl<$Res, _ViewModel>; + @useResult + $Res call( + {bool loading, + Group? group, + Member? member, + Profile? profile, + List? invites, + OnRemoveFromGroupCallback? onRemove}); + + $GroupCopyWith<$Res>? get group; + $MemberCopyWith<$Res>? get member; + $ProfileCopyWith<$Res>? get profile; +} + +/// @nodoc +class __$ViewModelCopyWithImpl<$Res, $Val extends _ViewModel> + implements _$ViewModelCopyWith<$Res> { + __$ViewModelCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of _ViewModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? loading = null, + Object? group = freezed, + Object? member = freezed, + Object? profile = freezed, + Object? invites = freezed, + Object? onRemove = freezed, + }) { + return _then(_value.copyWith( + loading: null == loading + ? _value.loading + : loading // ignore: cast_nullable_to_non_nullable + as bool, + group: freezed == group + ? _value.group + : group // ignore: cast_nullable_to_non_nullable + as Group?, + member: freezed == member + ? _value.member + : member // ignore: cast_nullable_to_non_nullable + as Member?, + profile: freezed == profile + ? _value.profile + : profile // ignore: cast_nullable_to_non_nullable + as Profile?, + invites: freezed == invites + ? _value.invites + : invites // ignore: cast_nullable_to_non_nullable + as List?, + onRemove: freezed == onRemove + ? _value.onRemove + : onRemove // ignore: cast_nullable_to_non_nullable + as OnRemoveFromGroupCallback?, + ) as $Val); + } + + /// Create a copy of _ViewModel + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $GroupCopyWith<$Res>? get group { + if (_value.group == null) { + return null; + } + + return $GroupCopyWith<$Res>(_value.group!, (value) { + return _then(_value.copyWith(group: value) as $Val); + }); + } + + /// Create a copy of _ViewModel + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $MemberCopyWith<$Res>? get member { + if (_value.member == null) { + return null; + } + + return $MemberCopyWith<$Res>(_value.member!, (value) { + return _then(_value.copyWith(member: value) as $Val); + }); + } + + /// Create a copy of _ViewModel + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $ProfileCopyWith<$Res>? get profile { + if (_value.profile == null) { + return null; + } + + return $ProfileCopyWith<$Res>(_value.profile!, (value) { + return _then(_value.copyWith(profile: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$_ViewModelImplCopyWith<$Res> + implements _$ViewModelCopyWith<$Res> { + factory _$$_ViewModelImplCopyWith( + _$_ViewModelImpl value, $Res Function(_$_ViewModelImpl) then) = + __$$_ViewModelImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {bool loading, + Group? group, + Member? member, + Profile? profile, + List? invites, + OnRemoveFromGroupCallback? onRemove}); + + @override + $GroupCopyWith<$Res>? get group; + @override + $MemberCopyWith<$Res>? get member; + @override + $ProfileCopyWith<$Res>? get profile; +} + +/// @nodoc +class __$$_ViewModelImplCopyWithImpl<$Res> + extends __$ViewModelCopyWithImpl<$Res, _$_ViewModelImpl> + implements _$$_ViewModelImplCopyWith<$Res> { + __$$_ViewModelImplCopyWithImpl( + _$_ViewModelImpl _value, $Res Function(_$_ViewModelImpl) _then) + : super(_value, _then); + + /// Create a copy of _ViewModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? loading = null, + Object? group = freezed, + Object? member = freezed, + Object? profile = freezed, + Object? invites = freezed, + Object? onRemove = freezed, + }) { + return _then(_$_ViewModelImpl( + loading: null == loading + ? _value.loading + : loading // ignore: cast_nullable_to_non_nullable + as bool, + group: freezed == group + ? _value.group + : group // ignore: cast_nullable_to_non_nullable + as Group?, + member: freezed == member + ? _value.member + : member // ignore: cast_nullable_to_non_nullable + as Member?, + profile: freezed == profile + ? _value.profile + : profile // ignore: cast_nullable_to_non_nullable + as Profile?, + invites: freezed == invites + ? _value._invites + : invites // ignore: cast_nullable_to_non_nullable + as List?, + onRemove: freezed == onRemove + ? _value.onRemove + : onRemove // ignore: cast_nullable_to_non_nullable + as OnRemoveFromGroupCallback?, + )); + } +} + +/// @nodoc + +class _$_ViewModelImpl with DiagnosticableTreeMixin implements __ViewModel { + const _$_ViewModelImpl( + {required this.loading, + this.group, + this.member, + this.profile, + final List? invites, + this.onRemove}) + : _invites = invites; + + @override + final bool loading; + @override + final Group? group; + @override + final Member? member; + @override + final Profile? profile; + final List? _invites; + @override + List? get invites { + final value = _invites; + if (value == null) return null; + if (_invites is EqualUnmodifiableListView) return _invites; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); + } + + @override + final OnRemoveFromGroupCallback? onRemove; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return '_ViewModel(loading: $loading, group: $group, member: $member, profile: $profile, invites: $invites, onRemove: $onRemove)'; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('type', '_ViewModel')) + ..add(DiagnosticsProperty('loading', loading)) + ..add(DiagnosticsProperty('group', group)) + ..add(DiagnosticsProperty('member', member)) + ..add(DiagnosticsProperty('profile', profile)) + ..add(DiagnosticsProperty('invites', invites)) + ..add(DiagnosticsProperty('onRemove', onRemove)); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$_ViewModelImpl && + (identical(other.loading, loading) || other.loading == loading) && + (identical(other.group, group) || other.group == group) && + (identical(other.member, member) || other.member == member) && + (identical(other.profile, profile) || other.profile == profile) && + const DeepCollectionEquality().equals(other._invites, _invites) && + (identical(other.onRemove, onRemove) || + other.onRemove == onRemove)); + } + + @override + int get hashCode => Object.hash(runtimeType, loading, group, member, profile, + const DeepCollectionEquality().hash(_invites), onRemove); + + /// Create a copy of _ViewModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$_ViewModelImplCopyWith<_$_ViewModelImpl> get copyWith => + __$$_ViewModelImplCopyWithImpl<_$_ViewModelImpl>(this, _$identity); +} + +abstract class __ViewModel implements _ViewModel { + const factory __ViewModel( + {required final bool loading, + final Group? group, + final Member? member, + final Profile? profile, + final List? invites, + final OnRemoveFromGroupCallback? onRemove}) = _$_ViewModelImpl; + + @override + bool get loading; + @override + Group? get group; + @override + Member? get member; + @override + Profile? get profile; + @override + List? get invites; + @override + OnRemoveFromGroupCallback? get onRemove; + + /// Create a copy of _ViewModel + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$_ViewModelImplCopyWith<_$_ViewModelImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/presentation/screens/group_member_details.dart b/lib/presentation/screens/group_member_details.dart new file mode 100644 index 00000000..ccb394a1 --- /dev/null +++ b/lib/presentation/screens/group_member_details.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:parousia/models/models.dart'; + +typedef OnRemoveFromGroupCallback = void Function(Member); + +class GroupMemberDetailsScreen extends StatelessWidget { + final bool loading; + final Group? group; + final Member? member; + final Profile? profile; + final List? invites; + final OnRemoveFromGroupCallback? onRemove; + + const GroupMemberDetailsScreen({ + super.key, + required this.loading, + this.group, + this.member, + this.profile, + this.invites, + this.onRemove, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: Text(member?.displayNameOverride ?? + profile?.displayName ?? + l10n.loading), + bottom: PreferredSize( + preferredSize: Size.zero, + child: Text(group?.displayName ?? l10n.loading), + ), + ), + body: Column( + children: [ + // TODO Should the default replies be set here? + if (profile != null) + // Only members that already joined can become admins + if (member?.role == GroupRoles.member) + ListTile( + leading: const Icon(Icons.add_moderator_outlined), + title: Text(l10n.groupMakeAdmin), + textColor: theme.colorScheme.primary, + ) + else + ListTile( + leading: const Icon(Icons.remove_moderator_outlined), + title: Text(l10n.groupDismissAdmin), + iconColor: theme.colorScheme.error, + textColor: theme.colorScheme.error, + ) + else ...[ + // Members that haven't joined yet can be invited + ListTile( + title: Text( + l10n.invites, + style: theme.textTheme.headlineMedium, + ), + ), + for (final invite in invites!) + ListTile( + leading: const Icon(Icons.email), + title: Text(invite.value), + ), + ListTile( + leading: const Icon(Icons.add_circle_outline), + title: Text(l10n.addInvite), + ) + ], + Divider(), + ListTile( + leading: const Icon(Icons.group_remove_outlined), + title: Text(l10n.groupRemoveMember), + splashColor: theme.colorScheme.error.withOpacity(0.3), + iconColor: theme.colorScheme.error, + textColor: theme.colorScheme.error, + onTap: () async { + final confirm = await showAdaptiveDialog( + context: context, + builder: (context) { + return AlertDialog.adaptive( + title: Text(l10n.groupRemoveMember), + content: Text(l10n.groupRemoveConfirmation), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom( + foregroundColor: theme.colorScheme.error, + ), + child: Text(l10n.yes), + ), + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(l10n.no), + ), + ], + ); + }, + ); + if (confirm == true) { + onRemove?.call(member!); + + if (context.mounted) Navigator.pop(context); + } + }, + ), + ], + ), + ); + } +} diff --git a/lib/presentation/screens/group_schedule_details.dart b/lib/presentation/screens/group_schedule_details.dart index 93b0c46e..470c7804 100644 --- a/lib/presentation/screens/group_schedule_details.dart +++ b/lib/presentation/screens/group_schedule_details.dart @@ -32,13 +32,6 @@ class GroupScheduleDetailsScreen extends StatelessWidget { return Scaffold( appBar: AppBar( title: Text(group?.displayName ?? l10n.loading), - // actions: [ - // IconButton( - // onPressed: () => - // GroupManageRoute(groupId: group!.id.toString()).push(context), - // icon: const FaIcon(FontAwesomeIcons.penToSquare), - // ) - // ], ), body: Column( children: [ diff --git a/lib/presentation/screens/screens.dart b/lib/presentation/screens/screens.dart index 29a861e4..31abd202 100644 --- a/lib/presentation/screens/screens.dart +++ b/lib/presentation/screens/screens.dart @@ -3,6 +3,7 @@ export 'create_contacts.dart'; export 'group_create.dart'; export 'group_details.dart'; export 'group_manage.dart'; +export 'group_member_details.dart'; export 'group_schedule_details.dart'; export 'home.dart'; export 'locale.dart'; diff --git a/lib/presentation/screens/select_contacts.dart b/lib/presentation/screens/select_contacts.dart index 045b47fe..651ed16e 100644 --- a/lib/presentation/screens/select_contacts.dart +++ b/lib/presentation/screens/select_contacts.dart @@ -95,7 +95,7 @@ class _SelectContactsScreenState extends State { } else if (snapshot.hasError) { return Center(child: Text(snapshot.error.toString())); } else { - return const Center(child: CircularProgressIndicator()); + return const Center(child: CircularProgressIndicator.adaptive()); } }, ), diff --git a/lib/presentation/widgets/group_members.dart b/lib/presentation/widgets/group_members.dart index 371d5f2f..12b43679 100644 --- a/lib/presentation/widgets/group_members.dart +++ b/lib/presentation/widgets/group_members.dart @@ -30,8 +30,8 @@ class GroupMembers extends StatelessWidget { (c) => ContactInvite( displayNameOverride: c.displayName, invites: [ - ...(c.emails).map((e) => (InviteMethods.email, e.address)), - ...(c.phones).map((p) => (InviteMethods.phone, p.number)), + for (final e in c.emails) (InviteMethods.email, e.address), + for (final p in c.phones) (InviteMethods.phone, p.number), ], ), ) @@ -120,8 +120,13 @@ class GroupMembers extends StatelessWidget { title: Text(member.displayNameOverride ?? memberProfile?.displayName ?? 'Unknown'), - subtitle: Text(member.role.name), - onTap: () {}); + subtitle: Text(l10n.groupRoles(member.role.name)), + onTap: () { + GroupMemberDetailsRoute( + groupId: member.groupId.toString(), + memberId: member.id.toString()) + .push(context); + }); }, ) : Image.asset('assets/images/seeyoulateralligator.webp'); diff --git a/lib/presentation/widgets/schedule_form.dart b/lib/presentation/widgets/schedule_form.dart index 5e996095..5038eca3 100644 --- a/lib/presentation/widgets/schedule_form.dart +++ b/lib/presentation/widgets/schedule_form.dart @@ -86,7 +86,7 @@ class _ScheduleFormState extends State { validator: FormBuilderValidators.required(), rruleL10n: snapshot.data as RruleL10n, ) - : const CircularProgressIndicator(), + : const CircularProgressIndicator.adaptive(), ), ], ), diff --git a/lib/presentation/widgets/schedules_list.dart b/lib/presentation/widgets/schedules_list.dart index 4b2dd765..661c0048 100644 --- a/lib/presentation/widgets/schedules_list.dart +++ b/lib/presentation/widgets/schedules_list.dart @@ -18,7 +18,7 @@ class SchedulesList extends StatelessWidget { @override Widget build(BuildContext context) { if (schedules == null) { - return const Center(child: CircularProgressIndicator()); + return const Center(child: CircularProgressIndicator.adaptive()); } return ListView.builder( diff --git a/lib/util/config.freezed.dart b/lib/util/config.freezed.dart index 968c7f43..00771940 100644 --- a/lib/util/config.freezed.dart +++ b/lib/util/config.freezed.dart @@ -129,7 +129,7 @@ class __$$ConfigImplCopyWithImpl<$Res> /// @nodoc @JsonSerializable() -class _$ConfigImpl implements _Config { +class _$ConfigImpl with DiagnosticableTreeMixin implements _Config { const _$ConfigImpl( {required this.supabaseConfigPath, required this.socialAuthWebClientId, @@ -146,10 +146,21 @@ class _$ConfigImpl implements _Config { final String socialAuthIosClientId; @override - String toString() { + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { return 'Config(supabaseConfigPath: $supabaseConfigPath, socialAuthWebClientId: $socialAuthWebClientId, socialAuthIosClientId: $socialAuthIosClientId)'; } + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('type', 'Config')) + ..add(DiagnosticsProperty('supabaseConfigPath', supabaseConfigPath)) + ..add(DiagnosticsProperty('socialAuthWebClientId', socialAuthWebClientId)) + ..add( + DiagnosticsProperty('socialAuthIosClientId', socialAuthIosClientId)); + } + @override bool operator ==(Object other) { return identical(this, other) || diff --git a/supabase/migrations/20230707220325_schema.sql b/supabase/migrations/20230707220325_schema.sql index 28926da3..40a23e0b 100644 --- a/supabase/migrations/20230707220325_schema.sql +++ b/supabase/migrations/20230707220325_schema.sql @@ -228,14 +228,12 @@ create policy "profiles_select" for select to authenticated using ( - (select auth.uid()) = id - or - exists ( - select 1 - from members - where members.profile_id = auth.uid() - and members.group_id = (select group_id from members where members.profile_id = profiles.id) - ) + (select auth.uid()) = id + or + exists (select 1 + from members + where members.profile_id = auth.uid() + and members.group_id = (select group_id from members where members.profile_id = profiles.id)) ); comment on policy "profiles_select" on profiles is 'Users can see their own profile';