reorganize pages into being under widgets

This commit is contained in:
Henry Hiles 2026-09-26 18:06:51 -04:00
commit 9f070fcdba
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
9 changed files with 10 additions and 10 deletions

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

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

View 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..."),
),
],
),
),
),
),
);
}
}

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

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

View 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"),
),
],
),
);
}
}