parent
a09ae01b4e
commit
05d25869ef
6 changed files with 323 additions and 128 deletions
|
|
@ -20,6 +20,8 @@ extension SchemeToTheme on ColorScheme {
|
|||
shape: RoundedRectangleBorder(borderRadius: .circular(16)),
|
||||
color: surfaceContainerHigh,
|
||||
),
|
||||
// So ink at the edges of a sheet doesn't spill past its rounded corners
|
||||
bottomSheetTheme: .new(clipBehavior: .antiAlias),
|
||||
appBarTheme: AppBarTheme(
|
||||
titleSpacing: 0,
|
||||
backgroundColor: surfaceContainerLow,
|
||||
|
|
|
|||
83
lib/helpers/extensions/show_image_viewer.dart
Normal file
83
lib/helpers/extensions/show_image_viewer.dart
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:m3e_buttons/m3e_buttons.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/helpers/mxc_image.dart";
|
||||
import "package:nexus/models/requests/download_media.dart";
|
||||
import "package:nexus/widgets/error_dialog.dart";
|
||||
|
||||
extension ShowImageViewer on BuildContext {
|
||||
/// Shows the image from [request] fullscreen, zoomable.
|
||||
///
|
||||
/// If [downloadFilename] is set, a download button is shown, suggesting it.
|
||||
void showImageViewer(
|
||||
WidgetRef ref,
|
||||
DownloadMediaRequest request, {
|
||||
String? downloadFilename,
|
||||
int? size,
|
||||
}) => showDialog(
|
||||
context: this,
|
||||
builder: (context) => SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: Navigator.of(context).pop,
|
||||
child: InteractiveViewer(
|
||||
maxScale: 10,
|
||||
child: Image(
|
||||
errorBuilder: (_, error, stackTrace) => ErrorDialog(
|
||||
"Loading failed for ${request.mxc}\nError: $error",
|
||||
stackTrace,
|
||||
),
|
||||
image: MxcImage(ref, request),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (size != null)
|
||||
Align(
|
||||
alignment: .topLeft,
|
||||
child: Padding(
|
||||
padding: .all(32),
|
||||
child: Card(
|
||||
margin: .zero,
|
||||
child: Padding(
|
||||
padding: .symmetric(horizontal: 12, vertical: 8),
|
||||
child: Text(size.sizeAsString),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: .topRight,
|
||||
child: Padding(
|
||||
padding: .all(32),
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (downloadFilename != null)
|
||||
M3EButton(
|
||||
onPressed: () => context.saveAttachment(
|
||||
ref,
|
||||
request.mxc,
|
||||
encrypted: request.encrypted,
|
||||
filename: downloadFilename,
|
||||
),
|
||||
child: Icon(Icons.download),
|
||||
),
|
||||
M3EButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +1,7 @@
|
|||
import "package:material_ui/material_ui.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:m3e_buttons/m3e_buttons.dart";
|
||||
import "package:nexus/helpers/extensions/save_attachment.dart";
|
||||
import "package:nexus/helpers/extensions/size_to_string.dart";
|
||||
import "package:nexus/helpers/mxc_image.dart";
|
||||
import "package:nexus/helpers/extensions/show_image_viewer.dart";
|
||||
import "package:nexus/models/requests/download_media.dart";
|
||||
import "package:nexus/widgets/error_dialog.dart";
|
||||
|
||||
final class const ExpandableImage(
|
||||
final DownloadMediaRequest? request, {
|
||||
|
|
@ -18,72 +14,15 @@ final class const ExpandableImage(
|
|||
}) extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => InkWell(
|
||||
onTap: request == null
|
||||
? null
|
||||
: () => showDialog(
|
||||
context: context,
|
||||
builder: (_) => SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: Navigator.of(context).pop,
|
||||
child: InteractiveViewer(
|
||||
maxScale: 10,
|
||||
child: Image(
|
||||
errorBuilder: (_, error, stackTrace) => ErrorDialog(
|
||||
"Loading failed for ${request?.mxc}\nError: $error",
|
||||
stackTrace,
|
||||
),
|
||||
image: MxcImage(ref, request!),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (size != null)
|
||||
Align(
|
||||
alignment: .topLeft,
|
||||
child: Padding(
|
||||
padding: .all(32),
|
||||
child: Card(
|
||||
margin: .zero,
|
||||
child: Padding(
|
||||
padding: .symmetric(horizontal: 12, vertical: 8),
|
||||
child: Text(size!.sizeAsString),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: .topRight,
|
||||
child: Padding(
|
||||
padding: .all(32),
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (downloadFilename != null)
|
||||
M3EButton(
|
||||
onPressed: () => context.saveAttachment(
|
||||
ref,
|
||||
request!.mxc,
|
||||
encrypted: request!.encrypted,
|
||||
filename: downloadFilename,
|
||||
),
|
||||
child: Icon(Icons.download),
|
||||
),
|
||||
M3EButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
onTap: switch (request) {
|
||||
final request? => () => context.showImageViewer(
|
||||
ref,
|
||||
request,
|
||||
downloadFilename: downloadFilename,
|
||||
size: size,
|
||||
),
|
||||
null => null,
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
25
lib/widgets/reaction_key.dart
Normal file
25
lib/widgets/reaction_key.dart
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/helpers/mxc_image.dart";
|
||||
|
||||
/// A reaction's key, either an emoji or a custom (mxc) image.
|
||||
class const ReactionKey(
|
||||
final String reaction, {
|
||||
required final double height,
|
||||
final TextStyle? style,
|
||||
super.key,
|
||||
}) extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) =>
|
||||
reaction.startsWith("mxc://")
|
||||
? Image(
|
||||
height: height,
|
||||
image: MxcImage(ref, .new(mxc: Uri.parse(reaction))),
|
||||
errorBuilder: (context, _, _) => Icon(
|
||||
Icons.broken_image,
|
||||
size: height,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
)
|
||||
: Text(reaction, style: style, overflow: .ellipsis);
|
||||
}
|
||||
|
|
@ -1,12 +1,15 @@
|
|||
import "package:flutter/gestures.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/controllers/reactions.dart";
|
||||
import "package:nexus/controllers/room_chat.dart";
|
||||
import "package:nexus/helpers/mxc_image.dart";
|
||||
import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/widgets/error_dialog.dart";
|
||||
import "package:nexus/widgets/reaction_key.dart";
|
||||
import "package:nexus/widgets/reactions_popover.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
|
||||
|
|
@ -36,65 +39,64 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget {
|
|||
final selected =
|
||||
reactors?[reaction]?.contains(clientState!.userId) ??
|
||||
false;
|
||||
return Tooltip(
|
||||
message: reactors?[reaction]?.join(", ") ?? "",
|
||||
child: ChoiceChip(
|
||||
showCheckmark: false,
|
||||
selected: selected,
|
||||
label: Row(
|
||||
mainAxisSize: .min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Flexible(
|
||||
child: reaction.startsWith("mxc://")
|
||||
? Image(
|
||||
height: 20,
|
||||
image: MxcImage(
|
||||
ref,
|
||||
.new(mxc: Uri.parse(reaction)),
|
||||
),
|
||||
errorBuilder: (context, _, _) => Icon(
|
||||
Icons.broken_image,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.error,
|
||||
),
|
||||
)
|
||||
: Text(reaction, overflow: .ellipsis),
|
||||
),
|
||||
Text(count.toString(), overflow: .ellipsis),
|
||||
],
|
||||
),
|
||||
onSelected: enabled.value
|
||||
? (value) async {
|
||||
enabled.value = false;
|
||||
try {
|
||||
final controller = ref.watch(
|
||||
RoomChatController.provider((
|
||||
roomId: event.roomId,
|
||||
contextualEvent: null,
|
||||
threadRoot: null,
|
||||
)).notifier,
|
||||
);
|
||||
void showReactions(PositionedGestureDetails details) =>
|
||||
context.showAdaptivePopover(
|
||||
(_) => ReactionsPopover(
|
||||
event,
|
||||
initialReaction: reaction,
|
||||
),
|
||||
anchor: context.globalRectAt(details.globalPosition),
|
||||
);
|
||||
|
||||
if (selected) {
|
||||
await controller
|
||||
.removeReaction(
|
||||
reaction,
|
||||
event,
|
||||
clientState!.userId!,
|
||||
)
|
||||
.onError(showError);
|
||||
} else {
|
||||
await controller
|
||||
.sendReaction(reaction, event)
|
||||
.onError(showError);
|
||||
return GestureDetector(
|
||||
onSecondaryTapUp: showReactions,
|
||||
onLongPressStart: showReactions,
|
||||
child: Tooltip(
|
||||
message: reactors?[reaction]?.join(", ") ?? "",
|
||||
child: ChoiceChip(
|
||||
showCheckmark: false,
|
||||
selected: selected,
|
||||
label: Row(
|
||||
mainAxisSize: .min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ReactionKey(reaction, height: 20),
|
||||
),
|
||||
Text(count.toString(), overflow: .ellipsis),
|
||||
],
|
||||
),
|
||||
onSelected: enabled.value
|
||||
? (value) async {
|
||||
enabled.value = false;
|
||||
try {
|
||||
final controller = ref.watch(
|
||||
RoomChatController.provider((
|
||||
roomId: event.roomId,
|
||||
contextualEvent: null,
|
||||
threadRoot: null,
|
||||
)).notifier,
|
||||
);
|
||||
|
||||
if (selected) {
|
||||
await controller
|
||||
.removeReaction(
|
||||
reaction,
|
||||
event,
|
||||
clientState!.userId!,
|
||||
)
|
||||
.onError(showError);
|
||||
} else {
|
||||
await controller
|
||||
.sendReaction(reaction, event)
|
||||
.onError(showError);
|
||||
}
|
||||
} finally {
|
||||
enabled.value = true;
|
||||
}
|
||||
} finally {
|
||||
enabled.value = true;
|
||||
}
|
||||
}
|
||||
: null,
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
144
lib/widgets/reactions_popover.dart
Normal file
144
lib/widgets/reactions_popover.dart
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import "dart:math";
|
||||
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/controllers/reactions.dart";
|
||||
import "package:nexus/controllers/user.dart";
|
||||
import "package:nexus/helpers/extensions/better_when.dart";
|
||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||
import "package:nexus/helpers/extensions/show_image_viewer.dart";
|
||||
import "package:nexus/widgets/reaction_key.dart";
|
||||
|
||||
/// Every reaction to an event in tabs, each listing who reacted with it.
|
||||
class const ReactionsPopover(
|
||||
final Event event, {
|
||||
final String? initialReaction,
|
||||
super.key,
|
||||
}) extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final reactions = ref.watch(
|
||||
ReactionsController.provider(
|
||||
.new(roomId: event.roomId, eventRowId: event.rowId),
|
||||
),
|
||||
);
|
||||
// Ordered like the reaction row on the timeline
|
||||
final order = event.reactions.keys.toIList();
|
||||
final keys =
|
||||
reactions.value?.keys.toIList().sortOrdered(
|
||||
(a, b) => order.indexOf(a).compareTo(order.indexOf(b)),
|
||||
) ??
|
||||
const .empty();
|
||||
|
||||
final vsync = useSingleTickerProvider(keys: [keys.length]);
|
||||
final tabController = useMemoized(
|
||||
() => TabController(
|
||||
length: keys.length,
|
||||
initialIndex: keys
|
||||
.indexOf(initialReaction ?? "")
|
||||
.clamp(0, max(0, keys.length - 1)),
|
||||
vsync: vsync,
|
||||
),
|
||||
[keys.length],
|
||||
);
|
||||
useEffect(() => tabController.dispose, [tabController]);
|
||||
useListenable(tabController);
|
||||
|
||||
return reactions.betterWhen(
|
||||
skipLoadingOnReload: true,
|
||||
data: (reactions) {
|
||||
if (keys.isEmpty) return SizedBox.shrink();
|
||||
|
||||
final reaction = keys[tabController.index];
|
||||
|
||||
return Column(
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .stretch,
|
||||
children: [
|
||||
TabBar(
|
||||
controller: tabController,
|
||||
isScrollable: true,
|
||||
tabAlignment: .start,
|
||||
tabs: keys.map((reaction) {
|
||||
void expand() => context.showImageViewer(
|
||||
ref,
|
||||
.new(mxc: Uri.parse(reaction)),
|
||||
downloadFilename: reaction.split("/").last,
|
||||
);
|
||||
final isImage = reaction.startsWith("mxc://");
|
||||
|
||||
return GestureDetector(
|
||||
onSecondaryTap: isImage ? expand : null,
|
||||
onLongPress: isImage ? expand : null,
|
||||
child: Tab(
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
ReactionKey(reaction, height: 20),
|
||||
Text(reactions[reaction]!.length.toString()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
Flexible(
|
||||
child: AnimatedSize(
|
||||
duration: Durations.medium2,
|
||||
curve: Curves.easeInOutCubicEmphasized,
|
||||
alignment: .topCenter,
|
||||
child: SingleChildScrollView(
|
||||
key: ValueKey(reaction),
|
||||
padding: .symmetric(vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: .stretch,
|
||||
children: reactions[reaction]!
|
||||
.map(
|
||||
(userId) => Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final member = ref
|
||||
.watch(
|
||||
UserController.provider(
|
||||
.new(
|
||||
roomId: event.roomId,
|
||||
userId: userId,
|
||||
),
|
||||
),
|
||||
)
|
||||
.value;
|
||||
final name =
|
||||
member?.displayName ?? userId.localpart;
|
||||
|
||||
return ListTile(
|
||||
leading: AvatarOrHash(
|
||||
member?.avatarUrl,
|
||||
name,
|
||||
dimension: 40,
|
||||
),
|
||||
title: Text(name, overflow: .ellipsis),
|
||||
subtitle: Text(userId, overflow: .ellipsis),
|
||||
onTap: () => context.showUserPopover(
|
||||
userId,
|
||||
roomId: event.roomId,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue