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
15 changed files with 568 additions and 547 deletions

View file

@ -0,0 +1,72 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
/// A line of what a state event changed, like a room's old name, struck
/// through, then its new one.
class const ChangeLine({
/// What changed, e.g. `Denied`, in bold before the items.
final String? label,
final IList<String> removed = const .empty(),
final IList<String> added = const .empty(),
required final Color removedColor,
required final Color addedColor,
/// Whether to quote the items, like server names, but not whole sentences.
final bool quoted = true,
/// Shown after the items, like a change that isn't one of them.
final List<Widget> trailing = const [],
/// How many removed and how many added items to show, as some events, like
/// a room's first server ACL, can change hundreds.
final int maxShown = 5,
/// The most lines each item can take, as some, like topics, can be long.
final int maxLines = 2,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final style = theme.textTheme.labelMedium;
Iterable<Widget> items(
IList<String> items, {
required Color color,
required bool struck,
}) => [
...items
.take(maxShown)
.map(
(item) => Text(
quoted ? "\"$item\"" : item,
maxLines: maxLines,
overflow: .ellipsis,
style: style?.copyWith(
color: color,
decoration: struck ? .lineThrough : null,
decorationColor: color,
),
),
),
if (items.length > maxShown)
Text(
"+${items.length - maxShown} more",
style: style?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
];
return Wrap(
spacing: 8,
runSpacing: 2,
crossAxisAlignment: .center,
children: [
if (label case final label?)
Text(label, style: style?.copyWith(fontWeight: .bold)),
...items(removed, color: removedColor, struck: true),
...items(added, color: addedColor, struck: false),
...trailing,
],
);
}
}

View file

@ -0,0 +1,72 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/extensions/join_rule_name.dart";
import "package:nexus/helpers/extensions/success_scheme.dart";
import "package:nexus/models/content/join_rules.dart";
import "package:nexus/widgets/renderers/diffs/change_line.dart";
/// The join rule a join rules event changed to, and for restricted rooms, the
/// rooms whose members it let join, or stopped letting join.
class const JoinRulesDiff(
final JoinRulesContent content, {
final JoinRulesContent? previous,
super.key,
}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final removedColor = theme.colorScheme.error;
final addedColor = theme.successScheme.primary;
ISet<String> roomIdsOf(JoinRulesContent? content) =>
(content?.allow ?? const ISet<AllowCondition>.empty())
.map((condition) => condition.roomId)
.nonNulls
.toISet();
final before = roomIdsOf(previous);
final after = roomIdsOf(content);
final removed = before.difference(after);
final added = after.difference(before);
// Rooms the user isn't in are shown by ID
final names = ref.watch(
RoomsController.provider.select(
(rooms) => IMap.fromEntries(
removed
.addAll(added)
.map(
(roomId) =>
MapEntry(roomId, rooms[roomId]?.metadata?.name ?? roomId),
),
),
),
);
IList<String> named(ISet<String> roomIds) =>
roomIds.map((roomId) => names[roomId] ?? roomId).toIList();
return Column(
crossAxisAlignment: .start,
spacing: 2,
children: [
if (previous?.joinRule != content.joinRule)
ChangeLine(
removed: [?previous?.joinRule.displayName].lock,
added: [content.joinRule.displayName].lock,
removedColor: removedColor,
addedColor: addedColor,
quoted: false,
),
if (removed.isNotEmpty || added.isNotEmpty)
ChangeLine(
label: "Members of",
removed: named(removed),
added: named(added),
removedColor: removedColor,
addedColor: addedColor,
),
],
);
}
}

View file

@ -0,0 +1,116 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/event.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/hooks/chat_scroll.dart";
import "package:nexus/helpers/extensions/success_scheme.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
import "package:nexus/widgets/renderers/event.dart";
/// A line for each message a pinned events event pinned, in green, and each one
/// it unpinned, in red and struck through.
class const PinnedEventsDiff(
final String roomId, {
required final IList<String> pinned,
required final IList<String> unpinned,
/// If set, tapping a message jumps to it.
final JumpToEvent? jumpToEvent,
super.key,
}) extends ConsumerWidget {
static const maxShown = 5;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final style = theme.textTheme.labelMedium;
final changes = [
...pinned.map((eventId) => (eventId, unpinned: false)),
...unpinned.map((eventId) => (eventId, unpinned: true)),
];
return Column(
crossAxisAlignment: .start,
spacing: 2,
children: [
...changes.take(maxShown).map((change) {
final color = change.unpinned
? theme.colorScheme.error
: theme.successScheme.primary;
final decoration = change.unpinned
? TextDecoration.lineThrough
: null;
return DefaultTextStyle.merge(
style: style?.copyWith(
color: color,
decoration: decoration,
decorationColor: color,
),
child: switch (ref.watch(
EventController.provider(
.new(roomId: roomId, eventId: change.$1),
),
)) {
// Keeps showing the event while it reloads
AsyncValue(value: final rowId?) => InkWell(
borderRadius: .circular(4),
hoverColor: color.withValues(alpha: 0.12),
onTap: jumpToEvent == null ? null : () => jumpToEvent!(rowId),
child: Row(
mainAxisSize: .min,
children: [
// Other events already say who sent them
if (ref.watch(
RoomsController.provider.select(
(rooms) => rooms[roomId]?.events[rowId],
),
)
case final event?
when event.content is MessageContent) ...[
MessageDisplayname(
event,
clickable: false,
shouldWrap: false,
style: .new(
color: color,
fontWeight: .bold,
decoration: decoration,
decorationColor: color,
),
),
Text(": "),
],
Flexible(
child: IgnorePointer(
child: EventRenderer(
rowId,
roomId: roomId,
textOnly: true,
maxLines: 1,
),
),
),
],
),
),
AsyncLoading() => SizedBox.square(
dimension: 12,
child: CircularProgressIndicator(strokeWidth: 2, color: color),
),
// Like if it was deleted, or the server won't share it
_ => Text("Message not found"),
},
);
}),
if (changes.length > maxShown)
Text(
"+${changes.length - maxShown} more",
style: style?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
],
);
}
}

View file

@ -0,0 +1,141 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/user.dart";
import "package:nexus/helpers/extensions/displayname_color.dart";
import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/helpers/extensions/success_scheme.dart";
import "package:nexus/models/content/power_levels.dart";
import "package:nexus/models/power_level_permission.dart";
/// Whose power levels, and which permissions' levels, a power levels event
/// changed, in green when raised, and red when lowered.
class const PowerLevelsDiff(
final String roomId,
final PowerLevelsContent content, {
final PowerLevelsContent? previous,
super.key,
}) extends ConsumerWidget {
static const maxShown = 5;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final style = theme.textTheme.labelMedium;
final before = previous ?? PowerLevelsContent();
Text levels(int? from, int? to, {int? fromLevel, int? toLevel}) => Text(
"${from ?? "Default"} → ${to ?? "Default"}",
style: style?.copyWith(
color: switch ((fromLevel ?? from, toLevel ?? to)) {
(final from?, final to?) when to > from =>
theme.successScheme.primary,
(final from?, final to?) when to < from => theme.colorScheme.error,
_ => theme.colorScheme.onSurfaceVariant,
},
),
);
Iterable<Widget> capped(Iterable<Widget> lines) {
final list = lines.toList();
return [
...list.take(maxShown),
if (list.length > maxShown)
Text(
"+${list.length - maxShown} more",
style: style?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
];
}
final users = before.users.keys
.toISet()
.addAll(content.users.keys)
.map(
(userId) => (
userId,
from: before.users[userId],
to: content.users[userId],
fromLevel: before.users[userId] ?? before.usersDefault,
toLevel: content.users[userId] ?? content.usersDefault,
),
)
.where((change) => change.from != change.to)
// The most powerful first
.toIList()
.sort((a, b) => b.toLevel.compareTo(a.toLevel));
final permissions = <(String, int?, int?)>[
...PowerLevelPermission.values.map(
(permission) => (
permission.title,
permission.levelIn(before),
permission.levelIn(content),
),
),
...before.events.keys
.toISet()
.addAll(content.events.keys)
.map(
(type) => (
PowerLevelEvent.of(type)?.title ?? type,
before.events[type],
content.events[type],
),
),
].where((permission) => permission.$2 != permission.$3);
return Column(
crossAxisAlignment: .start,
spacing: 2,
children: [
...capped(
users.map(
(change) => Wrap(
spacing: 8,
crossAxisAlignment: .center,
children: [
Text(
ref
.watch(
UserController.provider(
.new(roomId: roomId, userId: change.$1),
),
)
.value
?.displayName ??
change.$1.localpart,
style: style?.copyWith(
fontWeight: .bold,
color: ref.displaynameColorAt(
change.$1,
change.toLevel.toDouble(),
),
),
),
levels(
change.from,
change.to,
fromLevel: change.fromLevel,
toLevel: change.toLevel,
),
],
),
),
),
...capped(
permissions.map(
(permission) => Wrap(
spacing: 8,
crossAxisAlignment: .center,
children: [
Text(permission.$1, style: style?.copyWith(fontWeight: .bold)),
levels(permission.$2, permission.$3),
],
),
),
),
],
);
}
}

View file

@ -0,0 +1,76 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/helpers/extensions/success_scheme.dart";
import "package:nexus/models/content/server_acl.dart";
import "package:nexus/widgets/renderers/diffs/change_line.dart";
/// The servers newly denied (in red) and allowed (in green) by a server ACL
/// event, and those no longer denied or allowed, struck through.
class const ServerAclDiff(
final ServerACLContent content, {
final ServerACLContent? previous,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final ipLiteralsAllowed = switch (previous) {
final previous?
when previous.allowIpLiterals != content.allowIpLiterals =>
content.allowIpLiterals == true,
_ => null,
};
Widget? listDiff(
String label,
Color color,
IList<String> before,
IList<String> after, {
required bool ipLiterals,
}) {
// Sets, as an ACL can list hundreds of servers
final beforeSet = before.toISet();
final afterSet = after.toISet();
final removed = before.where((server) => !afterSet.contains(server));
final added = after.where((server) => !beforeSet.contains(server));
if (removed.isEmpty && added.isEmpty && !ipLiterals) return null;
return ChangeLine(
label: label,
removed: .new(removed),
added: .new(added),
removedColor: color,
addedColor: color,
trailing: [
if (ipLiterals)
Text(
"servers using IP addresses",
style: theme.textTheme.labelMedium?.copyWith(color: color),
),
],
);
}
return Column(
crossAxisAlignment: .start,
spacing: 2,
children: [
?listDiff(
"Denied",
theme.colorScheme.error,
previous?.deny ?? const .empty(),
content.deny,
ipLiterals: ipLiteralsAllowed == false,
),
?listDiff(
"Allowed",
theme.successScheme.primary,
previous?.allow ?? const .empty(),
content.allow,
ipLiterals: ipLiteralsAllowed == true,
),
],
);
}
}

View file

@ -32,21 +32,21 @@ import "package:nexus/models/content/server_acl.dart";
import "package:nexus/models/content/tombstone.dart";
import "package:nexus/models/content/topic.dart";
import "package:nexus/models/event.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/common/error_dialog.dart";
import "package:nexus/widgets/renderers/message.dart";
import "package:nexus/widgets/reaction_row.dart";
import "package:nexus/widgets/read_receipts_row.dart";
import "package:nexus/widgets/reactions/reaction_row.dart";
import "package:nexus/widgets/read_receipts/read_receipts_row.dart";
import "package:nexus/widgets/renderers/membership.dart";
import "package:nexus/widgets/renderers/change_line.dart";
import "package:nexus/widgets/renderers/diffs/change_line.dart";
import "package:nexus/widgets/renderers/generic_event.dart";
import "package:nexus/widgets/renderers/join_rules_diff.dart";
import "package:nexus/widgets/renderers/power_levels_diff.dart";
import "package:nexus/widgets/renderers/pinned_events_diff.dart";
import "package:nexus/widgets/renderers/server_acl_diff.dart";
import "package:nexus/widgets/swipe_to_reply.dart";
import "package:nexus/widgets/thread_summary.dart";
import "package:nexus/widgets/timestamp.dart";
import "package:nexus/widgets/user_span.dart";
import "package:nexus/widgets/renderers/diffs/join_rules_diff.dart";
import "package:nexus/widgets/renderers/diffs/power_levels_diff.dart";
import "package:nexus/widgets/renderers/diffs/pinned_events_diff.dart";
import "package:nexus/widgets/renderers/diffs/server_acl_diff.dart";
import "package:nexus/widgets/renderers/swipe_to_reply.dart";
import "package:nexus/widgets/threads/thread_summary.dart";
import "package:nexus/widgets/common/timestamp.dart";
import "package:nexus/widgets/users/user_span.dart";
class const EventRenderer(
final int eventRowId, {

View file

@ -0,0 +1,52 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/extensions/is_shown_as_message.dart";
import "package:nexus/widgets/lazy_loading/message_avatar.dart";
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
import "package:nexus/widgets/renderers/event.dart";
class const EventPreview(
final int rowId, {
required final String roomId,
super.key,
}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final event = ref.watch(
RoomsController.provider.select((rooms) => rooms[roomId]?.events[rowId]),
);
if (event == null) return SizedBox.shrink();
final showSender = event.hasMessageContent;
return IgnorePointer(
child: Padding(
padding: .symmetric(vertical: 4),
child: Row(
mainAxisSize: .min,
spacing: 8,
children: [
if (showSender) MessageAvatar(event, height: 36),
Flexible(
child: Column(
mainAxisAlignment: .center,
crossAxisAlignment: .start,
children: [
if (showSender) MessageDisplayname(event, shouldWrap: false),
EventRenderer(
rowId,
roomId: roomId,
textOnly: true,
maxLines: 1,
),
],
),
),
],
),
),
);
}
}

View file

@ -0,0 +1,35 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/helpers/extensions/save_attachment.dart";
import "package:nexus/helpers/extensions/size_to_string.dart";
import "package:nexus/models/info/file.dart";
final class const FileCard(
final Uri uri,
final FileInfo? info, {
final String? filename,
final bool encrypted = false,
super.key,
}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => SizedBox(
width: 320,
child: Card(
color: Theme.of(context).colorScheme.surfaceContainer,
child: ListTile(
leading: Icon(Icons.file_copy),
title: Text(filename ?? "file", maxLines: 1, overflow: .ellipsis),
subtitle: info?.size == null ? null : Text(info!.size!.sizeAsString),
trailing: IconButton(
onPressed: () => context.saveAttachment(
ref,
uri,
encrypted: encrypted,
filename: filename,
),
icon: Icon(Icons.download),
),
),
),
);
}

View file

@ -6,7 +6,7 @@ import "package:nexus/helpers/hooks/tap_recognizer.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/models/event.dart";
import "package:nexus/widgets/renderers/generic_event.dart";
import "package:nexus/widgets/user_span.dart";
import "package:nexus/widgets/users/user_span.dart";
class const MembershipRenderer(
final Event event, {

View file

@ -12,22 +12,22 @@ import "package:nexus/models/location.dart";
import "package:nexus/models/content/poll_start.dart";
import "package:nexus/models/content/sticker.dart";
import "package:nexus/models/event.dart";
import "package:nexus/widgets/file_card.dart";
import "package:nexus/widgets/renderers/file_card.dart";
import "package:nexus/widgets/html/html.dart";
import "package:nexus/widgets/lazy_loading/message_avatar.dart";
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
import "package:nexus/widgets/linkified_text.dart";
import "package:nexus/widgets/location_message.dart";
import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/message_image.dart";
import "package:nexus/widgets/reaction_row.dart";
import "package:nexus/widgets/timestamp.dart";
import "package:nexus/widgets/url_preview.dart";
import "package:nexus/widgets/event_preview.dart";
import "package:nexus/widgets/common/linkified_text.dart";
import "package:nexus/widgets/location/location_message.dart";
import "package:nexus/widgets/common/loading.dart";
import "package:nexus/widgets/renderers/message_image.dart";
import "package:nexus/widgets/reactions/reaction_row.dart";
import "package:nexus/widgets/common/timestamp.dart";
import "package:nexus/widgets/renderers/url_preview.dart";
import "package:nexus/widgets/renderers/event_preview.dart";
import "package:nexus/widgets/players/video.dart";
import "package:nexus/widgets/players/audio.dart";
import "package:nexus/widgets/renderers/poll.dart";
import "package:nexus/widgets/thread_summary.dart";
import "package:nexus/widgets/threads/thread_summary.dart";
class const MessageRenderer(
final Event event, {

View file

@ -0,0 +1,60 @@
import "package:material_ui/material_ui.dart";
import "package:flutter_blurhash/flutter_blurhash.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/helpers/mxc_image.dart";
import "package:nexus/models/info/image.dart" as i;
import "package:nexus/models/requests/download_media.dart";
import "package:nexus/widgets/common/expandable_image.dart";
import "package:nexus/widgets/common/loading.dart";
final class const MessageImage(
final Uri url, {
final i.ImageInfo? info,
required final bool encrypted,
final String? filename,
super.key,
}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final request = DownloadMediaRequest(mxc: url, encrypted: encrypted);
return ExpandableImage(
request,
downloadFilename: filename,
size: info?.size,
child: ClipRRect(
borderRadius: .all(.circular(8)),
child: AspectRatio(
aspectRatio: (info?.width ?? 1) / (info?.height ?? 1),
child: Image(
image: MxcImage(ref, request),
width: info?.width?.toDouble(),
fit: .fitWidth,
loadingBuilder: (_, child, loadingProgress) =>
loadingProgress == null
? child
: switch (info?.blurHash) {
final blurHash? =>
info?.width == null || info?.height == null
? SizedBox(
width: 200,
height: 200,
child: BlurHash(hash: blurHash),
)
: SizedBox(
width: info!.width!.toDouble(),
child: BlurHash(hash: blurHash),
),
_ => Loading(),
},
errorBuilder: (context, error, stackTrace) => Center(
child: Text(
"Image Failed to Load",
style: .new(color: Theme.of(context).colorScheme.error),
),
),
),
),
),
);
}
}

View file

@ -15,7 +15,7 @@ import "package:nexus/helpers/extensions/event_id_is_fake.dart";
import "package:nexus/main.dart";
import "package:nexus/models/content/poll_start.dart";
import "package:nexus/models/event.dart";
import "package:nexus/widgets/poll_option.dart";
import "package:nexus/widgets/renderers/poll_option.dart";
class const PollRenderer(final Event event, final PollStart poll, {super.key})
extends HookConsumerWidget {

View file

@ -0,0 +1,67 @@
import "package:material_ui/material_ui.dart";
class const PollOption(
final String text, {
required final bool picked,
required final bool isSingleChoice,
/// From 0 to 1
required final double share,
final bool isWinner = false,
final bool loading = false,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final emphasis = isWinner ? const TextStyle(fontWeight: .bold) : null;
return Column(
crossAxisAlignment: .stretch,
spacing: 10,
children: [
Row(
spacing: 12,
children: [
SizedBox.square(
dimension: 20,
child: loading
? const CircularProgressIndicator(strokeWidth: 2.5)
: Icon(
switch ((isSingleChoice, picked)) {
(true, true) => Icons.radio_button_checked,
(true, false) => Icons.radio_button_unchecked,
(false, true) => Icons.check_box,
(false, false) => Icons.check_box_outline_blank,
},
size: 20,
color: picked
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
Expanded(
child: Text(
text,
style: theme.textTheme.bodyLarge?.merge(emphasis),
),
),
Text(
"${(share * 100).round()}%",
style: theme.textTheme.labelLarge
?.copyWith(color: colorScheme.onSurfaceVariant)
.merge(emphasis),
),
],
),
TweenAnimationBuilder(
tween: Tween(end: share),
duration: Durations.medium2,
curve: Curves.easeInOut,
builder: (context, value, _) => LinearProgressIndicator(value: value),
),
],
);
}
}

View file

@ -0,0 +1,58 @@
import "package:flutter/services.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:material_ui/material_ui.dart";
class const SwipeToReply(
final Widget child, {
required final VoidCallback? onSwipe,
super.key,
}) extends HookWidget {
static const maxDrag = 64.0;
static const threshold = 0.75;
@override
Widget build(BuildContext context) {
final controller = useAnimationController(duration: Durations.short4);
useListenable(controller);
if (onSwipe == null) return child;
final progress = controller.value;
return GestureDetector(
onHorizontalDragUpdate: (details) {
final previous = controller.value;
controller.value = previous + details.primaryDelta! / maxDrag;
if (previous < threshold && controller.value >= threshold) {
HapticFeedback.lightImpact();
}
},
onHorizontalDragEnd: (_) {
if (controller.value >= threshold) onSwipe!();
controller.animateBack(0, curve: Curves.easeOut);
},
onHorizontalDragCancel: () =>
controller.animateBack(0, curve: Curves.easeOut),
child: Stack(
alignment: .centerLeft,
children: [
Positioned(
left: 16,
child: Opacity(
opacity: (progress / threshold).clamp(0, 1),
child: Transform.scale(
scale: progress >= threshold ? 1 : 0.75,
child: const Icon(Icons.reply),
),
),
),
Transform.translate(
offset: .new(progress * maxDrag, 0),
child: child,
),
],
),
);
}
}

View file

@ -0,0 +1,65 @@
import "package:material_ui/material_ui.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/url_preview.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/launch_helper.dart";
import "package:nexus/helpers/mxc_image.dart";
class const UrlPreview(final Uri link, {super.key}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => ConstrainedBox(
constraints: .loose(.fromWidth(400)),
child: ref
.watch(UrlPreviewController.provider(link))
.betterWhen(
data: (preview) => preview == null
? SizedBox.shrink()
: InkWell(
onTap: () => ref.read(LaunchHelper.provider).launchUrl(link),
child: Card(
margin: .symmetric(vertical: 4),
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
child: Padding(
padding: .all(16),
child: Column(
crossAxisAlignment: .start,
spacing: 4,
children: [
if (preview.title != null)
Text(
preview.title!,
style: Theme.of(context).textTheme.titleLarge,
maxLines: 3,
overflow: .ellipsis,
),
if (preview.description != null) ...[
Text(
preview.description!,
maxLines: 20,
overflow: .ellipsis,
),
SizedBox(height: 4),
],
if (preview.imageUrl != null)
ClipRRect(
borderRadius: .all(.circular(8)),
child: Image(
errorBuilder: (_, _, _) => SizedBox.shrink(),
width: preview.width,
image: MxcImage(
ref,
.new(mxc: preview.imageUrl!),
),
fit: .fitWidth,
),
),
],
),
),
),
),
),
);
}