use dynamic image details

This commit is contained in:
Thomas Way
2026-02-06 00:50:02 +00:00
parent fbdf2a8aab
commit b41e860b43
5 changed files with 159 additions and 300 deletions
+2 -2
View File
@@ -16,9 +16,9 @@ class ScrollToDateEvent extends Event {
} }
// Asset Viewer Events // Asset Viewer Events
class ViewerOpenBottomSheetEvent extends Event { class ViewerShowDetailsEvent extends Event {
final bool activitiesMode; final bool activitiesMode;
const ViewerOpenBottomSheetEvent({this.activitiesMode = false}); const ViewerShowDetailsEvent({this.activitiesMode = false});
} }
class ViewerReloadAssetEvent extends Event { class ViewerReloadAssetEvent extends Event {
@@ -3,279 +3,98 @@ import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_ui/immich_ui.dart'; import 'package:immich_ui/immich_ui.dart';
@RoutePage() List<Widget> _showcaseBuilder(Function(ImmichVariant variant, ImmichColor color) builder) {
class ImmichUIShowcasePage extends StatefulWidget { final children = <Widget>[];
const ImmichUIShowcasePage({super.key});
@override final items = [
State<ImmichUIShowcasePage> createState() => _ImmichUIShowcasePageState(); (variant: ImmichVariant.filled, title: "Filled Variant"),
(variant: ImmichVariant.ghost, title: "Ghost Variant"),
];
for (final (:variant, :title) in items) {
children.add(Text(title));
children.add(Row(spacing: 10, children: [for (var color in ImmichColor.values) builder(variant, color)]));
}
return children;
} }
class _ImmichUIShowcasePageState extends State<ImmichUIShowcasePage> { class _ComponentTitle extends StatelessWidget {
final ScrollController _scrollController = ScrollController(); final String title;
double _opacity = 0.0;
const _ComponentTitle(this.title);
@override @override
void initState() { Widget build(BuildContext context) {
super.initState(); return Text(title, style: context.textTheme.titleLarge);
_scrollController.addListener(_onScroll);
} }
}
void _onScroll() { @RoutePage()
print('Scroll offset: ${_scrollController.offset}'); class ImmichUIShowcasePage extends StatelessWidget {
setState(() { const ImmichUIShowcasePage({super.key});
_opacity = (_scrollController.offset / 50).clamp(0.0, 1.0);
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: StackedScrollView( appBar: AppBar(title: const Text('Immich UI Showcase')),
bottomPeekHeight: 100, body: Padding(
topChild: Container( padding: const EdgeInsets.all(20),
color: Colors.blue, child: SingleChildScrollView(
child: const Center( child: Column(
child: Text('Top', style: TextStyle(color: Colors.white, fontSize: 32)), spacing: 10,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _ComponentTitle("IconButton"),
..._showcaseBuilder(
(variant, color) =>
ImmichIconButton(icon: Icons.favorite, color: color, variant: variant, onPressed: () {}),
),
const _ComponentTitle("CloseButton"),
..._showcaseBuilder(
(variant, color) => ImmichCloseButton(color: color, variant: variant, onPressed: () {}),
),
const _ComponentTitle("TextButton"),
ImmichTextButton(
labelText: "Text Button",
onPressed: () {},
variant: ImmichVariant.filled,
color: ImmichColor.primary,
),
ImmichTextButton(
labelText: "Text Button",
onPressed: () {},
variant: ImmichVariant.filled,
color: ImmichColor.primary,
loading: true,
),
ImmichTextButton(
labelText: "Text Button",
onPressed: () {},
variant: ImmichVariant.ghost,
color: ImmichColor.primary,
),
ImmichTextButton(
labelText: "Text Button",
onPressed: () {},
variant: ImmichVariant.ghost,
color: ImmichColor.primary,
loading: true,
),
const _ComponentTitle("Form"),
ImmichForm(
onSubmit: () {},
child: const Column(
spacing: 10,
children: [ImmichTextInput(label: "Title", hintText: "Enter a title")],
),
),
],
), ),
), ),
bottomChild: Container(
height: 800,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 10, offset: Offset(0, -2))],
),
padding: const EdgeInsets.all(24),
child: const Text('Bottom sheet content'),
),
), ),
); );
} }
} }
class StackedScrollView extends StatefulWidget {
final Widget topChild;
final Widget bottomChild;
final double bottomPeekHeight;
const StackedScrollView({super.key, required this.topChild, required this.bottomChild, this.bottomPeekHeight = 80});
@override
State<StackedScrollView> createState() => _StackedScrollViewState();
}
class _StackedScrollViewState extends State<StackedScrollView> with SingleTickerProviderStateMixin {
double _offset = 0;
late double _maxOffset;
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController.unbounded(vsync: this)..addListener(_onAnimate);
}
void _onAnimate() {
final clamped = _controller.value.clamp(0.0, _maxOffset);
if (clamped != _offset) {
setState(() => _offset = clamped);
}
// Stop the controller if we've hit a boundary
if (_controller.value <= 0 || _controller.value >= _maxOffset) {
_controller.stop();
}
}
void _onDragUpdate(DragUpdateDetails details) {
_controller.stop();
setState(() {
_offset = (_offset - details.delta.dy).clamp(0.0, _maxOffset);
});
}
void _onDragEnd(DragEndDetails details) {
final velocity = -(details.primaryVelocity ?? 0);
final simulation = ClampingScrollSimulation(position: _offset, velocity: velocity);
_controller.animateWith(simulation);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final viewportHeight = constraints.maxHeight;
_maxOffset = viewportHeight - widget.bottomPeekHeight;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onVerticalDragUpdate: _onDragUpdate,
onVerticalDragEnd: _onDragEnd,
child: ClipRect(
child: SizedBox(
height: viewportHeight,
child: Stack(
clipBehavior: Clip.none,
children: [
// Top child — fills the screen, scrolls up
Positioned(top: -_offset, left: 0, right: 0, height: viewportHeight, child: widget.topChild),
// Bottom child — overlaps, peeks from bottom
Positioned(
top: viewportHeight - widget.bottomPeekHeight - _offset,
left: 0,
right: 0,
child: widget.bottomChild,
),
],
),
),
),
);
},
);
}
}
// class _ImmichUIShowcasePageState extends State<ImmichUIShowcasePage> {
// final ScrollController _scrollController = ScrollController();
// double _opacity = 0.0;
// @override
// void initState() {
// super.initState();
// _scrollController.addListener(_onScroll);
// }
// void _onScroll() {
// print('Scroll offset: ${_scrollController.offset}');
// setState(() {
// _opacity = (_scrollController.offset / 50).clamp(0.0, 1.0);
// });
// }
// @override
// void dispose() {
// _scrollController.dispose();
// super.dispose();
// }
// @override
// Widget build(BuildContext context) {
// return Scaffold(
// body: ListView(
// controller: _scrollController,
// physics: const PageScrollPhysics(),
// children: [
// Container(
// constraints: BoxConstraints.expand(height: MediaQuery.sizeOf(context).height),
// decoration: const BoxDecoration(color: Colors.green),
// ),
// AnimatedOpacity(
// opacity: _opacity,
// duration: const Duration(milliseconds: 300),
// child: Container(
// constraints: const BoxConstraints.expand(height: 2000),
// decoration: const BoxDecoration(color: Colors.blue),
// ),
// ),
// ],
// ),
// );
// }
// }
// class _ImmichUIShowcasePageState extends State<ImmichUIShowcasePage> {
// final ScrollController _scrollController = ScrollController();
// double _opacity = 0.0;
// @override
// void initState() {
// super.initState();
// _scrollController.addListener(_onScroll);
// }
// void _onScroll() {
// print('Scroll offset: ${_scrollController.offset}');
// setState(() {
// _opacity = (_scrollController.offset / 50).clamp(0.0, 1.0);
// });
// }
// @override
// void dispose() {
// _scrollController.dispose();
// super.dispose();
// }
// @override
// Widget build(BuildContext context) {
// return Scaffold(
// body: ListView(
// controller: _scrollController,
// physics: const PageScrollPhysics(),
// children: [
// Container(
// constraints: BoxConstraints.expand(height: MediaQuery.sizeOf(context).height),
// decoration: const BoxDecoration(color: Colors.green),
// ),
// AnimatedOpacity(
// opacity: _opacity,
// duration: const Duration(milliseconds: 300),
// child: Container(
// constraints: const BoxConstraints.expand(height: 2000),
// decoration: const BoxDecoration(color: Colors.blue),
// ),
// ),
// ],
// ),
// );
// }
// }
// @RoutePage()
// class ImmichUIShowcasePage extends StatelessWidget {
// const ImmichUIShowcasePage({super.key});
// @override
// Widget build(BuildContext context) {
// return Scaffold(
// body: LayoutBuilder(
// builder: (context, constraints) {
// final itemHeight = constraints.maxHeight * 0.5; // Each item takes 50% of screen
// return PageView.builder(
// scrollDirection: Axis.vertical,
// controller: PageController(
// viewportFraction: 1, // Shows 2 items at once
// ),
// itemCount: 2,
// itemBuilder: (context, index) {
// final colors = [Colors.blue, Colors.green];
// final labels = ['First Item', 'Second Item'];
// return Center(
// child: Container(
// height: index == 0 ? 100 : 30000,
// width: constraints.maxWidth,
// padding: const EdgeInsets.all(24),
// color: colors[index],
// child: Text(labels[index], style: const TextStyle(color: Colors.white, fontSize: 24)),
// ),
// );
// },
// );
// },
// ),
// );
// }
// }
@@ -107,6 +107,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
// PhotoViewGallery takes care of disposing it's controllers // PhotoViewGallery takes care of disposing it's controllers
PhotoViewControllerBase? viewController; PhotoViewControllerBase? viewController;
StreamSubscription? reloadSubscription; StreamSubscription? reloadSubscription;
StreamSubscription? _scaleBoundarySub;
late final int heroOffset; late final int heroOffset;
late PhotoViewControllerValue initialPhotoViewState; late PhotoViewControllerValue initialPhotoViewState;
@@ -171,8 +172,14 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
} }
} }
static const _scrollTolerance = Tolerance(distance: 0.5, velocity: 10.0); // Match Flutter's ScrollPhysics defaults
static final _snapSpring = SpringDescription.withDampingRatio(mass: 0.5, stiffness: 100.0, ratio: 1.0); static final _snapSpring = SpringDescription.withDampingRatio(mass: 0.5, stiffness: 100.0, ratio: 1.1);
static const _minFlingVelocity = 50.0; // px/s, matches ScrollPhysics.minFlingVelocity
Tolerance get _scrollTolerance {
final dpr = MediaQuery.devicePixelRatioOf(context);
return Tolerance(velocity: 1.0 / (0.050 * dpr), distance: 1.0 / dpr);
}
/// Drive the scroll controller by [dy] pixels (positive = scroll down). /// Drive the scroll controller by [dy] pixels (positive = scroll down).
void _scrollBy(double dy) { void _scrollBy(double dy) {
@@ -184,16 +191,33 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
/// Animate the scroll position to [target] using a spring simulation. /// Animate the scroll position to [target] using a spring simulation.
void _animateScrollTo(double target, double velocity) { void _animateScrollTo(double target, double velocity) {
final offset = _scrollController.offset; final offset = _scrollController.offset;
if ((offset - target).abs() < 0.5) { final tolerance = _scrollTolerance;
if ((offset - target).abs() < tolerance.distance) {
_scrollController.jumpTo(target); _scrollController.jumpTo(target);
return; return;
} }
_ballisticAnimController.value = offset; _ballisticAnimController.value = offset;
_ballisticAnimController.animateWith( _ballisticAnimController.animateWith(
ScrollSpringSimulation(_snapSpring, offset, target, velocity, tolerance: _scrollTolerance), ScrollSpringSimulation(_snapSpring, offset, target, velocity, tolerance: tolerance),
); );
} }
/// Create a platform-appropriate fling simulation (clamping on Android, bouncing on iOS).
Simulation _createFlingSimulation(double offset, double velocity) {
final tolerance = _scrollTolerance;
if (CurrentPlatform.isIOS) {
return BouncingScrollSimulation(
position: offset,
velocity: velocity,
leadingExtent: _currentSnapOffset,
trailingExtent: _scrollController.position.maxScrollExtent,
spring: _snapSpring,
tolerance: tolerance,
);
}
return ClampingScrollSimulation(position: offset, velocity: velocity, tolerance: tolerance);
}
void _onBallisticTick() { void _onBallisticTick() {
if (!_scrollController.hasClients) return; if (!_scrollController.hasClients) return;
final raw = _ballisticAnimController.value; final raw = _ballisticAnimController.value;
@@ -228,22 +252,20 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
// Above snap offset: free scroll or spring back to snap // Above snap offset: free scroll or spring back to snap
if (offset >= snap) { if (offset >= snap) {
if (velocity.abs() < 10) return; if (velocity.abs() < _minFlingVelocity) return;
if (velocity < -50) { if (velocity < -_minFlingVelocity) {
_animateScrollTo(snap, velocity); _animateScrollTo(snap, velocity);
return; return;
} }
// Scrolling up: decelerate naturally // Scrolling up: decelerate with platform-native physics
_ballisticAnimController.value = offset; _ballisticAnimController.value = offset;
_ballisticAnimController.animateWith( _ballisticAnimController.animateWith(_createFlingSimulation(offset, velocity));
ClampingScrollSimulation(position: offset, velocity: velocity, tolerance: _scrollTolerance),
);
return; return;
} }
// In snap zone (0 → snapOffset): snap to nearest target // In snap zone (0 → snapOffset): snap to nearest target
final double target; final double target;
if (velocity.abs() > 50) { if (velocity.abs() > _minFlingVelocity) {
target = velocity > 0 ? snap : 0; target = velocity > 0 ? snap : 0;
} else { } else {
target = (offset < snap / 2) ? 0 : snap; target = (offset < snap / 2) ? 0 : snap;
@@ -258,6 +280,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
pageController.dispose(); pageController.dispose();
_cancelTimers(); _cancelTimers();
reloadSubscription?.cancel(); reloadSubscription?.cancel();
_scaleBoundarySub?.cancel();
_prevPreCacheStream?.removeListener(_dummyListener); _prevPreCacheStream?.removeListener(_dummyListener);
_nextPreCacheStream?.removeListener(_dummyListener); _nextPreCacheStream?.removeListener(_dummyListener);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
@@ -265,7 +288,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
super.dispose(); super.dispose();
} }
bool get showingBottomSheet => _scrollController.offset > 0; bool get showingDetails => _scrollController.offset > 0;
Color get backgroundColor { Color get backgroundColor {
final opacity = ref.read(assetViewerProvider.select((s) => s.backgroundOpacity)); final opacity = ref.read(assetViewerProvider.select((s) => s.backgroundOpacity));
@@ -361,6 +384,20 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
void _onPageChanged(int index, PhotoViewControllerBase? controller) { void _onPageChanged(int index, PhotoViewControllerBase? controller) {
_onAssetChanged(index); _onAssetChanged(index);
viewController = controller; viewController = controller;
_listenForScaleBoundaries(controller);
}
void _listenForScaleBoundaries(PhotoViewControllerBase? controller) {
_scaleBoundarySub?.cancel();
_scaleBoundarySub = null;
if (controller == null || controller.scaleBoundaries != null) return;
_scaleBoundarySub = controller.outputStateStream.listen((_) {
if (controller.scaleBoundaries != null) {
_scaleBoundarySub?.cancel();
_scaleBoundarySub = null;
if (mounted) setState(() {});
}
});
} }
void _onDragStart( void _onDragStart(
@@ -374,11 +411,11 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
dragDownPosition = details.localPosition; dragDownPosition = details.localPosition;
_dragStartGlobalPosition = details.globalPosition; _dragStartGlobalPosition = details.globalPosition;
initialPhotoViewState = controller.value; initialPhotoViewState = controller.value;
_dragMode = showingBottomSheet ? _DragMode.scroll : _DragMode.undecided; _dragMode = showingDetails ? _DragMode.scroll : _DragMode.undecided;
final isZoomed = final isZoomed =
scaleStateController.scaleState == PhotoViewScaleState.zoomedIn || scaleStateController.scaleState == PhotoViewScaleState.zoomedIn ||
scaleStateController.scaleState == PhotoViewScaleState.covering; scaleStateController.scaleState == PhotoViewScaleState.covering;
if (!showingBottomSheet && isZoomed) { if (!showingDetails && isZoomed) {
blockGestures = true; blockGestures = true;
} }
} }
@@ -460,8 +497,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
ref.read(assetViewerProvider.notifier).setOpacity(backgroundOpacity); ref.read(assetViewerProvider.notifier).setOpacity(backgroundOpacity);
} }
void _onTapDown(_, __, ___) { void _onTapUp(_, __, ___) {
if (!showingBottomSheet) { if (!showingDetails) {
ref.read(assetViewerProvider.notifier).toggleControls(); ref.read(assetViewerProvider.notifier).toggleControls();
} }
} }
@@ -477,13 +514,13 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
return; return;
} }
if (event is ViewerOpenBottomSheetEvent) { if (event is ViewerShowDetailsEvent) {
_openDetails(); _showDetails();
return; return;
} }
} }
void _openDetails() { void _showDetails() {
if (!_scrollController.hasClients || _currentSnapOffset <= 0) return; if (!_scrollController.hasClients || _currentSnapOffset <= 0) return;
_ballisticAnimController.stop(); _ballisticAnimController.stop();
_animateScrollTo(_currentSnapOffset, 0); _animateScrollTo(_currentSnapOffset, 0);
@@ -513,7 +550,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
} }
final currentAsset = ref.read(currentAssetNotifier); final currentAsset = ref.read(currentAssetNotifier);
// Do not reload / close the bottom sheet if the asset has not changed // Do not reload if the asset has not changed
if (newAsset.heroTag == currentAsset?.heroTag) { if (newAsset.heroTag == currentAsset?.heroTag) {
return; return;
} }
@@ -536,7 +573,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
return; return;
} }
if (!showingBottomSheet) { if (!showingDetails) {
ref.read(assetViewerProvider.notifier).setControls(true); ref.read(assetViewerProvider.notifier).setControls(true);
} }
} }
@@ -585,11 +622,11 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'), heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
tightMode: true, tightMode: true,
disableScaleGestures: showingBottomSheet, disableScaleGestures: showingDetails,
onDragStart: _onDragStart, onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate, onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd, onDragEnd: _onDragEnd,
onTapDown: _onTapDown, onTapUp: _onTapUp,
onLongPressStart: asset.isMotionPhoto ? _onLongPress : null, onLongPressStart: asset.isMotionPhoto ? _onLongPress : null,
errorBuilder: (_, __, ___) => Container( errorBuilder: (_, __, ___) => Container(
width: size.width, width: size.width,
@@ -610,7 +647,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
onDragStart: _onDragStart, onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate, onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd, onDragEnd: _onDragEnd,
onTapDown: _onTapDown, onTapUp: _onTapUp,
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'), heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
maxScale: 1.0, maxScale: 1.0,
@@ -689,23 +726,26 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
final viewportWidth = constraints.maxWidth; final viewportWidth = constraints.maxWidth;
final viewportHeight = constraints.maxHeight; final viewportHeight = constraints.maxHeight;
// Calculate image display height from asset dimensions // Use the actual rendered image size from PhotoView when available,
final asset = ref.read(currentAssetNotifier); // falling back to a calculation from asset metadata.
final assetWidth = asset?.width; final sb = viewController?.scaleBoundaries;
final assetHeight = asset?.height; double imageHeight;
if (sb != null) {
// should probably get this value from the actual size of the image imageHeight = sb.childSize.height * sb.initialScale;
// rather than calculating it. It could lead to very slight } else {
// misalignments. final asset = ref.read(currentAssetNotifier);
double imageHeight = viewportHeight; final assetWidth = asset?.width;
if (assetWidth != null && assetHeight != null && assetWidth > 0 && assetHeight > 0) { final assetHeight = asset?.height;
final aspectRatio = assetWidth / assetHeight; imageHeight = viewportHeight;
imageHeight = math.min(viewportWidth / aspectRatio, viewportHeight); if (assetWidth != null && assetHeight != null && assetWidth > 0 && assetHeight > 0) {
final aspectRatio = assetWidth / assetHeight;
imageHeight = math.min(viewportWidth / aspectRatio, viewportHeight);
}
} }
// Calculate padding to center the image in the viewport // Calculate padding to center the image in the viewport
final topPadding = math.max((viewportHeight - imageHeight) / 2, 0.0); final topPadding = math.max((viewportHeight - imageHeight) / 2, 0.0);
final snapOffset = (topPadding + (imageHeight / 2)).clamp(viewportHeight / 2, viewportHeight / 3 * 2); final snapOffset = math.max(topPadding + (imageHeight / 2), viewportHeight / 4 * 3);
_currentSnapOffset = snapOffset; _currentSnapOffset = snapOffset;
return Stack( return Stack(
@@ -55,7 +55,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
IconButton( IconButton(
icon: const Icon(Icons.chat_outlined), icon: const Icon(Icons.chat_outlined),
onPressed: () { onPressed: () {
EventStream.shared.emit(const ViewerOpenBottomSheetEvent(activitiesMode: true)); EventStream.shared.emit(const ViewerShowDetailsEvent(activitiesMode: true));
}, },
), ),
+1 -1
View File
@@ -225,7 +225,7 @@ enum ActionButtonType {
iconData: Icons.info_outline, iconData: Icons.info_outline,
iconColor: context.originalTheme?.iconTheme.color, iconColor: context.originalTheme?.iconTheme.color,
menuItem: true, menuItem: true,
onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()), onPressed: () => EventStream.shared.emit(const ViewerShowDetailsEvent()),
), ),
ActionButtonType.viewInTimeline => BaseActionButton( ActionButtonType.viewInTimeline => BaseActionButton(
label: 'view_in_timeline'.tr(), label: 'view_in_timeline'.tr(),