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: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<IList<RoomSummary>> {
Future<void> declineAll() =>
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 =
NotifierProvider<InvitesController, IList<RoomSummary>>(
InvitesController.new,

View file

@ -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<void>();
/// Removing a room from a space, keyed by (space ID, room ID)
static final removeChildMutation = Mutation<void>();
static final provider = AsyncNotifierProvider.family
.autoDispose<SpaceHierarchyController, SpaceHierarchy, String>(
SpaceHierarchyController.new,

View file

@ -8,7 +8,9 @@ import "package:nexus/controllers/unified_push.dart";
import "package:nexus/main.dart";
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 account = accounts.firstWhereOrNull(
(account) => account.id == accountId,
@ -49,5 +51,5 @@ extension LogOut on MutationTarget {
await container
.read(ActiveAccountController.provider.notifier)
.switchTo(other.id);
}
});
}

View file

@ -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: (_) => [

View file

@ -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();
},

View file

@ -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<void> declineAll() async {
final confirmed = await context.showAdaptiveSheet<bool>(
@ -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,

View file

@ -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"),

View file

@ -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"),
),
],