mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
cleanup
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(flutter analyze:*)"
|
"Bash(flutter analyze:*)",
|
||||||
|
"Bash(dart analyze:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'package:flutter/rendering.dart';
|
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
@@ -31,7 +30,6 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider
|
|||||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
||||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||||
@@ -174,6 +172,27 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
|
|||||||
}
|
}
|
||||||
|
|
||||||
static const _scrollTolerance = Tolerance(distance: 0.5, velocity: 10.0);
|
static const _scrollTolerance = Tolerance(distance: 0.5, velocity: 10.0);
|
||||||
|
static final _snapSpring = SpringDescription.withDampingRatio(mass: 0.5, stiffness: 100.0, ratio: 1.0);
|
||||||
|
|
||||||
|
/// Drive the scroll controller by [dy] pixels (positive = scroll down).
|
||||||
|
void _scrollBy(double dy) {
|
||||||
|
if (!_scrollController.hasClients) return;
|
||||||
|
final newOffset = (_scrollController.offset - dy).clamp(0.0, _scrollController.position.maxScrollExtent);
|
||||||
|
_scrollController.jumpTo(newOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Animate the scroll position to [target] using a spring simulation.
|
||||||
|
void _animateScrollTo(double target, double velocity) {
|
||||||
|
final offset = _scrollController.offset;
|
||||||
|
if ((offset - target).abs() < 0.5) {
|
||||||
|
_scrollController.jumpTo(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ballisticAnimController.value = offset;
|
||||||
|
_ballisticAnimController.animateWith(
|
||||||
|
ScrollSpringSimulation(_snapSpring, offset, target, velocity, tolerance: _scrollTolerance),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _onBallisticTick() {
|
void _onBallisticTick() {
|
||||||
if (!_scrollController.hasClients) return;
|
if (!_scrollController.hasClients) return;
|
||||||
@@ -207,21 +226,11 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
|
|||||||
final snap = _currentSnapOffset;
|
final snap = _currentSnapOffset;
|
||||||
if (snap <= 0) return;
|
if (snap <= 0) return;
|
||||||
|
|
||||||
// Above snap offset: free scroll with deceleration
|
// Above snap offset: free scroll or spring back to snap
|
||||||
if (offset >= snap) {
|
if (offset >= snap) {
|
||||||
if (velocity.abs() < 10) return;
|
if (velocity.abs() < 10) return;
|
||||||
// Scrolling down towards snap zone: snap
|
|
||||||
if (velocity < -50) {
|
if (velocity < -50) {
|
||||||
_ballisticAnimController.value = offset;
|
_animateScrollTo(snap, velocity);
|
||||||
_ballisticAnimController.animateWith(
|
|
||||||
ScrollSpringSimulation(
|
|
||||||
SpringDescription.withDampingRatio(mass: 0.5, stiffness: 100.0, ratio: 1.0),
|
|
||||||
offset,
|
|
||||||
snap,
|
|
||||||
velocity,
|
|
||||||
tolerance: _scrollTolerance,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Scrolling up: decelerate naturally
|
// Scrolling up: decelerate naturally
|
||||||
@@ -233,28 +242,13 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
|
|||||||
}
|
}
|
||||||
|
|
||||||
// In snap zone (0 → snapOffset): snap to nearest target
|
// In snap zone (0 → snapOffset): snap to nearest target
|
||||||
double target;
|
final double target;
|
||||||
if (velocity.abs() > 50) {
|
if (velocity.abs() > 50) {
|
||||||
target = velocity > 0 ? snap : 0;
|
target = velocity > 0 ? snap : 0;
|
||||||
} else {
|
} else {
|
||||||
target = (offset < snap / 2) ? 0 : snap;
|
target = (offset < snap / 2) ? 0 : snap;
|
||||||
}
|
}
|
||||||
|
_animateScrollTo(target, velocity);
|
||||||
if ((offset - target).abs() < 0.5) {
|
|
||||||
_scrollController.jumpTo(target);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_ballisticAnimController.value = offset;
|
|
||||||
_ballisticAnimController.animateWith(
|
|
||||||
ScrollSpringSimulation(
|
|
||||||
SpringDescription.withDampingRatio(mass: 0.5, stiffness: 100.0, ratio: 1.0),
|
|
||||||
offset,
|
|
||||||
target,
|
|
||||||
velocity,
|
|
||||||
tolerance: _scrollTolerance,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -369,22 +363,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
|
|||||||
viewController = controller;
|
viewController = controller;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool onScrollNotification(ScrollNotification notification) {
|
|
||||||
if (notification is ScrollStartNotification) {
|
|
||||||
// Drag started
|
|
||||||
print('Scroll/drag started');
|
|
||||||
// final dragDetails = notification.dragDetails;
|
|
||||||
// _onDragStart(_, notification.dragDetails, controller, scaleStateController)
|
|
||||||
} else if (notification is ScrollUpdateNotification) {
|
|
||||||
// Drag is ongoing
|
|
||||||
print('Scroll offset: ${notification.metrics.pixels}');
|
|
||||||
} else if (notification is ScrollEndNotification) {
|
|
||||||
// Drag ended
|
|
||||||
print('Scroll/drag ended');
|
|
||||||
}
|
|
||||||
return false; // return false to allow the notification to continue propagating
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onDragStart(
|
void _onDragStart(
|
||||||
_,
|
_,
|
||||||
DragStartDetails details,
|
DragStartDetails details,
|
||||||
@@ -458,17 +436,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll mode: drive the scroll controller with incremental delta
|
_scrollBy(details.delta.dy);
|
||||||
if (!_scrollController.hasClients) return;
|
|
||||||
final newOffset = (_scrollController.offset - details.delta.dy).clamp(
|
|
||||||
0.0,
|
|
||||||
_scrollController.position.maxScrollExtent,
|
|
||||||
);
|
|
||||||
_scrollController.jumpTo(newOffset);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleDragDown(BuildContext ctx, Offset delta) {
|
void _handleDragDown(BuildContext ctx, Offset delta) {
|
||||||
print("drag down");
|
|
||||||
const double dragRatio = 0.2;
|
const double dragRatio = 0.2;
|
||||||
const double popThreshold = 75;
|
const double popThreshold = 75;
|
||||||
|
|
||||||
@@ -495,24 +466,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onDetailsDragStart(DragStartDetails details) {
|
|
||||||
_ballisticAnimController.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onDetailsDragUpdate(DragUpdateDetails details) {
|
|
||||||
if (!_scrollController.hasClients) return;
|
|
||||||
final newOffset = (_scrollController.offset - details.delta.dy).clamp(
|
|
||||||
0.0,
|
|
||||||
_scrollController.position.maxScrollExtent,
|
|
||||||
);
|
|
||||||
_scrollController.jumpTo(newOffset);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onDetailsDragEnd(DragEndDetails details) {
|
|
||||||
final scrollVelocity = -details.velocity.pixelsPerSecond.dy;
|
|
||||||
_snapScroll(scrollVelocity);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onEvent(Event event) {
|
void _onEvent(Event event) {
|
||||||
if (event is TimelineReloadEvent) {
|
if (event is TimelineReloadEvent) {
|
||||||
_onTimelineReloadEvent();
|
_onTimelineReloadEvent();
|
||||||
@@ -533,16 +486,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
|
|||||||
void _openDetails() {
|
void _openDetails() {
|
||||||
if (!_scrollController.hasClients || _currentSnapOffset <= 0) return;
|
if (!_scrollController.hasClients || _currentSnapOffset <= 0) return;
|
||||||
_ballisticAnimController.stop();
|
_ballisticAnimController.stop();
|
||||||
_ballisticAnimController.value = _scrollController.offset;
|
_animateScrollTo(_currentSnapOffset, 0);
|
||||||
_ballisticAnimController.animateWith(
|
|
||||||
ScrollSpringSimulation(
|
|
||||||
SpringDescription.withDampingRatio(mass: 0.5, stiffness: 100.0, ratio: 1.0),
|
|
||||||
_scrollController.offset,
|
|
||||||
_currentSnapOffset,
|
|
||||||
0,
|
|
||||||
tolerance: _scrollTolerance,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onTimelineReloadEvent() {
|
void _onTimelineReloadEvent() {
|
||||||
@@ -725,11 +669,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// debugPaintSizeEnabled = true;
|
|
||||||
|
|
||||||
// Currently it is not possible to scroll the asset when the bottom sheet is open all the way.
|
|
||||||
// Issue: https://github.com/flutter/flutter/issues/109037
|
|
||||||
// TODO: Add a custom scrum builder once the fix lands on stable
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
onPopInvokedWithResult: _onPop,
|
onPopInvokedWithResult: _onPop,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
@@ -766,58 +705,51 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
|
|||||||
|
|
||||||
// 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 / 3, viewportHeight / 2);
|
final snapOffset = (topPadding + (imageHeight / 2)).clamp(viewportHeight / 2, viewportHeight / 3 * 2);
|
||||||
_currentSnapOffset = snapOffset;
|
_currentSnapOffset = snapOffset;
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
NotificationListener<ScrollNotification>(
|
SingleChildScrollView(
|
||||||
onNotification: onScrollNotification,
|
controller: _scrollController,
|
||||||
child: SingleChildScrollView(
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
controller: _scrollController,
|
child: Column(
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
child: Column(
|
children: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
SizedOverflowBox(
|
||||||
children: [
|
size: Size(double.infinity, topPadding + imageHeight - (kMinInteractiveDimension / 2)),
|
||||||
// Container(
|
alignment: Alignment.topCenter,
|
||||||
// height: topPadding,
|
child: SizedBox(
|
||||||
// decoration: const BoxDecoration(color: Colors.green),
|
height: viewportHeight,
|
||||||
// ),
|
child: PhotoViewGallery.builder(
|
||||||
SizedOverflowBox(
|
gaplessPlayback: true,
|
||||||
size: Size(double.infinity, topPadding + imageHeight - (kMinInteractiveDimension / 2)),
|
loadingBuilder: _placeholderBuilder,
|
||||||
alignment: Alignment.topCenter,
|
pageController: pageController,
|
||||||
child: SizedBox(
|
scrollPhysics: CurrentPlatform.isIOS
|
||||||
height: viewportHeight,
|
? const FastScrollPhysics() // Use bouncing physics for iOS
|
||||||
child: PhotoViewGallery.builder(
|
: const FastClampingScrollPhysics(), // Use heavy physics for Android
|
||||||
gaplessPlayback: true,
|
itemCount: totalAssets,
|
||||||
loadingBuilder: _placeholderBuilder,
|
onPageChanged: _onPageChanged,
|
||||||
pageController: pageController,
|
scaleStateChangedCallback: _onScaleStateChanged,
|
||||||
scrollPhysics: CurrentPlatform.isIOS
|
builder: _assetBuilder,
|
||||||
? const FastScrollPhysics() // Use bouncing physics for iOS
|
backgroundDecoration: BoxDecoration(color: backgroundColor),
|
||||||
: const FastClampingScrollPhysics(), // Use heavy physics for Android
|
enablePanAlways: true,
|
||||||
itemCount: totalAssets,
|
|
||||||
onPageChanged: _onPageChanged,
|
|
||||||
scaleStateChangedCallback: _onScaleStateChanged,
|
|
||||||
builder: _assetBuilder,
|
|
||||||
backgroundDecoration: BoxDecoration(color: backgroundColor),
|
|
||||||
enablePanAlways: true,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onVerticalDragStart: _onDetailsDragStart,
|
onVerticalDragStart: (_) => _ballisticAnimController.stop(),
|
||||||
onVerticalDragUpdate: _onDetailsDragUpdate,
|
onVerticalDragUpdate: (details) => _scrollBy(details.delta.dy),
|
||||||
onVerticalDragEnd: _onDetailsDragEnd,
|
onVerticalDragEnd: (details) => _snapScroll(-details.velocity.pixelsPerSecond.dy),
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
opacity: _assetDetailsOpacity,
|
opacity: _assetDetailsOpacity,
|
||||||
duration: kThemeAnimationDuration,
|
duration: kThemeAnimationDuration,
|
||||||
child: AssetDetails(minHeight: viewportHeight - snapOffset),
|
child: AssetDetails(minHeight: viewportHeight / 4 * 3),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
@@ -840,57 +772,3 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class VariableHeightSnappingPhysics extends ScrollPhysics {
|
|
||||||
final double snapStart;
|
|
||||||
final double snapEnd;
|
|
||||||
final double snapOffset;
|
|
||||||
|
|
||||||
const VariableHeightSnappingPhysics({
|
|
||||||
required this.snapStart,
|
|
||||||
required this.snapEnd,
|
|
||||||
required this.snapOffset,
|
|
||||||
super.parent,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
VariableHeightSnappingPhysics applyTo(ScrollPhysics? ancestor) {
|
|
||||||
return VariableHeightSnappingPhysics(
|
|
||||||
parent: buildParent(ancestor),
|
|
||||||
snapStart: snapStart,
|
|
||||||
snapEnd: snapEnd,
|
|
||||||
snapOffset: snapOffset,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
double applyBoundaryConditions(ScrollMetrics position, double value) {
|
|
||||||
if (value < position.pixels && position.pixels <= position.minScrollExtent) {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
return super.applyBoundaryConditions(position, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) {
|
|
||||||
final tolerance = toleranceFor(position);
|
|
||||||
|
|
||||||
if (position.pixels >= snapStart && position.pixels <= snapEnd) {
|
|
||||||
double targetPixels;
|
|
||||||
|
|
||||||
if (velocity < -tolerance.velocity) {
|
|
||||||
targetPixels = 0;
|
|
||||||
} else if (velocity > tolerance.velocity) {
|
|
||||||
targetPixels = snapOffset;
|
|
||||||
} else {
|
|
||||||
targetPixels = (position.pixels < snapOffset / 2) ? 0 : snapOffset;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((position.pixels - targetPixels).abs() > tolerance.distance) {
|
|
||||||
return ScrollSpringSimulation(spring, position.pixels, targetPixels, velocity, tolerance: tolerance);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return super.createBallisticSimulation(position, velocity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,409 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
|
||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
|
||||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
|
||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
|
||||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
|
||||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
|
||||||
import 'package:immich_mobile/utils/timezone.dart';
|
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|
||||||
|
|
||||||
const _kSeparator = ' • ';
|
|
||||||
|
|
||||||
class AssetDetailBottomSheet extends ConsumerWidget {
|
|
||||||
final DraggableScrollableController? controller;
|
|
||||||
final double initialChildSize;
|
|
||||||
|
|
||||||
const AssetDetailBottomSheet({this.controller, this.initialChildSize = 0.35, super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final asset = ref.watch(currentAssetNotifier);
|
|
||||||
if (asset == null) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
return BaseBottomSheet(
|
|
||||||
actions: [],
|
|
||||||
slivers: const [_AssetDetailBottomSheet()],
|
|
||||||
controller: controller,
|
|
||||||
initialChildSize: initialChildSize,
|
|
||||||
minChildSize: 0.1,
|
|
||||||
maxChildSize: 0.88,
|
|
||||||
expand: false,
|
|
||||||
shouldCloseOnMinExtent: false,
|
|
||||||
resizeOnScroll: false,
|
|
||||||
backgroundColor: context.isDarkTheme ? context.colorScheme.surface : Colors.white,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AssetDetailBottomSheet extends ConsumerWidget {
|
|
||||||
const _AssetDetailBottomSheet();
|
|
||||||
|
|
||||||
String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) {
|
|
||||||
DateTime dateTime = asset.createdAt.toLocal();
|
|
||||||
Duration timeZoneOffset = dateTime.timeZoneOffset;
|
|
||||||
|
|
||||||
// Use EXIF timezone information if available (matching web app behavior)
|
|
||||||
if (exifInfo?.dateTimeOriginal != null) {
|
|
||||||
(dateTime, timeZoneOffset) = applyTimezoneOffset(
|
|
||||||
dateTime: exifInfo!.dateTimeOriginal!,
|
|
||||||
timeZone: exifInfo.timeZone,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime);
|
|
||||||
final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime);
|
|
||||||
final timezone = 'GMT${timeZoneOffset.formatAsOffset()}';
|
|
||||||
return '$date$_kSeparator$time $timezone';
|
|
||||||
}
|
|
||||||
|
|
||||||
String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) {
|
|
||||||
final height = asset.height;
|
|
||||||
final width = asset.width;
|
|
||||||
final resolution = (width != null && height != null) ? "${width.toInt()} x ${height.toInt()}" : null;
|
|
||||||
final fileSize = exifInfo?.fileSize != null ? formatBytes(exifInfo!.fileSize!) : null;
|
|
||||||
|
|
||||||
return switch ((fileSize, resolution)) {
|
|
||||||
(null, null) => '',
|
|
||||||
(String fileSize, null) => fileSize,
|
|
||||||
(null, String resolution) => resolution,
|
|
||||||
(String fileSize, String resolution) => '$fileSize$_kSeparator$resolution',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _getCameraInfoTitle(ExifInfo? exifInfo) {
|
|
||||||
if (exifInfo == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return switch ((exifInfo.make, exifInfo.model)) {
|
|
||||||
(null, null) => null,
|
|
||||||
(String make, null) => make,
|
|
||||||
(null, String model) => model,
|
|
||||||
(String make, String model) => '$make $model',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _getCameraInfoSubtitle(ExifInfo? exifInfo) {
|
|
||||||
if (exifInfo == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final exposureTime = exifInfo.exposureTime.isNotEmpty ? exifInfo.exposureTime : null;
|
|
||||||
final iso = exifInfo.iso != null ? 'ISO ${exifInfo.iso}' : null;
|
|
||||||
return [exposureTime, iso].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator);
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _getLensInfoSubtitle(ExifInfo? exifInfo) {
|
|
||||||
if (exifInfo == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final fNumber = exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null;
|
|
||||||
final focalLength = exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null;
|
|
||||||
return [fNumber, focalLength].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _editDateTime(BuildContext context, WidgetRef ref) async {
|
|
||||||
await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildAppearsInList(WidgetRef ref, BuildContext context) {
|
|
||||||
final asset = ref.watch(currentAssetNotifier);
|
|
||||||
if (asset == null) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!asset.hasRemote) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
String? remoteAssetId;
|
|
||||||
if (asset is RemoteAsset) {
|
|
||||||
remoteAssetId = asset.id;
|
|
||||||
} else if (asset is LocalAsset) {
|
|
||||||
remoteAssetId = asset.remoteAssetId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (remoteAssetId == null) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
final userId = ref.watch(currentUserProvider)?.id;
|
|
||||||
final assetAlbums = ref.watch(albumsContainingAssetProvider(remoteAssetId));
|
|
||||||
|
|
||||||
return assetAlbums.when(
|
|
||||||
data: (albums) {
|
|
||||||
if (albums.isEmpty) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
albums.sortBy((a) => a.name);
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
spacing: 12,
|
|
||||||
children: [
|
|
||||||
if (albums.isNotEmpty)
|
|
||||||
SheetTile(
|
|
||||||
title: 'appears_in'.t(context: context),
|
|
||||||
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 24),
|
|
||||||
child: Column(
|
|
||||||
spacing: 12,
|
|
||||||
children: albums.map((album) {
|
|
||||||
final isOwner = album.ownerId == userId;
|
|
||||||
return AlbumTile(
|
|
||||||
album: album,
|
|
||||||
isOwner: isOwner,
|
|
||||||
onAlbumSelected: (album) async {
|
|
||||||
ref.invalidate(assetViewerProvider);
|
|
||||||
unawaited(context.router.popAndPush(RemoteAlbumRoute(album: album)));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
loading: () => const SizedBox.shrink(),
|
|
||||||
error: (_, __) => const SizedBox.shrink(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final asset = ref.watch(currentAssetNotifier);
|
|
||||||
if (asset == null) {
|
|
||||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
|
||||||
}
|
|
||||||
|
|
||||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
|
||||||
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
|
||||||
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
|
|
||||||
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
|
|
||||||
final isRatingEnabled = ref
|
|
||||||
.watch(userMetadataPreferencesProvider)
|
|
||||||
.maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false);
|
|
||||||
|
|
||||||
// Build file info tile based on asset type
|
|
||||||
Widget buildFileInfoTile() {
|
|
||||||
if (asset is LocalAsset) {
|
|
||||||
final assetMediaRepository = ref.watch(assetMediaRepositoryProvider);
|
|
||||||
return FutureBuilder<String?>(
|
|
||||||
future: assetMediaRepository.getOriginalFilename(asset.id),
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
final displayName = snapshot.data ?? asset.name;
|
|
||||||
return SheetTile(
|
|
||||||
title: displayName,
|
|
||||||
titleStyle: context.textTheme.labelLarge,
|
|
||||||
leading: Icon(
|
|
||||||
asset.isImage ? Icons.image_outlined : Icons.videocam_outlined,
|
|
||||||
size: 24,
|
|
||||||
color: context.textTheme.labelLarge?.color,
|
|
||||||
),
|
|
||||||
subtitle: _getFileInfo(asset, exifInfo),
|
|
||||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// For remote assets, use the name directly
|
|
||||||
return SheetTile(
|
|
||||||
title: asset.name,
|
|
||||||
titleStyle: context.textTheme.labelLarge,
|
|
||||||
leading: Icon(
|
|
||||||
asset.isImage ? Icons.image_outlined : Icons.videocam_outlined,
|
|
||||||
size: 24,
|
|
||||||
color: context.textTheme.labelLarge?.color,
|
|
||||||
),
|
|
||||||
subtitle: _getFileInfo(asset, exifInfo),
|
|
||||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return SliverList.list(
|
|
||||||
children: [
|
|
||||||
// Asset Date and Time
|
|
||||||
SheetTile(
|
|
||||||
title: _getDateTime(context, asset, exifInfo),
|
|
||||||
titleStyle: context.textTheme.labelLarge,
|
|
||||||
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
|
|
||||||
onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null,
|
|
||||||
),
|
|
||||||
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner),
|
|
||||||
const SheetPeopleDetails(),
|
|
||||||
const SheetLocationDetails(),
|
|
||||||
// Details header
|
|
||||||
SheetTile(
|
|
||||||
title: 'details'.t(context: context),
|
|
||||||
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
|
||||||
),
|
|
||||||
// File info
|
|
||||||
buildFileInfoTile(),
|
|
||||||
// Camera info
|
|
||||||
if (cameraTitle != null) ...[
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
SheetTile(
|
|
||||||
title: cameraTitle,
|
|
||||||
titleStyle: context.textTheme.labelLarge,
|
|
||||||
leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color),
|
|
||||||
subtitle: _getCameraInfoSubtitle(exifInfo),
|
|
||||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
// Lens info
|
|
||||||
if (lensTitle != null) ...[
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
SheetTile(
|
|
||||||
title: lensTitle,
|
|
||||||
titleStyle: context.textTheme.labelLarge,
|
|
||||||
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
|
|
||||||
subtitle: _getLensInfoSubtitle(exifInfo),
|
|
||||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
// Rating bar
|
|
||||||
if (isRatingEnabled) ...[
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
spacing: 8,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'rating'.t(context: context),
|
|
||||||
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
|
||||||
),
|
|
||||||
RatingBar(
|
|
||||||
initialRating: exifInfo?.rating?.toDouble() ?? 0,
|
|
||||||
filledColor: context.themeData.colorScheme.primary,
|
|
||||||
unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100),
|
|
||||||
itemSize: 40,
|
|
||||||
onRatingUpdate: (rating) async {
|
|
||||||
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round());
|
|
||||||
},
|
|
||||||
onClearRating: () async {
|
|
||||||
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, 0);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
// Appears in (Albums)
|
|
||||||
Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)),
|
|
||||||
// padding at the bottom to avoid cut-off
|
|
||||||
const SizedBox(height: 60),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SheetAssetDescription extends ConsumerStatefulWidget {
|
|
||||||
final ExifInfo exif;
|
|
||||||
final bool isEditable;
|
|
||||||
|
|
||||||
const _SheetAssetDescription({required this.exif, this.isEditable = true});
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription> {
|
|
||||||
late TextEditingController _controller;
|
|
||||||
final _descriptionFocus = FocusNode();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_controller = TextEditingController(text: widget.exif.description ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> saveDescription(String? previousDescription) async {
|
|
||||||
final newDescription = _controller.text.trim();
|
|
||||||
|
|
||||||
if (newDescription == previousDescription) {
|
|
||||||
_descriptionFocus.unfocus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final editAction = await ref.read(actionProvider.notifier).updateDescription(ActionSource.viewer, newDescription);
|
|
||||||
|
|
||||||
if (!editAction.success) {
|
|
||||||
_controller.text = previousDescription ?? '';
|
|
||||||
|
|
||||||
ImmichToast.show(
|
|
||||||
context: context,
|
|
||||||
msg: 'exif_bottom_sheet_description_error'.t(context: context),
|
|
||||||
toastType: ToastType.error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
_descriptionFocus.unfocus();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
// Watch the current asset EXIF provider to get updates
|
|
||||||
final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
|
||||||
|
|
||||||
// Update controller text when EXIF data changes
|
|
||||||
final currentDescription = currentExifInfo?.description ?? '';
|
|
||||||
final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t(
|
|
||||||
context: context,
|
|
||||||
);
|
|
||||||
if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) {
|
|
||||||
_controller.text = currentDescription;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
|
|
||||||
child: IgnorePointer(
|
|
||||||
ignoring: !widget.isEditable,
|
|
||||||
child: TextField(
|
|
||||||
controller: _controller,
|
|
||||||
keyboardType: TextInputType.multiline,
|
|
||||||
focusNode: _descriptionFocus,
|
|
||||||
maxLines: null, // makes it grow as text is added
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: hintText,
|
|
||||||
border: InputBorder.none,
|
|
||||||
enabledBorder: InputBorder.none,
|
|
||||||
focusedBorder: InputBorder.none,
|
|
||||||
disabledBorder: InputBorder.none,
|
|
||||||
errorBorder: InputBorder.none,
|
|
||||||
focusedErrorBorder: InputBorder.none,
|
|
||||||
),
|
|
||||||
onTapOutside: (_) => saveDescription(currentExifInfo?.description),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user