This commit is contained in:
Thomas Way
2026-02-06 00:13:34 +00:00
parent 15c9ee2dd9
commit fbdf2a8aab
3 changed files with 65 additions and 595 deletions

View File

@@ -1,7 +1,8 @@
{
"permissions": {
"allow": [
"Bash(flutter analyze:*)"
"Bash(flutter analyze:*)",
"Bash(dart analyze:*)"
]
}
}

View File

@@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/rendering.dart';
import 'package:auto_route/auto_route.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/infrastructure/asset_viewer/current_asset.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/widgets/common/immich_loading_indicator.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 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() {
if (!_scrollController.hasClients) return;
@@ -207,21 +226,11 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
final snap = _currentSnapOffset;
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 (velocity.abs() < 10) return;
// Scrolling down towards snap zone: snap
if (velocity < -50) {
_ballisticAnimController.value = offset;
_ballisticAnimController.animateWith(
ScrollSpringSimulation(
SpringDescription.withDampingRatio(mass: 0.5, stiffness: 100.0, ratio: 1.0),
offset,
snap,
velocity,
tolerance: _scrollTolerance,
),
);
_animateScrollTo(snap, velocity);
return;
}
// Scrolling up: decelerate naturally
@@ -233,28 +242,13 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
}
// In snap zone (0 → snapOffset): snap to nearest target
double target;
final double target;
if (velocity.abs() > 50) {
target = velocity > 0 ? snap : 0;
} else {
target = (offset < snap / 2) ? 0 : snap;
}
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,
),
);
_animateScrollTo(target, velocity);
}
@override
@@ -369,22 +363,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
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(
_,
DragStartDetails details,
@@ -458,17 +436,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
return;
}
// Scroll mode: drive the scroll controller with incremental delta
if (!_scrollController.hasClients) return;
final newOffset = (_scrollController.offset - details.delta.dy).clamp(
0.0,
_scrollController.position.maxScrollExtent,
);
_scrollController.jumpTo(newOffset);
_scrollBy(details.delta.dy);
}
void _handleDragDown(BuildContext ctx, Offset delta) {
print("drag down");
const double dragRatio = 0.2;
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) {
if (event is TimelineReloadEvent) {
_onTimelineReloadEvent();
@@ -533,16 +486,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
void _openDetails() {
if (!_scrollController.hasClients || _currentSnapOffset <= 0) return;
_ballisticAnimController.stop();
_ballisticAnimController.value = _scrollController.offset;
_ballisticAnimController.animateWith(
ScrollSpringSimulation(
SpringDescription.withDampingRatio(mass: 0.5, stiffness: 100.0, ratio: 1.0),
_scrollController.offset,
_currentSnapOffset,
0,
tolerance: _scrollTolerance,
),
);
_animateScrollTo(_currentSnapOffset, 0);
}
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(
onPopInvokedWithResult: _onPop,
child: Scaffold(
@@ -766,58 +705,51 @@ class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderSt
// Calculate padding to center the image in the viewport
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;
return Stack(
clipBehavior: Clip.none,
children: [
NotificationListener<ScrollNotification>(
onNotification: onScrollNotification,
child: SingleChildScrollView(
controller: _scrollController,
physics: const NeverScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Container(
// height: topPadding,
// decoration: const BoxDecoration(color: Colors.green),
// ),
SizedOverflowBox(
size: Size(double.infinity, topPadding + imageHeight - (kMinInteractiveDimension / 2)),
alignment: Alignment.topCenter,
child: SizedBox(
height: viewportHeight,
child: PhotoViewGallery.builder(
gaplessPlayback: true,
loadingBuilder: _placeholderBuilder,
pageController: pageController,
scrollPhysics: CurrentPlatform.isIOS
? const FastScrollPhysics() // Use bouncing physics for iOS
: const FastClampingScrollPhysics(), // Use heavy physics for Android
itemCount: totalAssets,
onPageChanged: _onPageChanged,
scaleStateChangedCallback: _onScaleStateChanged,
builder: _assetBuilder,
backgroundDecoration: BoxDecoration(color: backgroundColor),
enablePanAlways: true,
),
SingleChildScrollView(
controller: _scrollController,
physics: const NeverScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedOverflowBox(
size: Size(double.infinity, topPadding + imageHeight - (kMinInteractiveDimension / 2)),
alignment: Alignment.topCenter,
child: SizedBox(
height: viewportHeight,
child: PhotoViewGallery.builder(
gaplessPlayback: true,
loadingBuilder: _placeholderBuilder,
pageController: pageController,
scrollPhysics: CurrentPlatform.isIOS
? const FastScrollPhysics() // Use bouncing physics for iOS
: const FastClampingScrollPhysics(), // Use heavy physics for Android
itemCount: totalAssets,
onPageChanged: _onPageChanged,
scaleStateChangedCallback: _onScaleStateChanged,
builder: _assetBuilder,
backgroundDecoration: BoxDecoration(color: backgroundColor),
enablePanAlways: true,
),
),
),
GestureDetector(
onVerticalDragStart: _onDetailsDragStart,
onVerticalDragUpdate: _onDetailsDragUpdate,
onVerticalDragEnd: _onDetailsDragEnd,
child: AnimatedOpacity(
opacity: _assetDetailsOpacity,
duration: kThemeAnimationDuration,
child: AssetDetails(minHeight: viewportHeight - snapOffset),
),
GestureDetector(
onVerticalDragStart: (_) => _ballisticAnimController.stop(),
onVerticalDragUpdate: (details) => _scrollBy(details.delta.dy),
onVerticalDragEnd: (details) => _snapScroll(-details.velocity.pixelsPerSecond.dy),
child: AnimatedOpacity(
opacity: _assetDetailsOpacity,
duration: kThemeAnimationDuration,
child: AssetDetails(minHeight: viewportHeight / 4 * 3),
),
],
),
),
],
),
),
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);
}
}

View File

@@ -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),
),
),
);
}
}