reorganize pages into being under widgets
This commit is contained in:
parent
d712bb3193
commit
9f070fcdba
9 changed files with 10 additions and 10 deletions
|
|
@ -7,7 +7,7 @@ import "package:nexus/controllers/portal.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
import "package:flutter_local_notifications/flutter_local_notifications.dart";
|
import "package:flutter_local_notifications/flutter_local_notifications.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/pages/notifications.dart";
|
import "package:nexus/widgets/pages/notifications.dart";
|
||||||
import "package:xdg_desktop_portal/xdg_desktop_portal.dart";
|
import "package:xdg_desktop_portal/xdg_desktop_portal.dart";
|
||||||
|
|
||||||
class NotificationController
|
class NotificationController
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,10 @@ import "package:nexus/controllers/settings.dart";
|
||||||
import "package:nexus/controllers/unified_push.dart";
|
import "package:nexus/controllers/unified_push.dart";
|
||||||
import "package:nexus/helpers/extensions/scheme_to_theme.dart";
|
import "package:nexus/helpers/extensions/scheme_to_theme.dart";
|
||||||
import "package:nexus/helpers/font_licenses.dart";
|
import "package:nexus/helpers/font_licenses.dart";
|
||||||
import "package:nexus/pages/chat.dart";
|
import "package:nexus/widgets/pages/chat.dart";
|
||||||
import "package:nexus/pages/select_server.dart";
|
import "package:nexus/widgets/pages/select_server.dart";
|
||||||
import "package:nexus/pages/settings.dart";
|
import "package:nexus/widgets/pages/settings.dart";
|
||||||
import "package:nexus/pages/verify.dart";
|
import "package:nexus/widgets/pages/verify.dart";
|
||||||
import "package:nexus/widgets/appbar.dart";
|
import "package:nexus/widgets/appbar.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";
|
||||||
|
|
|
||||||
51
lib/widgets/pages/chat.dart
Normal file
51
lib/widgets/pages/chat.dart
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
|
import "package:nexus/controllers/init_complete.dart";
|
||||||
|
import "package:nexus/controllers/key.dart";
|
||||||
|
import "package:nexus/widgets/appbar.dart";
|
||||||
|
import "package:nexus/widgets/sidebar.dart";
|
||||||
|
import "package:nexus/widgets/room_chat/room_chat.dart";
|
||||||
|
import "package:nexus/widgets/loading.dart";
|
||||||
|
|
||||||
|
class const ChatPage({super.key}) extends ConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final isDesktop = constraints.maxWidth > 650;
|
||||||
|
final showMembersByDefault = constraints.maxWidth > 1000;
|
||||||
|
final initComplete = ref.watch(InitCompleteController.provider);
|
||||||
|
final roomId = ref
|
||||||
|
.watch(KeyController.provider(KeyController.roomKey))
|
||||||
|
.requireValue;
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: initComplete ? null : Appbar(),
|
||||||
|
body: initComplete
|
||||||
|
? Row(
|
||||||
|
children: [
|
||||||
|
if (isDesktop) Sidebar(isDesktop: isDesktop),
|
||||||
|
Expanded(
|
||||||
|
child: RoomChat(
|
||||||
|
key: ValueKey(roomId),
|
||||||
|
roomId: roomId,
|
||||||
|
isDesktop: isDesktop,
|
||||||
|
showMembersByDefault: showMembersByDefault,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: .min,
|
||||||
|
children: [Loading(), Text("Syncing...")],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
drawer: isDesktop || !initComplete
|
||||||
|
? null
|
||||||
|
: Sidebar(isDesktop: isDesktop),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
164
lib/widgets/pages/notifications.dart
Normal file
164
lib/widgets/pages/notifications.dart
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import "dart:async";
|
||||||
|
|
||||||
|
import "package:m3e_buttons/m3e_buttons.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
|
import "package:nexus/controllers/notifications.dart";
|
||||||
|
import "package:nexus/models/event.dart";
|
||||||
|
import "package:nexus/widgets/appbar.dart";
|
||||||
|
import "package:nexus/widgets/error_dialog.dart";
|
||||||
|
import "package:nexus/widgets/highlight_wrapper.dart";
|
||||||
|
import "package:nexus/widgets/loading.dart";
|
||||||
|
import "package:nexus/widgets/renderers/event.dart";
|
||||||
|
import "package:super_sliver_list/super_sliver_list.dart";
|
||||||
|
|
||||||
|
class const NotificationsPage({
|
||||||
|
final String? highlightedEventId,
|
||||||
|
final bool defaultToAllNotifications = false,
|
||||||
|
super.key,
|
||||||
|
}) extends HookConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final options = <M3EToggleButtonGroupAction, UnreadType>{
|
||||||
|
M3EToggleButtonGroupAction(
|
||||||
|
checkedLabel: Text("Mentions"),
|
||||||
|
icon: Icon(Icons.alternate_email),
|
||||||
|
): .highlight,
|
||||||
|
M3EToggleButtonGroupAction(
|
||||||
|
checkedLabel: Text("All Notifications"),
|
||||||
|
icon: Icon(Icons.notifications),
|
||||||
|
): .notify,
|
||||||
|
};
|
||||||
|
final unreadTypeIndex = useState(defaultToAllNotifications ? 1 : 0);
|
||||||
|
|
||||||
|
final highlightedId = useState(highlightedEventId);
|
||||||
|
final listController = useRef(ListController());
|
||||||
|
final scrollController = useScrollController();
|
||||||
|
|
||||||
|
final provider = NotificationsController.provider((
|
||||||
|
options.values.toList()[unreadTypeIndex.value],
|
||||||
|
null,
|
||||||
|
));
|
||||||
|
final notifications = ref.watch(provider);
|
||||||
|
final notifier = ref.watch(provider.notifier);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
if (highlightedId.value == null) return null;
|
||||||
|
Timer? timer;
|
||||||
|
|
||||||
|
void listener() =>
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
if (!listController.value.isAttached) return;
|
||||||
|
|
||||||
|
final notifications = await ref.watch(provider.future);
|
||||||
|
|
||||||
|
final index = notifications.indexWhere(
|
||||||
|
(element) => element.eventId == highlightedId.value!,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (index == -1) return;
|
||||||
|
|
||||||
|
listController.value.animateToItem(
|
||||||
|
index: index,
|
||||||
|
scrollController: scrollController,
|
||||||
|
alignment: 0.5,
|
||||||
|
duration: (_) => .new(milliseconds: 700),
|
||||||
|
curve: (_) => Curves.easeInOut,
|
||||||
|
);
|
||||||
|
timer = Timer(.new(seconds: 1), () {
|
||||||
|
highlightedId.value = null;
|
||||||
|
});
|
||||||
|
listController.value.removeListener(listener);
|
||||||
|
});
|
||||||
|
|
||||||
|
listController.value.addListener(listener);
|
||||||
|
return timer?.cancel;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
Future<void> listener() async {
|
||||||
|
if (!scrollController.hasClients || notifications.isLoading) return;
|
||||||
|
|
||||||
|
if (scrollController.position.pixels >=
|
||||||
|
scrollController.position.maxScrollExtent) {
|
||||||
|
await notifier.loadOlder();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollController.addListener(listener);
|
||||||
|
return () => scrollController.removeListener(listener);
|
||||||
|
}, [scrollController, notifications]);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: Appbar(title: Text("Notifications")),
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
if (notifications is AsyncLoading && notifications.value != null)
|
||||||
|
const LinearProgressIndicator(minHeight: 2),
|
||||||
|
Expanded(
|
||||||
|
child: switch (notifications) {
|
||||||
|
AsyncData(:final value) || AsyncLoading(:final value?) =>
|
||||||
|
value.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
"No notifications yet",
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: SuperListView.builder(
|
||||||
|
listController: listController.value,
|
||||||
|
controller: scrollController,
|
||||||
|
itemCount: value.length,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
reverse: true,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final event = value[index];
|
||||||
|
final isHighlighted =
|
||||||
|
event.eventId == highlightedId.value;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: .only(top: 8),
|
||||||
|
child: HighlightWrapper(
|
||||||
|
InkWell(
|
||||||
|
onTap: () {
|
||||||
|
// TODO: Jump to event
|
||||||
|
},
|
||||||
|
child: EventRenderer(event),
|
||||||
|
),
|
||||||
|
isHighlighted: isHighlighted,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
AsyncLoading() => const Loading(),
|
||||||
|
AsyncError(:final error, :final stackTrace) => ErrorDialog(
|
||||||
|
error,
|
||||||
|
stackTrace,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: .topRight,
|
||||||
|
child: Padding(
|
||||||
|
padding: .all(16),
|
||||||
|
child: M3EToggleButtonGroup(
|
||||||
|
selectedIndex: unreadTypeIndex.value,
|
||||||
|
onSelectedIndexChanged: (index) =>
|
||||||
|
unreadTypeIndex.value = index ?? unreadTypeIndex.value,
|
||||||
|
actions: options.keys.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
236
lib/widgets/pages/select_server.dart
Normal file
236
lib/widgets/pages/select_server.dart
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
import "package:app_links/app_links.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
import "package:flutter_svg/flutter_svg.dart";
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
|
import "package:nexus/controllers/auth_url.dart";
|
||||||
|
import "package:nexus/controllers/client.dart";
|
||||||
|
import "package:nexus/controllers/client_id.dart";
|
||||||
|
import "package:nexus/helpers/launch_helper.dart";
|
||||||
|
import "package:nexus/main.dart";
|
||||||
|
import "package:nexus/models/homeserver.dart";
|
||||||
|
import "package:nexus/widgets/pages/settings.dart";
|
||||||
|
import "package:nexus/widgets/appbar.dart";
|
||||||
|
import "package:nexus/widgets/divider_text.dart";
|
||||||
|
|
||||||
|
class const SelectServerPage({super.key}) extends HookConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
final launch = ref.watch(LaunchHelper.provider).launchUrl;
|
||||||
|
|
||||||
|
final isLoading = useState(false);
|
||||||
|
final homeserverUrl = useTextEditingController();
|
||||||
|
|
||||||
|
Future<void> setHomeserver(Uri? newHomeserver) async {
|
||||||
|
if (newHomeserver == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
"Failed to parse homeserver URL. Are you sure you typed it correctly?",
|
||||||
|
style: .new(color: theme.colorScheme.onErrorContainer),
|
||||||
|
),
|
||||||
|
backgroundColor: theme.colorScheme.errorContainer,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
isLoading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (newHomeserver?.hasScheme == false) {
|
||||||
|
newHomeserver = Uri.https(newHomeserver!.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
final newUrl = await ref
|
||||||
|
.read(ClientController.provider.notifier)
|
||||||
|
.discoverHomeserver(newHomeserver!);
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
Future<void> tryLogin(Uri url) async {
|
||||||
|
final codeResponse = await ref.watch(
|
||||||
|
AuthUrlController.provider(url).future,
|
||||||
|
);
|
||||||
|
|
||||||
|
await ref.watch(LaunchHelper.provider).launchUrl(codeResponse.url);
|
||||||
|
|
||||||
|
AppLinks().uriLinkStream.listen((encodedUri) async {
|
||||||
|
final state = encodedUri.queryParameters["state"];
|
||||||
|
if (state != codeResponse.state) return;
|
||||||
|
isLoading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final code = encodedUri.queryParameters["code"];
|
||||||
|
|
||||||
|
if (code != null) {
|
||||||
|
await ref
|
||||||
|
.watch(ClientController.provider.notifier)
|
||||||
|
.exchangeToken(
|
||||||
|
.new(
|
||||||
|
homeserverUrl: url,
|
||||||
|
codeVerifier: codeResponse.codeVerifier,
|
||||||
|
redirectUri: .new(
|
||||||
|
scheme: "nexus.federated.nexus",
|
||||||
|
path: "/",
|
||||||
|
),
|
||||||
|
code: code,
|
||||||
|
clientId: await ref.watch(
|
||||||
|
ClientIdController.provider(url).future,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
showError(error, stackTrace);
|
||||||
|
rethrow;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newUrl == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
"Homeserver verification failed.",
|
||||||
|
style: .new(color: theme.colorScheme.onErrorContainer),
|
||||||
|
),
|
||||||
|
action: SnackBarAction(
|
||||||
|
onPressed: () => tryLogin(newHomeserver!).onError(showError),
|
||||||
|
label: "Attempt log in anyways",
|
||||||
|
textColor: theme.colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
backgroundColor: theme.colorScheme.errorContainer,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await tryLogin(newUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
showError(error, stackTrace);
|
||||||
|
rethrow;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: Appbar(
|
||||||
|
actions: .new([
|
||||||
|
IconButton(
|
||||||
|
onPressed: () =>
|
||||||
|
showDialog(context: context, builder: (_) => SettingsPage()),
|
||||||
|
icon: Icon(Icons.settings),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: .new(maxWidth: 600),
|
||||||
|
child: ListView(
|
||||||
|
padding: .symmetric(vertical: 8, horizontal: 12),
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
SvgPicture.asset("assets/bundled/icon.svg", width: 128),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: .start,
|
||||||
|
children: [
|
||||||
|
Text("Nexus", style: theme.textTheme.displayMedium),
|
||||||
|
Text(
|
||||||
|
"A Simple Matrix Client",
|
||||||
|
style: theme.textTheme.headlineMedium,
|
||||||
|
overflow: .ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Padding(padding: .symmetric(vertical: 12), child: Divider()),
|
||||||
|
DividerText("Enter a homeserver domain:"),
|
||||||
|
Row(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
textInputAction: .done,
|
||||||
|
autofocus: true,
|
||||||
|
onSubmitted: (text) => setHomeserver(.tryParse(text)),
|
||||||
|
controller: homeserverUrl,
|
||||||
|
decoration: .new(
|
||||||
|
labelText: "Homeserver URL",
|
||||||
|
hintText: "matrix.org",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton.filled(
|
||||||
|
tooltip: "Confirm homeserver choice",
|
||||||
|
onPressed: isLoading.value
|
||||||
|
? null
|
||||||
|
: () => setHomeserver(.tryParse(homeserverUrl.text)),
|
||||||
|
icon: Icon(Icons.check),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
DividerText("Or, choose from some popular homeservers:"),
|
||||||
|
...(<Homeserver>[
|
||||||
|
.new(
|
||||||
|
name: "Matrix.org",
|
||||||
|
description: "The Matrix.org Foundation offers the matrix.org homeserver as an easy entry point for anyone wanting to try out Matrix.",
|
||||||
|
url: .https("matrix.org"),
|
||||||
|
iconUrl:
|
||||||
|
"https://raw.githubusercontent.com/element-hq/logos/refs/heads/master/matrix/matrix-favicon${Theme.brightnessOf(context) == Brightness.dark ? "-white" : ""}.png",
|
||||||
|
),
|
||||||
|
.new(
|
||||||
|
name: "Federated Nexus",
|
||||||
|
description: "Federated Nexus is a community resource hosting multiple FOSS (especially federated) services, including Matrix and Forgejo. By the same developers who made Nexus client.",
|
||||||
|
url: .https("federated.nexus"),
|
||||||
|
iconUrl: "https://federated.nexus/images/icon.png",
|
||||||
|
),
|
||||||
|
.new(
|
||||||
|
name: "Unredacted",
|
||||||
|
description: "Unredacted is a 501(c)(3) non-profit organization that builds Internet infrastructure and services to help people evade censorship and protect their right to privacy.",
|
||||||
|
url: .https("unredacted.org", "services/si/matrix"),
|
||||||
|
iconUrl: "https://unredacted.org/favicon.ico",
|
||||||
|
),
|
||||||
|
].map(
|
||||||
|
(homeserver) => Card(
|
||||||
|
child: ListTile(
|
||||||
|
enabled: !isLoading.value,
|
||||||
|
title: Text(homeserver.name),
|
||||||
|
leading: Image.network(
|
||||||
|
homeserver.iconUrl,
|
||||||
|
errorBuilder: (_, _, _) => SizedBox.shrink(),
|
||||||
|
height: 32,
|
||||||
|
),
|
||||||
|
subtitle: Text(homeserver.description),
|
||||||
|
onTap: isLoading.value
|
||||||
|
? null
|
||||||
|
: () => setHomeserver(homeserver.url),
|
||||||
|
trailing: IconButton(
|
||||||
|
tooltip: "Launch homeserver info page",
|
||||||
|
onPressed: () => launch(homeserver.url),
|
||||||
|
icon: Icon(Icons.info_outline),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => launch(.https("servers.joinmatrix.org")),
|
||||||
|
child: Text("See more homeservers..."),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
247
lib/widgets/pages/settings.dart
Normal file
247
lib/widgets/pages/settings.dart
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
import "package:collection/collection.dart";
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
|
import "package:m3e_card_list/m3e_card_list.dart";
|
||||||
|
import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
|
||||||
|
import "package:nexus/controllers/settings_sections.dart";
|
||||||
|
import "package:nexus/helpers/extensions/better_when.dart";
|
||||||
|
import "package:nexus/helpers/extensions/show_about_dialog.dart";
|
||||||
|
import "package:nexus/widgets/pages/settings_category.dart";
|
||||||
|
import "package:nexus/widgets/divider_text.dart";
|
||||||
|
import "package:nexus/widgets/highlight_wrapper.dart";
|
||||||
|
import "package:super_sliver_list/super_sliver_list.dart";
|
||||||
|
|
||||||
|
class const SettingsPage({super.key}) extends ConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder(
|
||||||
|
builder: (_, constraints) => HookBuilder(
|
||||||
|
builder: (context) {
|
||||||
|
final categoriesArePages = constraints.maxWidth < 550;
|
||||||
|
|
||||||
|
final selected = useState(0);
|
||||||
|
|
||||||
|
final highlightedMatch = useState<(int, int)?>(null);
|
||||||
|
final listController = useRef(ListController());
|
||||||
|
final scrollController = useScrollController();
|
||||||
|
|
||||||
|
final searchBar = SearchAnchor.bar(
|
||||||
|
barHintText: "Search...",
|
||||||
|
isFullScreen: categoriesArePages,
|
||||||
|
suggestionsBuilder: (suggestionsContext, controller) async {
|
||||||
|
final categories = await ref.watch(
|
||||||
|
SettingsSectionsController.provider.future,
|
||||||
|
);
|
||||||
|
final query = controller.text.toLowerCase();
|
||||||
|
|
||||||
|
final matches = categories.values.flattenedToList
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.expand(
|
||||||
|
(categoryEntry) => categoryEntry.value.settings
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.where(
|
||||||
|
(settingEntry) =>
|
||||||
|
settingEntry.value.title.toLowerCase().contains(
|
||||||
|
query,
|
||||||
|
) ||
|
||||||
|
settingEntry.value.description
|
||||||
|
.toLowerCase()
|
||||||
|
.contains(query),
|
||||||
|
)
|
||||||
|
.map(
|
||||||
|
(settingEntry) => (
|
||||||
|
(categoryEntry.key, settingEntry.key),
|
||||||
|
settingEntry.value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toIList();
|
||||||
|
|
||||||
|
return matches.map(
|
||||||
|
(match) => ListTile(
|
||||||
|
onTap: () async {
|
||||||
|
if (context.mounted) Navigator.of(suggestionsContext).pop();
|
||||||
|
controller.text = "";
|
||||||
|
|
||||||
|
if (categoriesArePages) {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => SettingsCategoryPage(
|
||||||
|
match.$1.$1,
|
||||||
|
initialHighlight: match.$1.$2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
selected.value = match.$1.$1;
|
||||||
|
listController.value.animateToItem(
|
||||||
|
index: match.$1.$2,
|
||||||
|
scrollController: scrollController,
|
||||||
|
alignment: 0.5,
|
||||||
|
duration: (_) => .new(milliseconds: 700),
|
||||||
|
curve: (_) => Curves.easeInOut,
|
||||||
|
);
|
||||||
|
highlightedMatch.value = match.$1;
|
||||||
|
await Future.delayed(.new(seconds: 1), () {
|
||||||
|
if (highlightedMatch.value == match.$1) {
|
||||||
|
highlightedMatch.value = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
leading: Icon(match.$2.icon),
|
||||||
|
title: Text(match.$2.title),
|
||||||
|
subtitle: Text(match.$2.description),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final settingsContent = Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text("Settings"),
|
||||||
|
actionsPadding: .symmetric(horizontal: 12),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => context.showAboutDialog(ref),
|
||||||
|
icon: Icon(Icons.info_outline),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: ref
|
||||||
|
.watch(SettingsSectionsController.provider)
|
||||||
|
.betterWhen(
|
||||||
|
data: (sections) => categoriesArePages
|
||||||
|
? CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(12).copyWith(bottom: 8),
|
||||||
|
child: searchBar,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...sections
|
||||||
|
.mapTo(
|
||||||
|
(section, categories) => [
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
).copyWith(bottom: 4),
|
||||||
|
child: DividerText(section),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverM3ECardList(
|
||||||
|
padding: .symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
margin: .symmetric(horizontal: 12),
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primaryContainer,
|
||||||
|
itemCount: categories.length,
|
||||||
|
onTap: (index) => Navigator.of(context)
|
||||||
|
.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) =>
|
||||||
|
SettingsCategoryPage(
|
||||||
|
sections
|
||||||
|
.values
|
||||||
|
.flattenedToList
|
||||||
|
.indexOf(
|
||||||
|
categories[index],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
itemBuilder: (context, index) => ListTile(
|
||||||
|
leading: Icon(categories[index].icon),
|
||||||
|
title: Text(categories[index].title),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.flattened,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
|
children: [
|
||||||
|
MaterialUiCompatibilityBridge(
|
||||||
|
child: NavigationRailM3E(
|
||||||
|
type: .alwaysExpand,
|
||||||
|
trailing: searchBar,
|
||||||
|
scrollable: true,
|
||||||
|
sections: sections
|
||||||
|
.mapTo(
|
||||||
|
(
|
||||||
|
categoryGroup,
|
||||||
|
categories,
|
||||||
|
) => NavigationRailM3ESection(
|
||||||
|
header: DividerText(categoryGroup),
|
||||||
|
destinations: categories
|
||||||
|
.map(
|
||||||
|
(category) =>
|
||||||
|
NavigationRailM3EDestination(
|
||||||
|
icon: Icon(category.icon),
|
||||||
|
label: category.title,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
selectedIndex: selected.value,
|
||||||
|
onDestinationSelected: (value) =>
|
||||||
|
selected.value = value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
VerticalDivider(),
|
||||||
|
Expanded(
|
||||||
|
child: SuperListView(
|
||||||
|
listController: listController.value,
|
||||||
|
controller: scrollController,
|
||||||
|
padding: .symmetric(vertical: 12),
|
||||||
|
children: sections
|
||||||
|
.values
|
||||||
|
.flattenedToList[selected.value]
|
||||||
|
.settings
|
||||||
|
.mapIndexed(
|
||||||
|
(index, setting) => Padding(
|
||||||
|
padding: .only(bottom: 4),
|
||||||
|
child: HighlightWrapper(
|
||||||
|
setting.builder(
|
||||||
|
setting.title,
|
||||||
|
setting.description,
|
||||||
|
setting.icon,
|
||||||
|
),
|
||||||
|
isHighlighted:
|
||||||
|
highlightedMatch.value ==
|
||||||
|
(selected.value, index),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return constraints.maxWidth < 650
|
||||||
|
? settingsContent
|
||||||
|
: Dialog(
|
||||||
|
constraints: .loose(Size(900, 600)),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadiusGeometry.circular(12),
|
||||||
|
child: settingsContent,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
79
lib/widgets/pages/settings_category.dart
Normal file
79
lib/widgets/pages/settings_category.dart
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import "dart:async";
|
||||||
|
|
||||||
|
import "package:collection/collection.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
|
import "package:nexus/controllers/settings_sections.dart";
|
||||||
|
import "package:nexus/helpers/extensions/better_when.dart";
|
||||||
|
import "package:nexus/widgets/highlight_wrapper.dart";
|
||||||
|
import "package:super_sliver_list/super_sliver_list.dart";
|
||||||
|
|
||||||
|
class const SettingsCategoryPage(
|
||||||
|
final int index, {
|
||||||
|
final int? initialHighlight,
|
||||||
|
super.key,
|
||||||
|
}) extends HookConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final highlight = useState<int?>(initialHighlight);
|
||||||
|
final listController = useRef(ListController());
|
||||||
|
final scrollController = useScrollController();
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
if (initialHighlight == null) return null;
|
||||||
|
Timer? timer;
|
||||||
|
|
||||||
|
void listener() => WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!listController.value.isAttached) return;
|
||||||
|
|
||||||
|
listController.value.animateToItem(
|
||||||
|
index: initialHighlight!,
|
||||||
|
scrollController: scrollController,
|
||||||
|
alignment: 0.5,
|
||||||
|
duration: (_) => .new(milliseconds: 700),
|
||||||
|
curve: (_) => Curves.easeInOut,
|
||||||
|
);
|
||||||
|
timer = Timer(.new(seconds: 1), () {
|
||||||
|
highlight.value = null;
|
||||||
|
});
|
||||||
|
listController.value.removeListener(listener);
|
||||||
|
});
|
||||||
|
|
||||||
|
listController.value.addListener(listener);
|
||||||
|
return timer?.cancel;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return ref
|
||||||
|
.watch(SettingsSectionsController.provider)
|
||||||
|
.betterWhen(
|
||||||
|
data: (sections) => Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(sections.values.flattenedToList[index].title),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: SuperListView(
|
||||||
|
controller: scrollController,
|
||||||
|
listController: listController.value,
|
||||||
|
padding: .symmetric(vertical: 12, horizontal: 8),
|
||||||
|
children: sections.values.flattenedToList[index].settings
|
||||||
|
.mapIndexed(
|
||||||
|
(index, setting) => Padding(
|
||||||
|
padding: .only(bottom: 4),
|
||||||
|
child: HighlightWrapper(
|
||||||
|
setting.builder(
|
||||||
|
setting.title,
|
||||||
|
setting.description,
|
||||||
|
setting.icon,
|
||||||
|
),
|
||||||
|
isHighlighted: highlight.value == index,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
82
lib/widgets/pages/verify.dart
Normal file
82
lib/widgets/pages/verify.dart
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
|
import "package:nexus/controllers/client.dart";
|
||||||
|
import "package:nexus/widgets/pages/settings.dart";
|
||||||
|
import "package:nexus/widgets/appbar.dart";
|
||||||
|
import "package:nexus/helpers/required_validator_helper.dart";
|
||||||
|
|
||||||
|
class const VerifyPage({super.key}) extends HookConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final passphraseController = useTextEditingController();
|
||||||
|
final isLoading = useState(false);
|
||||||
|
final inputError = useState<String?>(null);
|
||||||
|
final formKey = useRef(GlobalKey<FormState>());
|
||||||
|
|
||||||
|
Future<void> verify() async {
|
||||||
|
isLoading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (formKey.value.currentState?.validate() != true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
inputError.value = await ref
|
||||||
|
.watch(ClientController.provider.notifier)
|
||||||
|
.verify(passphraseController.text);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: Appbar(
|
||||||
|
actions: .new([
|
||||||
|
IconButton(
|
||||||
|
onPressed: () =>
|
||||||
|
showDialog(context: context, builder: (_) => SettingsPage()),
|
||||||
|
icon: Icon(Icons.settings),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
body: AlertDialog(
|
||||||
|
title: Text("Verify"),
|
||||||
|
content: Form(
|
||||||
|
key: formKey.value,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: .min,
|
||||||
|
crossAxisAlignment: .start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"Enter your recovery key or passphrase below to unlock encrypted events.\nYour passphrase is usually not the same as your password.",
|
||||||
|
),
|
||||||
|
SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
autofocus: true,
|
||||||
|
controller: passphraseController,
|
||||||
|
textInputAction: .done,
|
||||||
|
autovalidateMode: .onUserInteraction,
|
||||||
|
validator: requiredValidator,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: .new(
|
||||||
|
label: Text("Recovery Key or Passphrase"),
|
||||||
|
errorText: inputError.value,
|
||||||
|
),
|
||||||
|
onFieldSubmitted: (_) => verify(),
|
||||||
|
// Don't defocus on submit
|
||||||
|
onEditingComplete: () {},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: isLoading.value ? null : verify,
|
||||||
|
child: Text("Verify"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,8 +6,8 @@ import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
|
||||||
import "package:nexus/controllers/key.dart";
|
import "package:nexus/controllers/key.dart";
|
||||||
import "package:nexus/controllers/spaces.dart";
|
import "package:nexus/controllers/spaces.dart";
|
||||||
import "package:nexus/models/room.dart";
|
import "package:nexus/models/room.dart";
|
||||||
import "package:nexus/pages/notifications.dart";
|
import "package:nexus/widgets/pages/notifications.dart";
|
||||||
import "package:nexus/pages/settings.dart";
|
import "package:nexus/widgets/pages/settings.dart";
|
||||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||||
import "package:nexus/widgets/divider_widget.dart";
|
import "package:nexus/widgets/divider_widget.dart";
|
||||||
import "package:nexus/widgets/join_dialog.dart";
|
import "package:nexus/widgets/join_dialog.dart";
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue