diff --git a/lib/controllers/invites.dart b/lib/controllers/invites.dart index 3effbf9..9c8a6e4 100644 --- a/lib/controllers/invites.dart +++ b/lib/controllers/invites.dart @@ -1,5 +1,6 @@ import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/experimental/mutation.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; @@ -115,6 +116,10 @@ class InvitesController extends Notifier> { Future declineAll() => Future.wait(state.map((invite) => decline(invite.roomId))); + /// Accepting or declining an invite, keyed by its room ID + static final respondMutation = Mutation(); + static final declineAllMutation = Mutation(); + static final provider = NotifierProvider>( InvitesController.new, diff --git a/lib/controllers/space_hierarchy.dart b/lib/controllers/space_hierarchy.dart index 8a5aa9d..226b091 100644 --- a/lib/controllers/space_hierarchy.dart +++ b/lib/controllers/space_hierarchy.dart @@ -1,3 +1,4 @@ +import "package:flutter_riverpod/experimental/mutation.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/models/space_hierarchy.dart"; @@ -27,6 +28,12 @@ class SpaceHierarchyController(final String spaceId) ); } + /// Adding rooms to a space, keyed by its ID + static final addChildrenMutation = Mutation(); + + /// Removing a room from a space, keyed by (space ID, room ID) + static final removeChildMutation = Mutation(); + static final provider = AsyncNotifierProvider.family .autoDispose( SpaceHierarchyController.new, diff --git a/lib/helpers/extensions/log_out.dart b/lib/helpers/extensions/log_out.dart index 166a2ef..a170857 100644 --- a/lib/helpers/extensions/log_out.dart +++ b/lib/helpers/extensions/log_out.dart @@ -8,7 +8,9 @@ import "package:nexus/controllers/unified_push.dart"; import "package:nexus/main.dart"; extension LogOut on MutationTarget { - Future logOut(String accountId) async { + static final mutation = Mutation(); + + Future logOut(String accountId) => mutation.run(this, (_) async { final accounts = await container.read(AccountsController.provider.future); final account = accounts.firstWhereOrNull( (account) => account.id == accountId, @@ -49,5 +51,5 @@ extension LogOut on MutationTarget { await container .read(ActiveAccountController.provider.notifier) .switchTo(other.id); - } + }); } diff --git a/lib/widgets/account_switcher.dart b/lib/widgets/account_switcher.dart index 055b3cf..734a42b 100644 --- a/lib/widgets/account_switcher.dart +++ b/lib/widgets/account_switcher.dart @@ -43,9 +43,17 @@ class const AccountSwitcher({final bool onlyWithOthers = false, super.key}) .switchTo(accountId) .onError(showError); + final loggingOut = ref.watch(LogOut.mutation).isPending; + return AdaptiveMenuButton( - tooltip: "Switch account", - icon: active == null + tooltip: loggingOut ? "Logging out..." : "Switch account", + enabled: !loggingOut, + icon: loggingOut + ? const SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(), + ) + : active == null ? const Icon(Icons.account_circle) : avatarOf(active, 28), itemBuilder: (_) => [ diff --git a/lib/widgets/add_space_rooms_sheet.dart b/lib/widgets/add_space_rooms_sheet.dart index 2f9aeb7..2f4ff8f 100644 --- a/lib/widgets/add_space_rooms_sheet.dart +++ b/lib/widgets/add_space_rooms_sheet.dart @@ -131,9 +131,12 @@ class const AddSpaceRoomsSheet( : () { final container = ProviderScope.containerOf(context); final roomIds = chosen.value; - context.withProgressSnackbar( - "Adding ${roomIds.length == 1 ? nameOf(roomIds.single) : "${roomIds.length} rooms"} to $spaceName...", - () => save(container, roomIds), + SpaceHierarchyController.addChildrenMutation(spaceId).run( + container, + (_) => context.withProgressSnackbar( + "Adding ${roomIds.length == 1 ? nameOf(roomIds.single) : "${roomIds.length} rooms"} to $spaceName...", + () => save(container, roomIds), + ), ); Navigator.of(context).pop(); }, diff --git a/lib/widgets/invites_list.dart b/lib/widgets/invites_list.dart index f1be8f5..7713560 100644 --- a/lib/widgets/invites_list.dart +++ b/lib/widgets/invites_list.dart @@ -1,4 +1,3 @@ -import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:material_ui/material_ui.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; @@ -12,9 +11,7 @@ import "package:nexus/models/room_summary.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/room_summary_sheet.dart"; -class const InvitesList({super.key}) - extends HookConsumerWidget - implements NavPage { +class const InvitesList({super.key}) extends ConsumerWidget implements NavPage { @override String get title => "Invites"; @@ -32,7 +29,9 @@ class const InvitesList({super.key}) @override Widget build(BuildContext context, WidgetRef ref) { final invites = ref.watch(InvitesController.provider); - final decliningAll = useState(false); + final decliningAll = ref + .watch(InvitesController.declineAllMutation) + .isPending; Future declineAll() async { final confirmed = await context.showAdaptiveSheet( @@ -57,12 +56,12 @@ class const InvitesList({super.key}) ); if (confirmed != true) return; - decliningAll.value = true; - await ref - .read(InvitesController.provider.notifier) - .declineAll() + await InvitesController.declineAllMutation + .run( + ref, + (_) => ref.read(InvitesController.provider.notifier).declineAll(), + ) .onError(showError); - if (context.mounted) decliningAll.value = false; } return Scaffold( @@ -70,8 +69,8 @@ class const InvitesList({super.key}) floatingActionButton: invites.isEmpty ? null : FloatingActionButton.extended( - onPressed: decliningAll.value ? null : declineAll, - icon: decliningAll.value + onPressed: decliningAll ? null : declineAll, + icon: decliningAll ? const SizedBox.square( dimension: 24, child: CircularProgressIndicator(), @@ -96,9 +95,12 @@ class const InvitesList({super.key}) } class const InviteCard(final RoomSummary invite, {super.key}) - extends StatelessWidget { + extends ConsumerWidget { @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final responding = ref + .watch(InvitesController.respondMutation(invite.roomId)) + .isPending; final name = invite.name ?? invite.canonicalAlias ?? invite.roomId; final inviterName = invite.inviterProfile?.displayName ?? invite.invitedBy?.localpart; @@ -106,7 +108,15 @@ class const InviteCard(final RoomSummary invite, {super.key}) return Card( clipBehavior: .antiAlias, child: ListTile( - onTap: () => context.showAdaptiveSheet((_) => RoomSummarySheet(invite)), + onTap: responding + ? null + : () => context.showAdaptiveSheet((_) => RoomSummarySheet(invite)), + trailing: responding + ? const SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(), + ) + : null, leading: AvatarOrHash( invite.avatarUrl, name, diff --git a/lib/widgets/room_summary_sheet.dart b/lib/widgets/room_summary_sheet.dart index f1700da..1118cb9 100644 --- a/lib/widgets/room_summary_sheet.dart +++ b/lib/widgets/room_summary_sheet.dart @@ -65,9 +65,13 @@ class const RoomSummarySheet( final container = ProviderScope.containerOf(context); Navigator.of(context).pop(); - container - .read(InvitesController.provider.notifier) - .decline(summary.roomId) + InvitesController.respondMutation(summary.roomId) + .run( + container, + (_) => container + .read(InvitesController.provider.notifier) + .decline(summary.roomId), + ) .onError(showError); }, style: TextButton.styleFrom(foregroundColor: colorScheme.error), @@ -77,18 +81,19 @@ class const RoomSummarySheet( onPressed: () { final container = ProviderScope.containerOf(context); - context - .joinRoom( - .new(roomIdOrAlias: summary.roomId, fromInvite: true), - name: name, - ) - .then((roomId) { + InvitesController.respondMutation(summary.roomId) + .run(container, (_) async { + final roomId = await context.joinRoom( + .new(roomIdOrAlias: summary.roomId, fromInvite: true), + name: name, + ); if (roomId != null) { container .read(InvitesController.provider.notifier) .remove(summary.roomId); } - }); + }) + .onError(showError); Navigator.of(context).pop(); }, child: Text("Accept"), diff --git a/lib/widgets/space_hierarchy_sheet.dart b/lib/widgets/space_hierarchy_sheet.dart index e41d780..f5147e0 100644 --- a/lib/widgets/space_hierarchy_sheet.dart +++ b/lib/widgets/space_hierarchy_sheet.dart @@ -74,53 +74,74 @@ class const SpaceHierarchySheet(final String spaceId, {super.key}) String roomId, { required String name, required String spaceName, - }) => IconButton( - tooltip: "Remove from space", - icon: const Icon(Icons.remove_circle_outline), - onPressed: () => context.showAdaptiveSheet( - (context) => SheetLayout( - title: const Text("Remove Room"), - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: const Text("Cancel"), + }) => Consumer( + builder: (context, ref, _) { + final mutation = SpaceHierarchyController.removeChildMutation(( + spaceId, + roomId, + )); + if (ref.watch(mutation).isPending) { + return const Padding( + padding: .all(12), + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(), ), - TextButton( - onPressed: () { - final container = ProviderScope.containerOf(context); - context.withProgressSnackbar( - "Removing $name from $spaceName...", - () async { - await container - .read(ClientController.provider.notifier) - .setState( - .new( - roomId: spaceId, - type: EventType.spaceChild.type, - stateKey: roomId, - content: const Content(), - ), - ); - container.invalidate( - SpaceHierarchyController.provider(spaceId), + ); + } + + return IconButton( + tooltip: "Remove from space", + icon: const Icon(Icons.remove_circle_outline), + onPressed: () => context.showAdaptiveSheet( + (context) => SheetLayout( + title: const Text("Remove Room"), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text("Cancel"), + ), + TextButton( + onPressed: () { + final container = ProviderScope.containerOf(context); + mutation.run( + container, + (_) => context.withProgressSnackbar( + "Removing $name from $spaceName...", + () async { + await container + .read(ClientController.provider.notifier) + .setState( + .new( + roomId: spaceId, + type: EventType.spaceChild.type, + stateKey: roomId, + content: const Content(), + ), + ); + container.invalidate( + SpaceHierarchyController.provider(spaceId), + ); + }, + ), ); + Navigator.of(context).pop(); }, - ); - Navigator.of(context).pop(); - }, - style: TextButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.error, - ), - child: const Text("Remove"), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: const Text("Remove"), + ), + ], + children: [ + Text( + "Are you sure you want to remove \"$name\" from \"$spaceName\"? Its members will stay in it.", + ), + ], ), - ], - children: [ - Text( - "Are you sure you want to remove \"$name\" from \"$spaceName\"? Its members will stay in it.", - ), - ], - ), - ), + ), + ); + }, ); Widget level( @@ -130,6 +151,9 @@ class const SpaceHierarchySheet(final String spaceId, {super.key}) ) => HookConsumer( builder: (context, ref, _) { final loadingMore = useState(false); + final adding = ref + .watch(SpaceHierarchyController.addChildrenMutation(spaceId)) + .isPending; final canManage = ref.watch( PowerLevelController.provider( .state(eventType: .spaceChild, roomId: spaceId), @@ -317,16 +341,24 @@ class const SpaceHierarchySheet(final String spaceId, {super.key}) ), if (canManage) TextButton.icon( - onPressed: () => context.showAdaptiveSheet( - (_) => AddSpaceRoomsSheet( - spaceId, - spaceName: spaceName, - existing: (byId[spaceId]?.children ?? const .empty()) - .map((child) => child.roomId) - .toISet(), - ), - ), - icon: const Icon(Icons.add), + onPressed: adding + ? null + : () => context.showAdaptiveSheet( + (_) => AddSpaceRoomsSheet( + spaceId, + spaceName: spaceName, + existing: + (byId[spaceId]?.children ?? const .empty()) + .map((child) => child.roomId) + .toISet(), + ), + ), + icon: adding + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(), + ) + : const Icon(Icons.add), label: const Text("Add rooms"), ), ],