mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
use dynamic image details
This commit is contained in:
@@ -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));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user