diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index fef1c16..569138f 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -307,6 +307,15 @@ class ClientController extends AsyncNotifier { } } + Future setProfileField(String field, Object? value) => _sendCommand( + "set_profile_field", + { + "field": field, + // gomuks deletes the field when the value is omitted, not when it's null + "value": ?value, + }, + ); + Future reportEvent(ReportRequest request) => _sendCommand("report_event", request.toJson()); diff --git a/lib/controllers/profile.dart b/lib/controllers/profile.dart index 7a1477c..246815d 100644 --- a/lib/controllers/profile.dart +++ b/lib/controllers/profile.dart @@ -10,6 +10,15 @@ class ProfileController(final String userId) return client.getProfile(userId); } + Future setField(String field, Object? value) async { + await ref + .read(ClientController.provider.notifier) + .setProfileField(field, value); + if (!ref.mounted) return; + ref.invalidateSelf(); + await future; + } + static final provider = AsyncNotifierProvider.family .autoDispose( ProfileController.new, diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index 665ecb1..2cd804c 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -18,12 +18,14 @@ import "package:nexus/controllers/unified_push_allowed.dart"; import "package:nexus/controllers/unified_push_distributors.dart"; import "package:nexus/helpers/extensions/distributor_name.dart"; import "package:nexus/models/account_data.dart"; +import "package:nexus/models/client_state.dart"; import "package:nexus/models/room_order.dart"; import "package:nexus/models/settings.dart"; import "package:nexus/models/settings_category.dart"; import "package:nexus/models/show_and_send.dart"; import "package:nexus/main.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart"; +import "package:nexus/widgets/user_bottom_sheet.dart"; import "package:nexus/widgets/reset_encryption_sheet.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; @@ -240,9 +242,28 @@ class SettingsSectionsController ]), ), ]), - if (ref.watch(ClientStateController.provider)?.isLoggedIn == true) + if (ref.watch(ClientStateController.provider) case ClientState( + isLoggedIn: true, + :final userId?, + )) "Account": .new([ - .new(title: "Profile", icon: Icons.person, settings: .new([])), + .new( + title: "Profile", + icon: Icons.person, + settings: .new([ + .new( + title: "Profile", + description: "Your avatar, display name, pronouns, and timezone, as others see them.", + icon: Icons.person, + builder: (title, description, icon) => Center( + child: ConstrainedBox( + constraints: .new(maxWidth: 500), + child: UserBottomSheet(userId, editable: true), + ), + ), + ), + ]), + ), .new( title: "Notifications", icon: Icons.notifications, diff --git a/lib/controllers/timezones.dart b/lib/controllers/timezones.dart new file mode 100644 index 0000000..458ad79 --- /dev/null +++ b/lib/controllers/timezones.dart @@ -0,0 +1,17 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:timezone/data/latest_10y.dart"; +import "package:timezone/timezone.dart"; + +/// The names of all IANA timezones, e.g. `Europe/London`, alphabetically. +class TimezonesController extends Notifier> { + @override + IList build() { + initializeTimeZones(); + return timeZoneDatabase.locations.keys.toIList().sort(); + } + + static final provider = NotifierProvider>( + TimezonesController.new, + ); +} diff --git a/lib/models/profile_response.dart b/lib/models/profile_response.dart index 3896e32..7949f0d 100644 --- a/lib/models/profile_response.dart +++ b/lib/models/profile_response.dart @@ -31,24 +31,46 @@ class const Bio({required final String html, final String? editSource}) class const Profile({ final String? id, final String? parseError, - final Uri? avatarUrl, + @JsonKey(name: Profile.avatarUrlKey) final Uri? avatarUrl, - @JsonKey(name: "displayname", fromJson: MembershipContent.displaynameFromJson) + @JsonKey( + name: Profile.displayNameKey, + fromJson: MembershipContent.displaynameFromJson, + ) final String? displayName, - @JsonKey(readValue: Profile.readTimezone, name: "m.tz") + @JsonKey(readValue: Profile.readTimezone, name: Profile.timezoneKey) final String? timezone, - @JsonKey(readValue: Profile.readPronouns, name: "m.pronouns") + @JsonKey(readValue: Profile.readPronouns, name: Profile.pronounsKey) final IList pronouns = const .empty(), }) with _$Profile { + static const avatarUrlKey = "avatar_url"; + static const displayNameKey = "displayname"; + static const timezoneKey = "m.tz"; + static const unstableTimezoneKey = "us.cloke.msc4175.tz"; + static const pronounsKey = "m.pronouns"; + static const unstablePronounsKey = "io.fsky.nyx.pronouns"; + Map toJson() => _$ProfileToJson(this); + Profile withPronouns(String language, Iterable summaries) => copyWith( + pronouns: pronouns + .where((pronoun) => pronoun.language != language) + .toIList() + .addAll( + summaries.map( + (summary) => Pronoun(language: language, summary: summary), + ), + ), + ); + static Object? readPronouns(Map map, String key) => - map[key] ?? map["io.fsky.nyx.pronouns"]; + /* until stable, commented out: map[key] ?? */ map[Profile + .unstablePronounsKey]; static Object? readTimezone(Map map, String key) => - map[key] ?? map["us.cloke.msc4175.tz"]; + map[key] ?? map[Profile.unstableTimezoneKey]; factory Profile.fromJson(Map json) => _$ProfileFromJson(json); diff --git a/lib/widgets/editable_avatar.dart b/lib/widgets/editable_avatar.dart new file mode 100644 index 0000000..d8d387f --- /dev/null +++ b/lib/widgets/editable_avatar.dart @@ -0,0 +1,86 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/helpers/extensions/show_adaptive_menu.dart"; +import "package:nexus/helpers/file_dialog_helper.dart"; +import "package:nexus/helpers/hooks/saving.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/content/message.dart"; + +/// Shows [child], an avatar, with a button to upload a new one or remove it. +class const EditableAvatar({ + required final Widget child, + + /// Whether there's an avatar to remove. + required final bool hasAvatar, + + /// Called with the uploaded image, or null when the avatar is removed. + required final Future Function(Uri? url) onChanged, + final double dimension = 200, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final saving = Saving.use(); + Future save(Future Function() action) => + saving.run(() => action().onError(showError)); + + Future pick() async { + final file = await ref + .read(FileDialogHelper.provider) + .pickFile(images: true); + if (file == null || !context.mounted) return; + + await save(() async { + final content = await ref + .read(ClientController.provider.notifier) + .uploadMedia(.new(path: file.path, encrypt: false)); + if (content case ImageMessageContent(:final url)) { + await onChanged(url); + } else { + throw Exception("The file isn't an image"); + } + }); + } + + return SizedBox.square( + dimension: dimension, + child: Stack( + children: [ + child, + if (saving.active) const Center(child: CircularProgressIndicator()), + Positioned( + right: dimension * 0.01, + bottom: dimension * 0.01, + child: Builder( + builder: (context) => IconButton.filled( + tooltip: "Edit avatar", + icon: Icon(Icons.edit), + onPressed: saving.active + ? null + : !hasAvatar + ? pick + : () => context.showAdaptiveMenu([ + PopupMenuItem( + onTap: pick, + child: ListTile( + leading: Icon(Icons.upload), + title: Text("Upload new avatar"), + ), + ), + PopupMenuItem( + onTap: () => save(() => onChanged(null)), + child: ListTile( + leading: Icon(Icons.delete), + title: Text("Remove avatar"), + ), + ), + ]), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/settings/inline_text_field.dart b/lib/widgets/settings/inline_text_field.dart new file mode 100644 index 0000000..1ccddb7 --- /dev/null +++ b/lib/widgets/settings/inline_text_field.dart @@ -0,0 +1,131 @@ +import "dart:async"; + +import "package:flutter/services.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/hooks/saving.dart"; + +/// Centered text that's edited in place, saving when submitted or unfocused. +class const InlineTextField({ + required final String? value, + required final String hint, + + /// Disables the field if null, and while a returned future runs. + required final FutureOr Function(String value)? onChanged, + final TextStyle? style, + + /// Pads the start to match the edit icon, keeping the text centered when the + /// field is on its own, rather than beside others. + final bool balanced = true, + super.key, +}) extends HookWidget { + static double iconSizeOf(TextStyle? style) => + ((style?.fontSize ?? 16) * 0.7).clamp(16.0, 24.0); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final saving = Saving.use(); + final controller = useTextEditingController(text: value); + final isEmpty = useValueListenable(controller).text.isEmpty; + final focusNode = useFocusNode(); + final focused = useListenableSelector(focusNode, () => focusNode.hasFocus); + final latest = useRef(value); + latest.value = value; + + // Show changes made elsewhere, unless they'd overwrite an edit. Effects run + // while building, so this waits until after, to not rebuild the field mid-build. + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted && !focusNode.hasFocus) { + controller.text = value ?? ""; + } + }); + return null; + }, [value]); + + useEffect(() { + Future listener() async { + if (focusNode.hasFocus) return; + final text = controller.text.trim(); + if (text != (latest.value ?? "")) { + await saving.run(() => onChanged?.call(text)); + } + + if (context.mounted && !focusNode.hasFocus) { + controller.text = latest.value ?? ""; + } + } + + focusNode.addListener(listener); + return () => focusNode.removeListener(listener); + }, [focusNode, onChanged]); + + const border = UnderlineInputBorder(borderRadius: .zero, borderSide: .none); + final iconSize = iconSizeOf(style); + final iconConstraints = BoxConstraints.tight(Size(iconSize + 12, iconSize)); + + return CallbackShortcuts( + bindings: { + const SingleActivator(.escape): () { + controller.text = value ?? ""; + focusNode.unfocus(); + }, + }, + // Sized to the text, so the edit icon sits beside it + child: IntrinsicWidth( + child: TextField( + controller: controller, + focusNode: focusNode, + enabled: onChanged != null && !saving.active, + style: style, + textAlign: .center, + textCapitalization: .sentences, + // Wraps long text, but stays one line, with enter saving it + maxLines: null, + keyboardType: .text, + textInputAction: .done, + inputFormatters: [FilteringTextInputFormatter.singleLineFormatter], + onSubmitted: (_) => focusNode.unfocus(), + onTapOutside: (_) => focusNode.unfocus(), + decoration: .new( + // The hint counts towards the field's width, so it's only set when it shows + hintText: isEmpty ? hint : null, + hintStyle: style?.copyWith(color: colorScheme.onSurfaceVariant), + filled: true, + fillColor: Colors.transparent, + hoverColor: colorScheme.surfaceContainerHighest, + isDense: true, + // Without the prefix, this keeps the text off the edge of the hover background + contentPadding: EdgeInsetsDirectional.only( + start: balanced ? 0 : 6, + top: 8, + bottom: 8, + ), + border: border, + focusedBorder: border.copyWith( + borderSide: BorderSide(color: colorScheme.primary, width: 2), + ), + prefixIcon: balanced ? const SizedBox() : null, + prefixIconConstraints: iconConstraints, + suffixIcon: saving.active + ? Center( + child: SizedBox.square( + dimension: iconSize - 4, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : Icon( + Icons.edit, + size: iconSize, + color: focused + ? colorScheme.onSurfaceVariant.withValues(alpha: 0.38) + : colorScheme.onSurfaceVariant, + ), + suffixIconConstraints: iconConstraints, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/timezone_field.dart b/lib/widgets/timezone_field.dart new file mode 100644 index 0000000..aeab833 --- /dev/null +++ b/lib/widgets/timezone_field.dart @@ -0,0 +1,102 @@ +import "dart:async"; + +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/timezones.dart"; +import "package:nexus/helpers/extensions/is_desktop.dart"; +import "package:nexus/helpers/hooks/saving.dart"; +import "package:nexus/widgets/settings/inline_text_field.dart"; +import "package:timezone/timezone.dart"; + +class const TimezoneField({ + required final String? value, + + required final FutureOr Function(String? value)? onChanged, + final TextStyle? style, + super.key, +}) extends HookConsumerWidget { + static String offsetOf(String timezone) { + final offset = TZDateTime.now(getLocation(timezone)).timeZoneOffset; + final hours = offset.inHours.abs().toString().padLeft(2, "0"); + final minutes = (offset.inMinutes.abs() % 60).toString().padLeft(2, "0"); + return "UTC${offset.isNegative ? "-" : "+"}$hours:$minutes"; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + final saving = Saving.use(); + final timezones = ref.watch(TimezonesController.provider); + final enabled = onChanged != null && !saving.active; + final iconSize = InlineTextField.iconSizeOf(style); + + void choose(SearchController controller, String? timezone) { + controller.closeView(""); + if (timezone != value) saving.run(() => onChanged!(timezone)); + } + + return SearchAnchor( + viewHintText: "Search timezones...", + viewConstraints: context.isDesktop + ? const .new(minWidth: 320, maxWidth: 400, maxHeight: 400) + : null, + builder: (context, controller) => InkWell( + hoverColor: colorScheme.surfaceContainerHighest, + onTap: enabled ? controller.openView : null, + child: Padding( + padding: const .fromLTRB(6, 8, 0, 8), + child: Row( + mainAxisSize: .min, + children: [ + Text( + value?.replaceAll("_", " ") ?? "Timezone", + style: value == null + ? style?.copyWith(color: colorScheme.onSurfaceVariant) + : style, + ), + SizedBox( + width: iconSize + 12, + child: saving.active + ? Center( + child: SizedBox.square( + dimension: iconSize - 4, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : Icon( + Icons.edit, + size: iconSize, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + suggestionsBuilder: (context, controller) { + final query = controller.text.trim().toLowerCase().replaceAll(" ", "_"); + + return [ + if (value != null && query.isEmpty) + ListTile( + leading: Icon(Icons.close), + title: Text("Remove timezone"), + onTap: () => choose(controller, null), + ), + ...timezones + .where((timezone) => timezone.toLowerCase().contains(query)) + .map( + (timezone) => ListTile( + leading: Icon( + timezone == value ? Icons.check : Icons.schedule, + ), + title: Text(timezone.replaceAll("_", " ")), + subtitle: Text(offsetOf(timezone)), + onTap: () => choose(controller, timezone), + ), + ), + ]; + }, + ); + } +} diff --git a/lib/widgets/user_bottom_sheet.dart b/lib/widgets/user_bottom_sheet.dart index bcab7fc..58e29d2 100644 --- a/lib/widgets/user_bottom_sheet.dart +++ b/lib/widgets/user_bottom_sheet.dart @@ -1,4 +1,5 @@ import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:dynamic_color/dynamic_color.dart"; import "package:material_ui/material_ui.dart"; import "package:nexus/widgets/sheet_layout.dart"; @@ -20,15 +21,23 @@ import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/mutual_rooms_sheet.dart"; +import "package:nexus/models/content/membership.dart"; import "package:nexus/models/membership_action.dart"; +import "package:nexus/models/profile_response.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/main.dart"; +import "package:nexus/widgets/editable_avatar.dart"; import "package:nexus/widgets/expandable_image.dart"; +import "package:nexus/widgets/settings/inline_text_field.dart"; import "package:nexus/widgets/start_dm_sheet.dart"; +import "package:nexus/widgets/timezone_field.dart"; final class const UserBottomSheet( final String userId, { final String? roomId, + + /// Edits the current user's global profile, instead of showing actions. + final bool editable = false, super.key, }) extends ConsumerWidget { @override @@ -39,6 +48,38 @@ final class const UserBottomSheet( color: theme.colorScheme.onSurfaceVariant, ); final client = ref.read(ClientController.provider.notifier); + final profileController = ref.read( + ProfileController.provider(userId).notifier, + ); + Future setProfileField(String field, Object? value) => + profileController.setField(field, value).onError(showError); + + final profile = ref + .watch(ProfileController.provider(userId)) + .value + ?.profile; + final englishPronouns = (profile?.pronouns ?? const IList.empty()) + // TODO: Check system language (l10n) + .where((pronoun) => pronoun.language == "en"); + final divider = SizedBox( + height: 16, + child: VerticalDivider( + thickness: 1.5, + width: 4, + color: theme.colorScheme.onSurfaceVariant, + ), + ); + + Widget avatarOf(MembershipContent member) => ExpandableImage( + member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!), + downloadFilename: member.displayName ?? userId.localpart, + child: AvatarOrHash( + member.avatarUrl, + member.displayName ?? userId.localpart, + dimension: 200, + ), + ); + // Green for accepting knocks, harmonized so it fits the current theme final acceptScheme = ColorScheme.fromSeed( seedColor: Colors.green.harmonizeWith(theme.colorScheme.primary), @@ -101,45 +142,62 @@ final class const UserBottomSheet( AsyncData(value: final member) || AsyncLoading(value: final member?) => SingleChildScrollView( child: Padding( - padding: .all(42), + padding: .all(editable ? 16 : 42), child: Column( spacing: 4, mainAxisSize: .min, crossAxisAlignment: .center, children: [ - Row( - mainAxisAlignment: .end, - children: [ - M3EButton( - onPressed: Navigator.of(context).pop, - child: Icon(Icons.close), - ), - ], - ), - SizedBox(height: 18), - - ExpandableImage( - member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!), - downloadFilename: member.displayName ?? userId.localpart, - child: AvatarOrHash( - member.avatarUrl, - member.displayName ?? userId.localpart, - dimension: 200, + if (!editable) ...[ + Row( + mainAxisAlignment: .end, + children: [ + M3EButton( + onPressed: Navigator.of(context).pop, + child: Icon(Icons.close), + ), + ], ), - ), + SizedBox(height: 18), + ], + + if (editable) + EditableAvatar( + hasAvatar: member.avatarUrl != null, + onChanged: (url) => setProfileField( + Profile.avatarUrlKey, + url?.toString() ?? "", + ), + child: avatarOf(member), + ) + else + avatarOf(member), SizedBox(height: 8), - SelectableText( - member.displayName ?? userId.localpart, - style: textTheme.headlineLarge, - textAlign: .center, - ), + if (editable) + InlineTextField( + value: profile?.displayName, + hint: userId.localpart, + style: textTheme.headlineLarge, + onChanged: profile == null + ? null + : (value) => + setProfileField(Profile.displayNameKey, value), + ) + else + SelectableText( + member.displayName ?? userId.localpart, + style: textTheme.headlineLarge, + textAlign: .center, + ), SelectableText(userId, textAlign: .center, style: subtitleStyle), ref .watch(ProfileController.provider(userId)) .betterWhen( + // Keeps showing the profile while it's fetched again after saving + skipLoadingOnRefresh: true, loading: () => Text(""), data: (profileResponse) => Column( children: [ @@ -148,53 +206,108 @@ final class const UserBottomSheet( alignment: .center, spacing: 4, runSpacing: 4, - children: [ - ...profileResponse.profile.pronouns - .where( - // TODO: Check system language (l10n) - (pronoun) => pronoun.language == "en", - ) - .mapIndexed( - (index, pronoun) => [ - if (index != 0) - Icon( - Icons.circle, - size: 4, - color: - theme.colorScheme.onSurfaceVariant, - ), + children: editable + ? [ + InlineTextField( + value: englishPronouns + .map((pronoun) => pronoun.summary) + .join(", "), + hint: "Pronouns, e.g. they/them", + balanced: false, + style: subtitleStyle, + onChanged: (value) { + final pronouns = profileResponse + .profile + .pronouns + .where( + (pronoun) => + // TODO: Check system language (l10n) + pronoun.language != "en", + ) + .toIList() + .addAll( + value + .split(",") + .map( + (summary) => summary.trim(), + ) + .where( + (summary) => + summary.isNotEmpty, + ) + .map( + (summary) => Pronoun( + language: "en", + summary: summary, + ), + ), + ); + + return setProfileField( + Profile.unstablePronounsKey, + pronouns.isEmpty + ? null + : pronouns + .map( + (pronoun) => + pronoun.toJson(), + ) + .toList(), + ); + }, + ), + divider, + TimezoneField( + value: profileResponse.profile.timezone, + style: subtitleStyle, + onChanged: (value) => setProfileField( + Profile.timezoneKey, + value, + ), + ), + ] + : [ + ...englishPronouns + .mapIndexed( + (index, pronoun) => [ + if (index != 0) + Icon( + Icons.circle, + size: 4, + color: theme + .colorScheme + .onSurfaceVariant, + ), + Text( + pronoun.summary, + textAlign: .center, + style: subtitleStyle, + ), + ], + ) + .flattened, + + if (profileResponse.profile.timezone != + null) ...[ + if (profileResponse + .profile + .pronouns + .isNotEmpty) + divider, Text( - pronoun.summary, + profileResponse.profile.timezone!, textAlign: .center, style: subtitleStyle, ), ], - ) - .flattened, - - if (profileResponse.profile.timezone != null) ...[ - if (profileResponse.profile.pronouns.isNotEmpty) - SizedBox( - height: 16, - child: VerticalDivider( - thickness: 1.5, - width: 4, - color: theme.colorScheme.onSurfaceVariant, - ), - ), - Text( - profileResponse.profile.timezone!, - textAlign: .center, - style: subtitleStyle, - ), - ], - ], + ], ), ], ), ), - if (userId != ref.watch(ClientStateController.provider)?.userId) + if (!editable && + userId != ref.watch(ClientStateController.provider)?.userId) switch (ref.watch(MutualRoomsController.provider(userId))) { AsyncData(value: final roomIds) => switch (ref.watch( RoomsController.provider.select( @@ -215,30 +328,46 @@ final class const UserBottomSheet( }, SizedBox(height: 8), - if (userId != - ref.watch(ClientStateController.provider)?.userId) ...[ + if (editable || + userId != + ref.watch(ClientStateController.provider)?.userId) ...[ Row( children: [ Expanded( child: M3EButton.icon( - onPressed: () async { - final name = member.displayName ?? userId.localpart; - final hasDm = ref - .read(RoomsController.provider) - .values - .any((room) => room.metadata?.dmUserId == userId); + onPressed: editable + ? null + : () async { + final name = + member.displayName ?? userId.localpart; + final hasDm = ref + .read(RoomsController.provider) + .values + .any( + (room) => + room.metadata?.dmUserId == userId, + ); - if (hasDm) { - context.startDirectMessage(userId, name: name); - } else if (await context.showAdaptiveSheet( - (_) => StartDmSheet(userId: userId, name: name), - ) != - true) { - return; - } + if (hasDm) { + context.startDirectMessage( + userId, + name: name, + ); + } else if (await context + .showAdaptiveSheet( + (_) => StartDmSheet( + userId: userId, + name: name, + ), + ) != + true) { + return; + } - if (context.mounted) Navigator.of(context).pop(); - }, + if (context.mounted) { + Navigator.of(context).pop(); + } + }, shape: .square, style: .tonal, icon: Icon(Icons.message), @@ -287,24 +416,32 @@ final class const UserBottomSheet( ), ), - if (roomId != null && - ref.watch( - PowerLevelController.provider( - .membershipAction( - action: .kick, - roomId: roomId!, - targetUser: userId, - ), - ), - )) + // Shown as a moderator would see it, while editing + if (editable || + (roomId != null && + ref.watch( + PowerLevelController.provider( + .membershipAction( + action: .kick, + roomId: roomId!, + targetUser: userId, + ), + ), + ))) Padding( padding: .only(top: 4), child: Row( spacing: 8, children: [ - if (member.status case .join || .invite || .knock) + if (editable || + switch (member.status) { + .join || .invite || .knock => true, + _ => false, + }) M3EButton.icon( - onPressed: () => showMembershipSheet(.kick), + onPressed: editable + ? null + : () => showMembershipSheet(.kick), shape: .square, icon: Icon(Icons.sports_martial_arts), label: Text("Kick"), @@ -315,7 +452,9 @@ final class const UserBottomSheet( ), M3EButton.icon( - onPressed: () => showMembershipSheet(.ban), + onPressed: editable + ? null + : () => showMembershipSheet(.ban), shape: .square, icon: Icon(Icons.gavel), label: Text("Ban"), diff --git a/pubspec.lock b/pubspec.lock index d3101a9..670b2ca 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1488,7 +1488,7 @@ packages: source: hosted version: "3.7.1" timezone: - dependency: transitive + dependency: "direct main" description: name: timezone sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" diff --git a/pubspec.yaml b/pubspec.yaml index 47df5ce..4f39749 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -110,6 +110,7 @@ dependencies: unifiedpush: 6.2.0 unifiedpush_storage_shared_preferences: 1.0.0 flutter_local_notifications: 22.3.1 + timezone: 0.11.1 material_emoji_picker: 1.2.0 xdg_desktop_portal: git: