reorganize and rename widgets

This commit is contained in:
Henry Hiles 2026-11-09 17:25:02 -05:00
commit d5f29dc344
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
14 changed files with 568 additions and 547 deletions

View file

@ -0,0 +1,133 @@
import "package:collection/collection.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
import "package:nexus/controllers/accounts.dart";
import "package:nexus/controllers/active_account.dart";
import "package:nexus/helpers/extensions/log_out.dart";
import "package:nexus/main.dart";
import "package:nexus/models/account.dart";
import "package:nexus/widgets/avatars/avatar_or_hash.dart";
/// Switches between, adds and logs out of accounts. With [onlyWithOthers], it's
/// hidden when there's only one account.
class const AccountSwitcher({final bool onlyWithOthers = false, super.key})
extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final danger = Theme.of(context).colorScheme.error;
final accounts = ref.watch(AccountsController.provider).value;
final activeId = ref.watch(ActiveAccountController.provider);
if (accounts == null || (onlyWithOthers && accounts.length < 2)) {
return const SizedBox.shrink();
}
String nameOf(Account account) =>
account.displayName ?? account.userId ?? "New account";
Widget avatarOf(Account account, double dimension) => AvatarOrHash(
account.avatar,
nameOf(account),
dimension: dimension,
fallback: account.userId == null
? Icon(Icons.account_circle, size: dimension)
: null,
);
final active = accounts.firstWhereOrNull(
(account) => account.id == activeId,
);
void switchTo(String accountId) => ref
.read(ActiveAccountController.provider.notifier)
.switchTo(accountId)
.onError(showError);
final loggingOut = ref.watch(LogOut.mutation).isPending;
return AdaptiveMenuButton<void>(
tooltip: loggingOut ? "Logging out..." : "Switch account",
enabled: !loggingOut,
icon: loggingOut
? const SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(),
)
: active == null
? const Icon(Icons.account_circle)
: avatarOf(active, 28),
itemBuilder: (_) => [
for (final account in accounts)
PopupMenuItem(
onTap: () => switchTo(account.id),
child: ListTile(
selected: account.id == activeId,
leading: avatarOf(account, 32),
title: Text(nameOf(account)),
subtitle: switch (account) {
Account(displayName: _?, :final userId?) => Text(userId),
_ => null,
},
trailing: account.userId == null
? null
// Inside the menu, so it can close it
: Builder(
builder: (menuContext) => IconButton(
tooltip: "Log out",
icon: Icon(Icons.logout, color: danger),
onPressed: () async {
Navigator.of(menuContext).pop();
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text("Log out of ${nameOf(account)}?"),
content: Text(
account.id == activeId
? "You'll be switched to another account, or returned to the login page if this is your only one."
: "You'll stop receiving notifications for this account.",
),
actions: [
TextButton(
onPressed: () =>
Navigator.of(dialogContext).pop(false),
child: const Text("Cancel"),
),
TextButton(
onPressed: () =>
Navigator.of(dialogContext).pop(true),
style: TextButton.styleFrom(
foregroundColor: danger,
),
child: const Text("Log out"),
),
],
),
);
if (confirmed != true) return;
await ref.logOut(account.id).onError(showError);
},
),
),
),
),
const PopupMenuDivider(),
PopupMenuItem(
onTap: () {
final id = DateTime.now().millisecondsSinceEpoch.toString();
ref
.read(AccountsController.provider.notifier)
.save(.new(id: id))
.then((_) => switchTo(id))
.onError(showError);
},
child: const ListTile(
leading: Icon(Icons.person_add),
title: Text("Add account"),
),
),
],
);
}
}

View file

@ -0,0 +1,96 @@
import "dart:io";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/settings.dart";
import "package:window_manager/window_manager.dart";
final class const Appbar({
final Widget? leading,
final Widget? title,
final Color? backgroundColor,
final double? scrolledUnderElevation,
final IList<Widget> actions = const .empty(),
final VoidCallback? onTap,
/// Whether to show the minimize, maximize and close buttons on desktop.
final bool windowControls = true,
super.key,
}) extends ConsumerWidget implements PreferredSizeWidget {
@override
Size get preferredSize => const .fromHeight(kToolbarHeight);
@override
Widget build(BuildContext context, WidgetRef ref) {
final isDesktop = !(Platform.isAndroid || Platform.isIOS);
void maximize() {
final window = WindowManager.instance.getCurrent();
if (window == null) return;
if (window.isMaximized) {
return window.unmaximize();
}
return window.maximize();
}
return GestureDetector(
onPanStart: ref
.watch(SettingsController.provider)
.whenOrNull(
data: (settings) => !isDesktop || settings.linuxMobileMode
? null
: (_) => WindowManager.instance.getCurrent()?.startDragging(),
),
child: AppBar(
leading: leading == null ? null : InkWell(onTap: onTap, child: leading),
backgroundColor: backgroundColor,
scrolledUnderElevation: scrolledUnderElevation,
actionsPadding: const .symmetric(horizontal: 8),
title: InkWell(
onTap: onTap,
child: IgnorePointer(child: title),
),
flexibleSpace: GestureDetector(
onDoubleTap: isDesktop ? maximize : null,
),
actions: [
...actions,
if (windowControls && isDesktop) ...[
if (ref
.watch(SettingsController.provider)
.whenOrNull(
data: (settings) => settings.showMinimizeButton,
) ??
false)
IconButton(
tooltip: "Minimize window",
onPressed: () =>
WindowManager.instance.getCurrent()?.minimize(),
icon: const Icon(Icons.minimize),
),
if (!Platform.isLinux)
IconButton(
tooltip: "Maximize window",
onPressed: maximize,
icon: const Icon(Icons.fullscreen),
),
if (ref
.watch(SettingsController.provider)
.whenOrNull(
data: (settings) => !settings.linuxMobileMode,
) ??
true)
IconButton(
tooltip: "Close window",
onPressed: () => exit(0),
icon: const Icon(Icons.close),
),
],
],
),
);
}
}

View file

@ -0,0 +1,41 @@
import "dart:math";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:material_ui/material_ui.dart";
/// Three dots that bounce one after another, in a loop.
class const BouncingDots({required final Color color, super.key})
extends HookWidget {
@override
Widget build(BuildContext context) {
final controller = useAnimationController(
duration: const .new(milliseconds: 1200),
);
useEffect(() {
controller.repeat();
return null;
}, []);
final progress = useAnimation(controller);
return Row(
mainAxisSize: .min,
spacing: 3,
children: [0, 1, 2].map((index) {
final phase = (progress - index * 0.15) % 1;
final bounce = phase < 0.4 ? sin(phase / 0.4 * pi) : 0.0;
return Transform.translate(
offset: .new(0, -3 * bounce),
child: Opacity(
opacity: 0.4 + 0.6 * bounce,
child: Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: color, shape: .circle),
),
),
);
}).toList(),
);
}
}

View file

@ -0,0 +1,16 @@
import "package:material_ui/material_ui.dart";
/// Shows [count] in a badge on [child]: nothing if null, a dot if 0, and the
/// number otherwise.
class const CountBadge(
final int? count, {
required final Widget child,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => switch (count) {
null => child,
0 => Badge(child: child),
final count => Badge.count(count: count, child: child),
};
}

View file

@ -0,0 +1,9 @@
import "package:material_ui/material_ui.dart";
import "package:nexus/widgets/common/divider_widget.dart";
final class const DividerText(final String text, {super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) =>
DividerWidget(Text(text, style: Theme.of(context).textTheme.labelLarge));
}

View file

@ -0,0 +1,23 @@
import "package:material_ui/material_ui.dart";
final class const DividerWidget(final Widget widget, {super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (_, constraints) => Row(
children: [
SizedBox(
width: 16,
child: Divider(color: Theme.of(context).colorScheme.onSurface),
),
ConstrainedBox(
constraints: .new(maxWidth: constraints.maxWidth - 32),
child: Padding(padding: const .all(8), child: widget),
),
Expanded(
child: Divider(color: Theme.of(context).colorScheme.onSurface),
),
],
),
);
}

View file

@ -0,0 +1,35 @@
import "package:material_ui/material_ui.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flutter_riverpod/misc.dart";
final class const ErrorDialog(
final Object error,
final StackTrace? stackTrace, {
final ProviderOrFamily? provider,
super.key,
}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return AlertDialog(
title: Text("An Error Occurred"),
content: SingleChildScrollView(
child: SelectableText(
"$error${stackTrace == null ? "" : "\n\n$stackTrace"}",
),
),
actions: [
if (provider != null)
TextButton(
onPressed: () => ref.invalidate(provider!),
child: const Text("Try Again"),
),
if (Navigator.of(context).canPop())
TextButton(
onPressed: () =>
Navigator.of(context).popUntil((route) => route.isFirst),
child: const Text("Go Back"),
),
],
);
}
}

View file

@ -0,0 +1,28 @@
import "package:material_ui/material_ui.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/helpers/extensions/show_image_viewer.dart";
import "package:nexus/models/requests/download_media.dart";
final class const ExpandableImage(
final DownloadMediaRequest? request, {
required final Widget child,
/// If set, a download button is shown, suggesting this filename
final String? downloadFilename,
final int? size,
super.key,
}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => InkWell(
onTap: switch (request) {
final request? => () => context.showImageViewer(
ref,
request,
downloadFilename: downloadFilename,
size: size,
),
null => null,
},
child: child,
);
}

View file

@ -0,0 +1,20 @@
import "package:material_ui/material_ui.dart";
final class const HighlightWrapper(
final Widget child, {
final bool isHighlighted = false,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => ClipRRect(
borderRadius: .all(.circular(12)),
child: AnimatedContainer(
padding: isHighlighted ? .all(8) : .all(0),
color: isHighlighted
? Theme.of(context).colorScheme.onSurface.withAlpha(50)
: Colors.transparent,
duration: Durations.medium1,
child: Material(color: Colors.transparent, child: child),
),
);
}

View file

@ -0,0 +1,49 @@
import "package:flutter/gestures.dart";
import "package:material_ui/material_ui.dart";
import "package:linkify/linkify.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/helpers/extensions/get_link_info.dart";
import "package:nexus/helpers/launch_helper.dart";
import "package:nexus/helpers/matrix_linkifier.dart";
import "package:nexus/widgets/html/mention_chip.dart";
final class const LinkifiedText(
final String text, {
final String? roomId,
final int? maxLines,
final TextStyle? style,
super.key,
}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => Text.rich(
TextSpan(
style: style,
children: linkifyText(text)
.map<InlineSpan>(
(element) => switch (element) {
LinkableElement(:final url) when url.linkInfo != null =>
WidgetSpan(
alignment: .baseline,
baseline: .alphabetic,
child: MentionChip(url, roomId),
),
LinkableElement(:final text, :final url) => TextSpan(
text: text,
style: .new(
color: Theme.of(context).colorScheme.primary,
decoration: .underline,
),
mouseCursor: SystemMouseCursors.click,
recognizer: TapGestureRecognizer()
..onTap = () =>
ref.read(LaunchHelper.provider).launchUrl(.parse(url)),
),
_ => TextSpan(text: element.text),
},
)
.toList(),
),
maxLines: maxLines,
overflow: maxLines == null ? null : .ellipsis,
);
}

View file

@ -0,0 +1,15 @@
import "package:material_ui/material_ui.dart";
class const Loading({super.key, final double? dimension})
extends StatelessWidget {
@override
Widget build(BuildContext context) => Center(
child: Padding(
padding: .all(16),
child: SizedBox.square(
dimension: dimension,
child: CircularProgressIndicator(),
),
),
);
}

View file

@ -0,0 +1,36 @@
import "package:material_ui/material_ui.dart";
import "package:m3e_design/m3e_design.dart";
// Needed for navigation_rail_m3e (#65).
import "package:flutter/material.dart" as old_mat;
/// Themes a navigation_rail_m3e rail, which still uses the old Material theme.
class const M3ERailTheme({
required final WidgetBuilder builder,
final List<old_mat.ThemeExtension> extensions = const [],
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => MaterialUiCompatibilityBridge(
child: Builder(
builder: (context) {
final theme = old_mat.Theme.of(context);
return old_mat.Theme(
data: theme.copyWith(
extensions: [
...extensions,
// Otherwise the FAB label falls back to Roboto
M3ETheme.defaults(theme.colorScheme).copyWith(
typography: .defaultFor(
theme.brightness,
baseOverride: theme.textTheme,
),
),
],
),
child: Builder(builder: builder),
);
},
),
);
}

View file

@ -0,0 +1,85 @@
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/sync_status.dart";
import "package:nexus/widgets/common/error_dialog.dart";
import "package:timeago/timeago.dart";
class const SyncStatusBanner({super.key}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final status = ref.watch(SyncStatusController.provider);
final colorScheme = Theme.of(context).colorScheme;
final banner = switch (status?.type) {
null || .ok => null,
.waiting => (
title: "Waiting for sync...",
icon: Icons.sync,
background: colorScheme.primary,
foreground: colorScheme.onPrimary,
),
.erroring => (
title: "Sync is failing, retrying...",
icon: Icons.sync_problem,
background: colorScheme.error,
foreground: colorScheme.onError,
),
.permanentlyFailed => (
title: "Sync has permanently failed",
icon: Icons.cloud_off,
background: colorScheme.error,
foreground: colorScheme.onError,
),
};
final details = status == null
? []
: [
?status.error,
if (status.errorCount > 0) "Failed attempts: ${status.errorCount}.",
if (status.lastSync case final lastSync?)
"Last synced ${format(lastSync)}.",
];
return AnimatedSize(
duration: Durations.short4,
alignment: .topCenter,
child: banner == null
? const SizedBox(width: double.infinity)
: Material(
color: banner.background,
child: InkWell(
onTap: details.isEmpty
? null
: () => showDialog(
context: context,
builder: (context) =>
ErrorDialog(details.join("\n\n"), null),
),
child: Padding(
padding: const .symmetric(horizontal: 16, vertical: 8),
child: Row(
spacing: 12,
children: [
Icon(banner.icon, size: 20, color: banner.foreground),
Expanded(
child: Text(
banner.title,
maxLines: 1,
overflow: .ellipsis,
style: .new(
color: banner.foreground,
fontWeight: .bold,
),
),
),
if (details.isNotEmpty)
Icon(Icons.info_outline, color: banner.foreground),
],
),
),
),
),
);
}
}

View file

@ -0,0 +1,17 @@
import "package:material_ui/material_ui.dart";
import "package:timeago/timeago.dart";
class const Timestamp(final DateTime timestamp, {super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) => Tooltip(
message: timestamp.toString(),
child: Text(
format(timestamp),
maxLines: 1,
overflow: .ellipsis,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(color: Colors.grey),
),
);
}