refactor to use mutations where helpful

This commit is contained in:
Henry Hiles 2026-11-02 17:06:01 -05:00
commit e9b009d1b9
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
8 changed files with 157 additions and 85 deletions

View file

@ -1,5 +1,6 @@
import "package:collection/collection.dart"; import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.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:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/client_state.dart";
@ -115,6 +116,10 @@ class InvitesController extends Notifier<IList<RoomSummary>> {
Future<void> declineAll() => Future<void> declineAll() =>
Future.wait(state.map((invite) => decline(invite.roomId))); Future.wait(state.map((invite) => decline(invite.roomId)));
/// Accepting or declining an invite, keyed by its room ID
static final respondMutation = Mutation<void>();
static final declineAllMutation = Mutation<void>();
static final provider = static final provider =
NotifierProvider<InvitesController, IList<RoomSummary>>( NotifierProvider<InvitesController, IList<RoomSummary>>(
InvitesController.new, InvitesController.new,

View file

@ -1,3 +1,4 @@
import "package:flutter_riverpod/experimental/mutation.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/models/space_hierarchy.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<void>();
/// Removing a room from a space, keyed by (space ID, room ID)
static final removeChildMutation = Mutation<void>();
static final provider = AsyncNotifierProvider.family static final provider = AsyncNotifierProvider.family
.autoDispose<SpaceHierarchyController, SpaceHierarchy, String>( .autoDispose<SpaceHierarchyController, SpaceHierarchy, String>(
SpaceHierarchyController.new, SpaceHierarchyController.new,

View file

@ -8,7 +8,9 @@ import "package:nexus/controllers/unified_push.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
extension LogOut on MutationTarget { extension LogOut on MutationTarget {
Future<void> logOut(String accountId) async { static final mutation = Mutation<void>();
Future<void> logOut(String accountId) => mutation.run(this, (_) async {
final accounts = await container.read(AccountsController.provider.future); final accounts = await container.read(AccountsController.provider.future);
final account = accounts.firstWhereOrNull( final account = accounts.firstWhereOrNull(
(account) => account.id == accountId, (account) => account.id == accountId,
@ -49,5 +51,5 @@ extension LogOut on MutationTarget {
await container await container
.read(ActiveAccountController.provider.notifier) .read(ActiveAccountController.provider.notifier)
.switchTo(other.id); .switchTo(other.id);
} });
} }

View file

@ -43,9 +43,17 @@ class const AccountSwitcher({final bool onlyWithOthers = false, super.key})
.switchTo(accountId) .switchTo(accountId)
.onError(showError); .onError(showError);
final loggingOut = ref.watch(LogOut.mutation).isPending;
return AdaptiveMenuButton( return AdaptiveMenuButton(
tooltip: "Switch account", tooltip: loggingOut ? "Logging out..." : "Switch account",
icon: active == null enabled: !loggingOut,
icon: loggingOut
? const SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(),
)
: active == null
? const Icon(Icons.account_circle) ? const Icon(Icons.account_circle)
: avatarOf(active, 28), : avatarOf(active, 28),
itemBuilder: (_) => [ itemBuilder: (_) => [

View file

@ -131,9 +131,12 @@ class const AddSpaceRoomsSheet(
: () { : () {
final container = ProviderScope.containerOf(context); final container = ProviderScope.containerOf(context);
final roomIds = chosen.value; final roomIds = chosen.value;
context.withProgressSnackbar( SpaceHierarchyController.addChildrenMutation(spaceId).run(
"Adding ${roomIds.length == 1 ? nameOf(roomIds.single) : "${roomIds.length} rooms"} to $spaceName...", container,
() => save(container, roomIds), (_) => context.withProgressSnackbar(
"Adding ${roomIds.length == 1 ? nameOf(roomIds.single) : "${roomIds.length} rooms"} to $spaceName...",
() => save(container, roomIds),
),
); );
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },

View file

@ -1,4 +1,3 @@
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.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/avatar_or_hash.dart";
import "package:nexus/widgets/room_summary_sheet.dart"; import "package:nexus/widgets/room_summary_sheet.dart";
class const InvitesList({super.key}) class const InvitesList({super.key}) extends ConsumerWidget implements NavPage {
extends HookConsumerWidget
implements NavPage {
@override @override
String get title => "Invites"; String get title => "Invites";
@ -32,7 +29,9 @@ class const InvitesList({super.key})
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final invites = ref.watch(InvitesController.provider); final invites = ref.watch(InvitesController.provider);
final decliningAll = useState(false); final decliningAll = ref
.watch(InvitesController.declineAllMutation)
.isPending;
Future<void> declineAll() async { Future<void> declineAll() async {
final confirmed = await context.showAdaptiveSheet<bool>( final confirmed = await context.showAdaptiveSheet<bool>(
@ -57,12 +56,12 @@ class const InvitesList({super.key})
); );
if (confirmed != true) return; if (confirmed != true) return;
decliningAll.value = true; await InvitesController.declineAllMutation
await ref .run(
.read(InvitesController.provider.notifier) ref,
.declineAll() (_) => ref.read(InvitesController.provider.notifier).declineAll(),
)
.onError(showError); .onError(showError);
if (context.mounted) decliningAll.value = false;
} }
return Scaffold( return Scaffold(
@ -70,8 +69,8 @@ class const InvitesList({super.key})
floatingActionButton: invites.isEmpty floatingActionButton: invites.isEmpty
? null ? null
: FloatingActionButton.extended( : FloatingActionButton.extended(
onPressed: decliningAll.value ? null : declineAll, onPressed: decliningAll ? null : declineAll,
icon: decliningAll.value icon: decliningAll
? const SizedBox.square( ? const SizedBox.square(
dimension: 24, dimension: 24,
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
@ -96,9 +95,12 @@ class const InvitesList({super.key})
} }
class const InviteCard(final RoomSummary invite, {super.key}) class const InviteCard(final RoomSummary invite, {super.key})
extends StatelessWidget { extends ConsumerWidget {
@override @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 name = invite.name ?? invite.canonicalAlias ?? invite.roomId;
final inviterName = final inviterName =
invite.inviterProfile?.displayName ?? invite.invitedBy?.localpart; invite.inviterProfile?.displayName ?? invite.invitedBy?.localpart;
@ -106,7 +108,15 @@ class const InviteCard(final RoomSummary invite, {super.key})
return Card( return Card(
clipBehavior: .antiAlias, clipBehavior: .antiAlias,
child: ListTile( 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( leading: AvatarOrHash(
invite.avatarUrl, invite.avatarUrl,
name, name,

View file

@ -65,9 +65,13 @@ class const RoomSummarySheet(
final container = ProviderScope.containerOf(context); final container = ProviderScope.containerOf(context);
Navigator.of(context).pop(); Navigator.of(context).pop();
container InvitesController.respondMutation(summary.roomId)
.read(InvitesController.provider.notifier) .run(
.decline(summary.roomId) container,
(_) => container
.read(InvitesController.provider.notifier)
.decline(summary.roomId),
)
.onError(showError); .onError(showError);
}, },
style: TextButton.styleFrom(foregroundColor: colorScheme.error), style: TextButton.styleFrom(foregroundColor: colorScheme.error),
@ -77,18 +81,19 @@ class const RoomSummarySheet(
onPressed: () { onPressed: () {
final container = ProviderScope.containerOf(context); final container = ProviderScope.containerOf(context);
context InvitesController.respondMutation(summary.roomId)
.joinRoom( .run(container, (_) async {
.new(roomIdOrAlias: summary.roomId, fromInvite: true), final roomId = await context.joinRoom(
name: name, .new(roomIdOrAlias: summary.roomId, fromInvite: true),
) name: name,
.then((roomId) { );
if (roomId != null) { if (roomId != null) {
container container
.read(InvitesController.provider.notifier) .read(InvitesController.provider.notifier)
.remove(summary.roomId); .remove(summary.roomId);
} }
}); })
.onError(showError);
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
child: Text("Accept"), child: Text("Accept"),

View file

@ -74,53 +74,74 @@ class const SpaceHierarchySheet(final String spaceId, {super.key})
String roomId, { String roomId, {
required String name, required String name,
required String spaceName, required String spaceName,
}) => IconButton( }) => Consumer(
tooltip: "Remove from space", builder: (context, ref, _) {
icon: const Icon(Icons.remove_circle_outline), final mutation = SpaceHierarchyController.removeChildMutation((
onPressed: () => context.showAdaptiveSheet( spaceId,
(context) => SheetLayout( roomId,
title: const Text("Remove Room"), ));
actions: [ if (ref.watch(mutation).isPending) {
TextButton( return const Padding(
onPressed: Navigator.of(context).pop, padding: .all(12),
child: const Text("Cancel"), child: SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(),
), ),
TextButton( );
onPressed: () { }
final container = ProviderScope.containerOf(context);
context.withProgressSnackbar( return IconButton(
"Removing $name from $spaceName...", tooltip: "Remove from space",
() async { icon: const Icon(Icons.remove_circle_outline),
await container onPressed: () => context.showAdaptiveSheet(
.read(ClientController.provider.notifier) (context) => SheetLayout(
.setState( title: const Text("Remove Room"),
.new( actions: [
roomId: spaceId, TextButton(
type: EventType.spaceChild.type, onPressed: Navigator.of(context).pop,
stateKey: roomId, child: const Text("Cancel"),
content: const Content(), ),
), TextButton(
); onPressed: () {
container.invalidate( final container = ProviderScope.containerOf(context);
SpaceHierarchyController.provider(spaceId), 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();
}, },
); style: TextButton.styleFrom(
Navigator.of(context).pop(); foregroundColor: Theme.of(context).colorScheme.error,
}, ),
style: TextButton.styleFrom( child: const Text("Remove"),
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( Widget level(
@ -130,6 +151,9 @@ class const SpaceHierarchySheet(final String spaceId, {super.key})
) => HookConsumer( ) => HookConsumer(
builder: (context, ref, _) { builder: (context, ref, _) {
final loadingMore = useState(false); final loadingMore = useState(false);
final adding = ref
.watch(SpaceHierarchyController.addChildrenMutation(spaceId))
.isPending;
final canManage = ref.watch( final canManage = ref.watch(
PowerLevelController.provider( PowerLevelController.provider(
.state(eventType: .spaceChild, roomId: spaceId), .state(eventType: .spaceChild, roomId: spaceId),
@ -317,16 +341,24 @@ class const SpaceHierarchySheet(final String spaceId, {super.key})
), ),
if (canManage) if (canManage)
TextButton.icon( TextButton.icon(
onPressed: () => context.showAdaptiveSheet( onPressed: adding
(_) => AddSpaceRoomsSheet( ? null
spaceId, : () => context.showAdaptiveSheet(
spaceName: spaceName, (_) => AddSpaceRoomsSheet(
existing: (byId[spaceId]?.children ?? const .empty()) spaceId,
.map((child) => child.roomId) spaceName: spaceName,
.toISet(), existing:
), (byId[spaceId]?.children ?? const .empty())
), .map((child) => child.roomId)
icon: const Icon(Icons.add), .toISet(),
),
),
icon: adding
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(),
)
: const Icon(Icons.add),
label: const Text("Add rooms"), label: const Text("Add rooms"),
), ),
], ],