reorganize and rename widgets
This commit is contained in:
parent
05d25869ef
commit
d5f29dc344
102 changed files with 568 additions and 547 deletions
42
lib/widgets/avatars/avatar_or_hash.dart
Normal file
42
lib/widgets/avatars/avatar_or_hash.dart
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import "package:color_hash/color_hash.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/helpers/mxc_image.dart";
|
||||
|
||||
final class const AvatarOrHash(
|
||||
final Uri? avatar,
|
||||
final String title, {
|
||||
final Widget? fallback,
|
||||
final double dimension = 24,
|
||||
super.key,
|
||||
}) extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final box = ColoredBox(
|
||||
color: ColorHash(title).color,
|
||||
child: Center(child: Icon(Icons.person, size: dimension / 2)),
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
width: dimension,
|
||||
height: dimension,
|
||||
child: Center(
|
||||
child: ClipRRect(
|
||||
borderRadius: .all(.circular((dimension - 8) / 2.5)),
|
||||
child: SizedBox.square(
|
||||
dimension: dimension,
|
||||
child: avatar == null
|
||||
? fallback ?? box
|
||||
: Image(
|
||||
image: MxcImage(ref, .new(mxc: avatar!, isAvatar: true)),
|
||||
fit: .cover,
|
||||
loadingBuilder: (_, child, loadingProgress) =>
|
||||
loadingProgress == null ? child : fallback ?? box,
|
||||
errorBuilder: (_, _, _) => fallback ?? box,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
39
lib/widgets/avatars/avatar_ring.dart
Normal file
39
lib/widgets/avatars/avatar_ring.dart
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import "package:material_ui/material_ui.dart";
|
||||
|
||||
class const AvatarRing({
|
||||
required final String tooltip,
|
||||
required final VoidCallback onTap,
|
||||
required final Widget child,
|
||||
final double dimension = 24,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
static const ringWidth = 1.5;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Tooltip(
|
||||
message: tooltip,
|
||||
child: Container(
|
||||
padding: .all(ringWidth),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
shape: .circle,
|
||||
),
|
||||
child: ClipOval(
|
||||
child: SizedBox.square(
|
||||
dimension: dimension,
|
||||
child: Stack(
|
||||
fit: .expand,
|
||||
children: [
|
||||
child,
|
||||
// Over the child, so the ripple shows on top of avatars
|
||||
Material(
|
||||
type: .transparency,
|
||||
child: InkWell(onTap: onTap),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
86
lib/widgets/avatars/editable_avatar.dart
Normal file
86
lib/widgets/avatars/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:material_adaptive_sheets/material_adaptive_sheets.dart";
|
||||
import "package:nexus/controllers/client.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"),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
14
lib/widgets/avatars/image_pack_avatar.dart
Normal file
14
lib/widgets/avatars/image_pack_avatar.dart
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/widgets/emoji/mxc_emoji.dart";
|
||||
|
||||
class const ImagePackAvatar(final Uri? icon, {super.key})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox.square(
|
||||
dimension: 40,
|
||||
child: switch (icon) {
|
||||
final icon? => MxcEmoji(icon),
|
||||
null => Icon(Icons.emoji_emotions),
|
||||
},
|
||||
);
|
||||
}
|
||||
133
lib/widgets/common/account_switcher.dart
Normal file
133
lib/widgets/common/account_switcher.dart
Normal 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"),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
96
lib/widgets/common/appbar.dart
Normal file
96
lib/widgets/common/appbar.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
41
lib/widgets/common/bouncing_dots.dart
Normal file
41
lib/widgets/common/bouncing_dots.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
16
lib/widgets/common/count_badge.dart
Normal file
16
lib/widgets/common/count_badge.dart
Normal 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),
|
||||
};
|
||||
}
|
||||
9
lib/widgets/common/divider_text.dart
Normal file
9
lib/widgets/common/divider_text.dart
Normal 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));
|
||||
}
|
||||
23
lib/widgets/common/divider_widget.dart
Normal file
23
lib/widgets/common/divider_widget.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
35
lib/widgets/common/error_dialog.dart
Normal file
35
lib/widgets/common/error_dialog.dart
Normal 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"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
28
lib/widgets/common/expandable_image.dart
Normal file
28
lib/widgets/common/expandable_image.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
20
lib/widgets/common/highlight_wrapper.dart
Normal file
20
lib/widgets/common/highlight_wrapper.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
49
lib/widgets/common/linkified_text.dart
Normal file
49
lib/widgets/common/linkified_text.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
15
lib/widgets/common/loading.dart
Normal file
15
lib/widgets/common/loading.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
36
lib/widgets/common/m3e_rail_theme.dart
Normal file
36
lib/widgets/common/m3e_rail_theme.dart
Normal 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),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
85
lib/widgets/common/sync_status_banner.dart
Normal file
85
lib/widgets/common/sync_status_banner.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
17
lib/widgets/common/timestamp.dart
Normal file
17
lib/widgets/common/timestamp.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ import "package:nexus/controllers/bot_command_draft.dart";
|
|||
import "package:nexus/models/bot_argument_suggestion.dart";
|
||||
import "package:nexus/models/bot_command.dart";
|
||||
import "package:nexus/models/text_command.dart";
|
||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||
import "package:nexus/widgets/avatars/avatar_or_hash.dart";
|
||||
|
||||
class const BotCommandPanel(
|
||||
final ComposerParams composer, {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import "package:nexus/models/composer_params.dart";
|
|||
import "package:nexus/models/text_command.dart";
|
||||
import "package:nexus/widgets/composer/bot_command_form.dart";
|
||||
import "package:nexus/widgets/composer/bot_command_panel.dart";
|
||||
import "package:nexus/widgets/emoji_picker.dart";
|
||||
import "package:nexus/widgets/emoji/emoji_picker.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:flutter/services.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
|
|
@ -35,10 +35,10 @@ import "package:nexus/models/content/image_pack.dart";
|
|||
import "package:nexus/models/content/message.dart";
|
||||
import "package:nexus/models/content/poll_start.dart";
|
||||
import "package:nexus/models/image_pack.dart";
|
||||
import "package:nexus/widgets/mxc_emoji.dart";
|
||||
import "package:nexus/widgets/sticker_picker.dart";
|
||||
import "package:nexus/widgets/create_poll_sheet.dart";
|
||||
import "package:nexus/widgets/location_picker_sheet.dart";
|
||||
import "package:nexus/widgets/emoji/mxc_emoji.dart";
|
||||
import "package:nexus/widgets/emoji/sticker_picker.dart";
|
||||
import "package:nexus/widgets/composer/create_poll_sheet.dart";
|
||||
import "package:nexus/widgets/location/location_picker_sheet.dart";
|
||||
import "package:nexus/models/location.dart";
|
||||
import "package:nexus/widgets/composer/overlays/tagger_overlay.dart";
|
||||
import "package:nexus/widgets/composer/paste_files_action.dart";
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue