reorganize and rename widgets
This commit is contained in:
parent
05d25869ef
commit
d5f29dc344
15 changed files with 568 additions and 547 deletions
72
lib/widgets/renderers/diffs/change_line.dart
Normal file
72
lib/widgets/renderers/diffs/change_line.dart
Normal 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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
72
lib/widgets/renderers/diffs/join_rules_diff.dart
Normal file
72
lib/widgets/renderers/diffs/join_rules_diff.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
116
lib/widgets/renderers/diffs/pinned_events_diff.dart
Normal file
116
lib/widgets/renderers/diffs/pinned_events_diff.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
141
lib/widgets/renderers/diffs/power_levels_diff.dart
Normal file
141
lib/widgets/renderers/diffs/power_levels_diff.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
76
lib/widgets/renderers/diffs/server_acl_diff.dart
Normal file
76
lib/widgets/renderers/diffs/server_acl_diff.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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, {
|
||||
|
|
|
|||
52
lib/widgets/renderers/event_preview.dart
Normal file
52
lib/widgets/renderers/event_preview.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
35
lib/widgets/renderers/file_card.dart
Normal file
35
lib/widgets/renderers/file_card.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
60
lib/widgets/renderers/message_image.dart
Normal file
60
lib/widgets/renderers/message_image.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
67
lib/widgets/renderers/poll_option.dart
Normal file
67
lib/widgets/renderers/poll_option.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
58
lib/widgets/renderers/swipe_to_reply.dart
Normal file
58
lib/widgets/renderers/swipe_to_reply.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
65
lib/widgets/renderers/url_preview.dart
Normal file
65
lib/widgets/renderers/url_preview.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue