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) =>
|
Future<void> reportEvent(ReportRequest request) =>
|
||||||
_sendCommand("report_event", request.toJson());
|
_sendCommand("report_event", request.toJson());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,15 @@ class ProfileController(final String userId)
|
||||||
return client.getProfile(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
|
static final provider = AsyncNotifierProvider.family
|
||||||
.autoDispose<ProfileController, ProfileResponse, String>(
|
.autoDispose<ProfileController, ProfileResponse, String>(
|
||||||
ProfileController.new,
|
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/controllers/unified_push_distributors.dart";
|
||||||
import "package:nexus/helpers/extensions/distributor_name.dart";
|
import "package:nexus/helpers/extensions/distributor_name.dart";
|
||||||
import "package:nexus/models/account_data.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/room_order.dart";
|
||||||
import "package:nexus/models/settings.dart";
|
import "package:nexus/models/settings.dart";
|
||||||
import "package:nexus/models/settings_category.dart";
|
import "package:nexus/models/settings_category.dart";
|
||||||
import "package:nexus/models/show_and_send.dart";
|
import "package:nexus/models/show_and_send.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
import "package:nexus/widgets/settings/dialog_list_tile.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/widgets/reset_encryption_sheet.dart";
|
||||||
import "package:nexus/helpers/extensions/show_adaptive_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([
|
"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(
|
.new(
|
||||||
title: "Notifications",
|
title: "Notifications",
|
||||||
icon: Icons.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({
|
class const Profile({
|
||||||
final String? id,
|
final String? id,
|
||||||
final String? parseError,
|
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,
|
final String? displayName,
|
||||||
|
|
||||||
@JsonKey(readValue: Profile.readTimezone, name: "m.tz")
|
@JsonKey(readValue: Profile.readTimezone, name: Profile.timezoneKey)
|
||||||
final String? timezone,
|
final String? timezone,
|
||||||
|
|
||||||
@JsonKey(readValue: Profile.readPronouns, name: "m.pronouns")
|
@JsonKey(readValue: Profile.readPronouns, name: Profile.pronounsKey)
|
||||||
final IList<Pronoun> pronouns = const .empty(),
|
final IList<Pronoun> pronouns = const .empty(),
|
||||||
}) with _$Profile {
|
}) 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);
|
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) =>
|
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) =>
|
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) =>
|
factory Profile.fromJson(Map<String, dynamic> json) =>
|
||||||
_$ProfileFromJson(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:collection/collection.dart";
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:dynamic_color/dynamic_color.dart";
|
import "package:dynamic_color/dynamic_color.dart";
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:nexus/widgets/sheet_layout.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/error_dialog.dart";
|
||||||
import "package:nexus/widgets/loading.dart";
|
import "package:nexus/widgets/loading.dart";
|
||||||
import "package:nexus/widgets/mutual_rooms_sheet.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/membership_action.dart";
|
||||||
|
import "package:nexus/models/profile_response.dart";
|
||||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
|
import "package:nexus/widgets/editable_avatar.dart";
|
||||||
import "package:nexus/widgets/expandable_image.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/start_dm_sheet.dart";
|
||||||
|
import "package:nexus/widgets/timezone_field.dart";
|
||||||
|
|
||||||
final class const UserBottomSheet(
|
final class const UserBottomSheet(
|
||||||
final String userId, {
|
final String userId, {
|
||||||
final String? roomId,
|
final String? roomId,
|
||||||
|
|
||||||
|
/// Edits the current user's global profile, instead of showing actions.
|
||||||
|
final bool editable = false,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends ConsumerWidget {
|
}) extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -39,6 +48,38 @@ final class const UserBottomSheet(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
);
|
);
|
||||||
final client = ref.read(ClientController.provider.notifier);
|
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
|
// Green for accepting knocks, harmonized so it fits the current theme
|
||||||
final acceptScheme = ColorScheme.fromSeed(
|
final acceptScheme = ColorScheme.fromSeed(
|
||||||
seedColor: Colors.green.harmonizeWith(theme.colorScheme.primary),
|
seedColor: Colors.green.harmonizeWith(theme.colorScheme.primary),
|
||||||
|
|
@ -101,45 +142,62 @@ final class const UserBottomSheet(
|
||||||
AsyncData(value: final member) ||
|
AsyncData(value: final member) ||
|
||||||
AsyncLoading(value: final member?) => SingleChildScrollView(
|
AsyncLoading(value: final member?) => SingleChildScrollView(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: .all(42),
|
padding: .all(editable ? 16 : 42),
|
||||||
child: Column(
|
child: Column(
|
||||||
spacing: 4,
|
spacing: 4,
|
||||||
mainAxisSize: .min,
|
mainAxisSize: .min,
|
||||||
crossAxisAlignment: .center,
|
crossAxisAlignment: .center,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
if (!editable) ...[
|
||||||
mainAxisAlignment: .end,
|
Row(
|
||||||
children: [
|
mainAxisAlignment: .end,
|
||||||
M3EButton(
|
children: [
|
||||||
onPressed: Navigator.of(context).pop,
|
M3EButton(
|
||||||
child: Icon(Icons.close),
|
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,
|
|
||||||
),
|
),
|
||||||
),
|
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),
|
SizedBox(height: 8),
|
||||||
|
|
||||||
SelectableText(
|
if (editable)
|
||||||
member.displayName ?? userId.localpart,
|
InlineTextField(
|
||||||
style: textTheme.headlineLarge,
|
value: profile?.displayName,
|
||||||
textAlign: .center,
|
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),
|
SelectableText(userId, textAlign: .center, style: subtitleStyle),
|
||||||
|
|
||||||
ref
|
ref
|
||||||
.watch(ProfileController.provider(userId))
|
.watch(ProfileController.provider(userId))
|
||||||
.betterWhen(
|
.betterWhen(
|
||||||
|
// Keeps showing the profile while it's fetched again after saving
|
||||||
|
skipLoadingOnRefresh: true,
|
||||||
loading: () => Text(""),
|
loading: () => Text(""),
|
||||||
data: (profileResponse) => Column(
|
data: (profileResponse) => Column(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -148,53 +206,108 @@ final class const UserBottomSheet(
|
||||||
alignment: .center,
|
alignment: .center,
|
||||||
spacing: 4,
|
spacing: 4,
|
||||||
runSpacing: 4,
|
runSpacing: 4,
|
||||||
children: [
|
children: editable
|
||||||
...profileResponse.profile.pronouns
|
? [
|
||||||
.where(
|
InlineTextField(
|
||||||
// TODO: Check system language (l10n)
|
value: englishPronouns
|
||||||
(pronoun) => pronoun.language == "en",
|
.map((pronoun) => pronoun.summary)
|
||||||
)
|
.join(", "),
|
||||||
.mapIndexed(
|
hint: "Pronouns, e.g. they/them",
|
||||||
(index, pronoun) => [
|
balanced: false,
|
||||||
if (index != 0)
|
style: subtitleStyle,
|
||||||
Icon(
|
onChanged: (value) {
|
||||||
Icons.circle,
|
final pronouns = profileResponse
|
||||||
size: 4,
|
.profile
|
||||||
color:
|
.pronouns
|
||||||
theme.colorScheme.onSurfaceVariant,
|
.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(
|
Text(
|
||||||
pronoun.summary,
|
profileResponse.profile.timezone!,
|
||||||
textAlign: .center,
|
textAlign: .center,
|
||||||
style: subtitleStyle,
|
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))) {
|
switch (ref.watch(MutualRoomsController.provider(userId))) {
|
||||||
AsyncData(value: final roomIds) => switch (ref.watch(
|
AsyncData(value: final roomIds) => switch (ref.watch(
|
||||||
RoomsController.provider.select(
|
RoomsController.provider.select(
|
||||||
|
|
@ -215,30 +328,46 @@ final class const UserBottomSheet(
|
||||||
},
|
},
|
||||||
|
|
||||||
SizedBox(height: 8),
|
SizedBox(height: 8),
|
||||||
if (userId !=
|
if (editable ||
|
||||||
ref.watch(ClientStateController.provider)?.userId) ...[
|
userId !=
|
||||||
|
ref.watch(ClientStateController.provider)?.userId) ...[
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: M3EButton.icon(
|
child: M3EButton.icon(
|
||||||
onPressed: () async {
|
onPressed: editable
|
||||||
final name = member.displayName ?? userId.localpart;
|
? null
|
||||||
final hasDm = ref
|
: () async {
|
||||||
.read(RoomsController.provider)
|
final name =
|
||||||
.values
|
member.displayName ?? userId.localpart;
|
||||||
.any((room) => room.metadata?.dmUserId == userId);
|
final hasDm = ref
|
||||||
|
.read(RoomsController.provider)
|
||||||
|
.values
|
||||||
|
.any(
|
||||||
|
(room) =>
|
||||||
|
room.metadata?.dmUserId == userId,
|
||||||
|
);
|
||||||
|
|
||||||
if (hasDm) {
|
if (hasDm) {
|
||||||
context.startDirectMessage(userId, name: name);
|
context.startDirectMessage(
|
||||||
} else if (await context.showAdaptiveSheet<bool>(
|
userId,
|
||||||
(_) => StartDmSheet(userId: userId, name: name),
|
name: name,
|
||||||
) !=
|
);
|
||||||
true) {
|
} else if (await context
|
||||||
return;
|
.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,
|
shape: .square,
|
||||||
style: .tonal,
|
style: .tonal,
|
||||||
icon: Icon(Icons.message),
|
icon: Icon(Icons.message),
|
||||||
|
|
@ -287,24 +416,32 @@ final class const UserBottomSheet(
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
if (roomId != null &&
|
// Shown as a moderator would see it, while editing
|
||||||
ref.watch(
|
if (editable ||
|
||||||
PowerLevelController.provider(
|
(roomId != null &&
|
||||||
.membershipAction(
|
ref.watch(
|
||||||
action: .kick,
|
PowerLevelController.provider(
|
||||||
roomId: roomId!,
|
.membershipAction(
|
||||||
targetUser: userId,
|
action: .kick,
|
||||||
),
|
roomId: roomId!,
|
||||||
),
|
targetUser: userId,
|
||||||
))
|
),
|
||||||
|
),
|
||||||
|
)))
|
||||||
Padding(
|
Padding(
|
||||||
padding: .only(top: 4),
|
padding: .only(top: 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
children: [
|
children: [
|
||||||
if (member.status case .join || .invite || .knock)
|
if (editable ||
|
||||||
|
switch (member.status) {
|
||||||
|
.join || .invite || .knock => true,
|
||||||
|
_ => false,
|
||||||
|
})
|
||||||
M3EButton.icon(
|
M3EButton.icon(
|
||||||
onPressed: () => showMembershipSheet(.kick),
|
onPressed: editable
|
||||||
|
? null
|
||||||
|
: () => showMembershipSheet(.kick),
|
||||||
shape: .square,
|
shape: .square,
|
||||||
icon: Icon(Icons.sports_martial_arts),
|
icon: Icon(Icons.sports_martial_arts),
|
||||||
label: Text("Kick"),
|
label: Text("Kick"),
|
||||||
|
|
@ -315,7 +452,9 @@ final class const UserBottomSheet(
|
||||||
),
|
),
|
||||||
|
|
||||||
M3EButton.icon(
|
M3EButton.icon(
|
||||||
onPressed: () => showMembershipSheet(.ban),
|
onPressed: editable
|
||||||
|
? null
|
||||||
|
: () => showMembershipSheet(.ban),
|
||||||
shape: .square,
|
shape: .square,
|
||||||
icon: Icon(Icons.gavel),
|
icon: Icon(Icons.gavel),
|
||||||
label: Text("Ban"),
|
label: Text("Ban"),
|
||||||
|
|
|
||||||
|
|
@ -1488,7 +1488,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.7.1"
|
version: "3.7.1"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: timezone
|
name: timezone
|
||||||
sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb"
|
sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb"
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ dependencies:
|
||||||
unifiedpush: 6.2.0
|
unifiedpush: 6.2.0
|
||||||
unifiedpush_storage_shared_preferences: 1.0.0
|
unifiedpush_storage_shared_preferences: 1.0.0
|
||||||
flutter_local_notifications: 22.3.1
|
flutter_local_notifications: 22.3.1
|
||||||
|
timezone: 0.11.1
|
||||||
material_emoji_picker: 1.2.0
|
material_emoji_picker: 1.2.0
|
||||||
xdg_desktop_portal:
|
xdg_desktop_portal:
|
||||||
git:
|
git:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue