forked from Nexus/nexus
parent
e0b5dc58d0
commit
8fbfcde9c5
11 changed files with 639 additions and 102 deletions
|
|
@ -307,6 +307,15 @@ class ClientController extends AsyncNotifier<int> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> 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<void> reportEvent(ReportRequest request) =>
|
||||
_sendCommand("report_event", request.toJson());
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,15 @@ class ProfileController(final String userId)
|
|||
return client.getProfile(userId);
|
||||
}
|
||||
|
||||
Future<void> 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, ProfileResponse, String>(
|
||||
ProfileController.new,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
17
lib/controllers/timezones.dart
Normal file
17
lib/controllers/timezones.dart
Normal file
|
|
@ -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<IList<String>> {
|
||||
@override
|
||||
IList<String> build() {
|
||||
initializeTimeZones();
|
||||
return timeZoneDatabase.locations.keys.toIList().sort();
|
||||
}
|
||||
|
||||
static final provider = NotifierProvider<TimezonesController, IList<String>>(
|
||||
TimezonesController.new,
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Pronoun> 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<String, Object?> toJson() => _$ProfileToJson(this);
|
||||
|
||||
Profile withPronouns(String language, Iterable<String> summaries) => copyWith(
|
||||
pronouns: pronouns
|
||||
.where((pronoun) => pronoun.language != language)
|
||||
.toIList()
|
||||
.addAll(
|
||||
summaries.map(
|
||||
(summary) => Pronoun(language: language, summary: summary),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
static Object? readPronouns(Map<dynamic, dynamic> map, String key) =>
|
||||
map[key] ?? map["io.fsky.nyx.pronouns"];
|
||||
/* until stable, commented out: map[key] ?? */ map[Profile
|
||||
.unstablePronounsKey];
|
||||
|
||||
static Object? readTimezone(Map<dynamic, dynamic> map, String key) =>
|
||||
map[key] ?? map["us.cloke.msc4175.tz"];
|
||||
map[key] ?? map[Profile.unstableTimezoneKey];
|
||||
|
||||
factory Profile.fromJson(Map<String, dynamic> json) =>
|
||||
_$ProfileFromJson(json);
|
||||
|
|
|
|||
86
lib/widgets/editable_avatar.dart
Normal file
86
lib/widgets/editable_avatar.dart
Normal file
|
|
@ -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<void> Function(Uri? url) onChanged,
|
||||
final double dimension = 200,
|
||||
super.key,
|
||||
}) extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final saving = Saving.use();
|
||||
Future<void> save(Future<void> Function() action) =>
|
||||
saving.run(() => action().onError(showError));
|
||||
|
||||
Future<void> 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"),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
131
lib/widgets/settings/inline_text_field.dart
Normal file
131
lib/widgets/settings/inline_text_field.dart
Normal file
|
|
@ -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<void> 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<void> 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
102
lib/widgets/timezone_field.dart
Normal file
102
lib/widgets/timezone_field.dart
Normal file
|
|
@ -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<void> 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),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> 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<Pronoun>.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,12 +142,13 @@ 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: [
|
||||
if (!editable) ...[
|
||||
Row(
|
||||
mainAxisAlignment: .end,
|
||||
children: [
|
||||
|
|
@ -117,19 +159,33 @@ final class const UserBottomSheet(
|
|||
],
|
||||
),
|
||||
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)
|
||||
EditableAvatar(
|
||||
hasAvatar: member.avatarUrl != null,
|
||||
onChanged: (url) => setProfileField(
|
||||
Profile.avatarUrlKey,
|
||||
url?.toString() ?? "",
|
||||
),
|
||||
child: avatarOf(member),
|
||||
)
|
||||
else
|
||||
avatarOf(member),
|
||||
|
||||
SizedBox(height: 8),
|
||||
|
||||
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,
|
||||
|
|
@ -140,6 +196,8 @@ final class const UserBottomSheet(
|
|||
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,20 +206,77 @@ final class const UserBottomSheet(
|
|||
alignment: .center,
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
...profileResponse.profile.pronouns
|
||||
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) => pronoun.language == "en",
|
||||
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,
|
||||
color: theme
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
Text(
|
||||
pronoun.summary,
|
||||
|
|
@ -172,16 +287,13 @@ final class const UserBottomSheet(
|
|||
)
|
||||
.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,
|
||||
),
|
||||
),
|
||||
if (profileResponse.profile.timezone !=
|
||||
null) ...[
|
||||
if (profileResponse
|
||||
.profile
|
||||
.pronouns
|
||||
.isNotEmpty)
|
||||
divider,
|
||||
Text(
|
||||
profileResponse.profile.timezone!,
|
||||
textAlign: .center,
|
||||
|
|
@ -194,7 +306,8 @@ final class const UserBottomSheet(
|
|||
),
|
||||
),
|
||||
|
||||
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,29 +328,45 @@ final class const UserBottomSheet(
|
|||
},
|
||||
|
||||
SizedBox(height: 8),
|
||||
if (userId !=
|
||||
if (editable ||
|
||||
userId !=
|
||||
ref.watch(ClientStateController.provider)?.userId) ...[
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: M3EButton.icon(
|
||||
onPressed: () async {
|
||||
final name = member.displayName ?? userId.localpart;
|
||||
onPressed: editable
|
||||
? null
|
||||
: () async {
|
||||
final name =
|
||||
member.displayName ?? userId.localpart;
|
||||
final hasDm = ref
|
||||
.read(RoomsController.provider)
|
||||
.values
|
||||
.any((room) => room.metadata?.dmUserId == userId);
|
||||
.any(
|
||||
(room) =>
|
||||
room.metadata?.dmUserId == userId,
|
||||
);
|
||||
|
||||
if (hasDm) {
|
||||
context.startDirectMessage(userId, name: name);
|
||||
} else if (await context.showAdaptiveSheet<bool>(
|
||||
(_) => StartDmSheet(userId: userId, name: name),
|
||||
context.startDirectMessage(
|
||||
userId,
|
||||
name: name,
|
||||
);
|
||||
} else if (await context
|
||||
.showAdaptiveSheet<bool>(
|
||||
(_) => 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,
|
||||
|
|
@ -287,7 +416,9 @@ final class const UserBottomSheet(
|
|||
),
|
||||
),
|
||||
|
||||
if (roomId != null &&
|
||||
// Shown as a moderator would see it, while editing
|
||||
if (editable ||
|
||||
(roomId != null &&
|
||||
ref.watch(
|
||||
PowerLevelController.provider(
|
||||
.membershipAction(
|
||||
|
|
@ -296,15 +427,21 @@ final class const UserBottomSheet(
|
|||
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"),
|
||||
|
|
|
|||
|
|
@ -1488,7 +1488,7 @@ packages:
|
|||
source: hosted
|
||||
version: "3.7.1"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: timezone
|
||||
sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue