diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e4f50f9ef6..810f06e7a0 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,8 @@ { "permissions": { "allow": [ - "Bash(flutter analyze:*)" + "Bash(flutter analyze:*)", + "Bash(dart analyze:*)" ] } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 01f80bfe9a..b53b63be78 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -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 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 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 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 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 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 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 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 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 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( - 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 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); - } -} diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart deleted file mode 100644 index 2e10e6856b..0000000000 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ /dev/null @@ -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 _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( - 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 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), - ), - ), - ); - } -}