From 59af9e087bfb1a4f78d2e5b5f8344ae3f13b51d8 Mon Sep 17 00:00:00 2001 From: Thomas Way Date: Thu, 5 Feb 2026 16:49:37 +0000 Subject: [PATCH] with image and controls --- .../asset_viewer/asset_details.widget.dart | 157 +++++++++--------- .../asset_viewer/asset_viewer.page.dart | 129 ++++++++------ 2 files changed, 158 insertions(+), 128 deletions(-) diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart index d23f6a5012..69a5c4e499 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart @@ -225,81 +225,85 @@ class AssetDetails extends ConsumerWidget { return Container( constraints: BoxConstraints(minHeight: minHeight), decoration: BoxDecoration(color: context.isDarkTheme ? context.colorScheme.surface : Colors.white), - child: Column( + child: Stack( children: [ const _DragHandle(), - // 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); - }, - ), - ], + Column( + 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, ), - ), - ], - // 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), + 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), + ], + ), ], ), ); @@ -404,9 +408,14 @@ class _DragHandle extends StatelessWidget { label: MaterialLocalizations.of(context).modalBarrierDismissLabel, container: true, button: true, - child: SizedBox( - width: math.max(handleSize.width, kMinInteractiveDimension), + child: Container( + decoration: BoxDecoration( + color: context.isDarkTheme ? context.colorScheme.surface : Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + width: double.infinity, height: math.max(handleSize.height, kMinInteractiveDimension), + transform: Matrix4.translationValues(0, -kMinInteractiveDimension, 0), child: Center( child: Container( height: handleSize.height, 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 2ee25a8259..952efa054f 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -165,7 +165,14 @@ class _AssetViewerState extends ConsumerState { void _onScroll() { setState(() { - _assetDetailsOpacity = (_scrollController.offset / 50).clamp(0.0, 1.0); + // _assetDetailsOpacity = (_scrollController.offset / 50).clamp(0.0, 1.0); + _assetDetailsOpacity = _scrollController.offset > 10 ? 1 : 0; + + if (_assetDetailsOpacity == 0) { + ref.read(assetViewerProvider.notifier).setControls(true); + } else { + ref.read(assetViewerProvider.notifier).setControls(false); + } }); } @@ -706,12 +713,26 @@ class _AssetViewerState extends ConsumerState { ), body: LayoutBuilder( builder: (context, constraints) { - final imageHeight = 200.0; // Your image's height + final viewportWidth = constraints.maxWidth; final viewportHeight = constraints.maxHeight; + // Calculate image display height from asset dimensions + final asset = ref.read(currentAssetNotifier); + final assetWidth = asset?.width; + final assetHeight = asset?.height; + + // should probably get this value from the actual size of the image + // rather than calculating it. It could lead to very slight + // misalignments. + double imageHeight = 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 - final topPadding = (viewportHeight - imageHeight) / 2; - final snapOffset = math.min(topPadding + (imageHeight / 2), viewportHeight / 3); + final topPadding = math.max((viewportHeight - imageHeight) / 2, 0.0); + final snapOffset = math.min(topPadding + (imageHeight / 2), (viewportHeight / 3) * 2); return SingleChildScrollView( controller: _scrollController, @@ -719,62 +740,62 @@ class _AssetViewerState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SizedBox(height: topPadding), - // Center(child: Image.asset('assets/immich-logo.png', height: imageHeight)), 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, - onPageBuild: _onPageBuild, - scaleStateChangedCallback: _onScaleStateChanged, - builder: _assetBuilder, - backgroundDecoration: BoxDecoration(color: backgroundColor), - enablePanAlways: true, + height: imageHeight + topPadding, + child: Stack( + clipBehavior: Clip.none, + children: [ + Column( + children: [ + SizedBox(height: topPadding), + SizedBox( + height: imageHeight, + 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, + onPageBuild: _onPageBuild, + scaleStateChangedCallback: _onScaleStateChanged, + builder: _assetBuilder, + backgroundDecoration: BoxDecoration(color: backgroundColor), + enablePanAlways: true, + ), + ), + ], + ), + Positioned( + height: viewportHeight, + top: 0, + left: 0, + right: 0, + child: const IgnorePointer( + // TODO: Sync with whether it's visible? + // TODO: Hide on scroll + ignoring: true, + child: Align( + alignment: Alignment.bottomCenter, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [AssetStackRow(), ViewerBottomBar()], + ), + ), + ), + ), + ], ), ), - Opacity( + AnimatedOpacity( opacity: _assetDetailsOpacity, + duration: kThemeAnimationDuration, child: AssetDetails(minHeight: viewportHeight / 3 * 2), ), - - // Stack( - // children: [ - // 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, - // onPageBuild: _onPageBuild, - // scaleStateChangedCallback: _onScaleStateChanged, - // builder: _assetBuilder, - // backgroundDecoration: BoxDecoration(color: backgroundColor), - // enablePanAlways: true, - // ), - // if (!showingBottomSheet) - // const Positioned( - // bottom: 0, - // left: 0, - // right: 0, - // child: Column( - // mainAxisSize: MainAxisSize.min, - // mainAxisAlignment: MainAxisAlignment.end, - // crossAxisAlignment: CrossAxisAlignment.stretch, - // children: [AssetStackRow(), ViewerBottomBar()], - // ), - // ), - // ], - // ), ], ), );