scroll physics, asset details

This commit is contained in:
Thomas Way
2026-02-05 15:37:07 +00:00
parent bf6ed541dd
commit 6e91e2e202
4 changed files with 808 additions and 47 deletions

BIN
mobile/flutter_01.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -4,37 +4,239 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_ui/immich_ui.dart';
@RoutePage()
class ImmichUIShowcasePage extends StatelessWidget {
class ImmichUIShowcasePage extends StatefulWidget {
const ImmichUIShowcasePage({super.key});
@override
State<ImmichUIShowcasePage> createState() => _ImmichUIShowcasePageState();
}
class _ImmichUIShowcasePageState extends State<ImmichUIShowcasePage> {
final ScrollController _scrollController = ScrollController();
double _opacity = 0.0;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
void _onScroll() {
print('Scroll offset: ${_scrollController.offset}');
setState(() {
_opacity = (_scrollController.offset / 50).clamp(0.0, 1.0);
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: LayoutBuilder(
builder: (context, constraints) {
final itemHeight = constraints.maxHeight * 0.5; // Each item takes 50% of screen
final imageHeight = 200.0; // Your image's height
final viewportHeight = constraints.maxHeight;
return PageView.builder(
scrollDirection: Axis.vertical,
controller: PageController(
viewportFraction: 0.5, // Shows 2 items at once
),
itemCount: 2,
itemBuilder: (context, index) {
final colors = [Colors.blue, Colors.green];
final labels = ['First Item', 'Second Item'];
// Calculate padding to center the image in the viewport
final topPadding = (viewportHeight - imageHeight) / 2;
final snapOffset = topPadding + (imageHeight * 2 / 3);
return Center(
child: Container(
padding: const EdgeInsets.all(24),
color: colors[index],
child: Text(labels[index], style: const TextStyle(color: Colors.white, fontSize: 24)),
return SingleChildScrollView(
controller: _scrollController,
physics: SnapToPartialPhysics(snapOffset: snapOffset),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(height: topPadding), // Push image to center
Center(child: Image.asset('assets/immich-logo.png', height: imageHeight)),
Opacity(
opacity: _opacity,
child: Container(
constraints: BoxConstraints(minHeight: snapOffset + 100),
color: Colors.blue,
height: 100 + imageHeight * (1 / 3),
child: const Text('Some content'),
),
),
);
},
],
),
);
},
),
);
}
}
class SnapToPartialPhysics extends ScrollPhysics {
final double snapOffset;
const SnapToPartialPhysics({super.parent, required this.snapOffset});
@override
SnapToPartialPhysics applyTo(ScrollPhysics? ancestor) {
return SnapToPartialPhysics(parent: buildParent(ancestor), snapOffset: snapOffset);
}
@override
Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) {
final tolerance = toleranceFor(position);
// If already at a snap point, let it settle naturally
if ((position.pixels - 0).abs() < tolerance.distance || (position.pixels - snapOffset).abs() < tolerance.distance) {
return super.createBallisticSimulation(position, velocity);
}
// Determine snap target based on position and velocity
double target;
if (velocity > 0) {
// Scrolling down
target = snapOffset;
} else if (velocity < 0) {
// Scrolling up
target = 0;
} else {
// No velocity, snap to nearest
target = position.pixels < snapOffset / 2 ? 0 : snapOffset;
}
return ScrollSpringSimulation(spring, position.pixels, target, velocity, tolerance: tolerance);
}
}
// class _ImmichUIShowcasePageState extends State<ImmichUIShowcasePage> {
// final ScrollController _scrollController = ScrollController();
// double _opacity = 0.0;
// @override
// void initState() {
// super.initState();
// _scrollController.addListener(_onScroll);
// }
// void _onScroll() {
// print('Scroll offset: ${_scrollController.offset}');
// setState(() {
// _opacity = (_scrollController.offset / 50).clamp(0.0, 1.0);
// });
// }
// @override
// void dispose() {
// _scrollController.dispose();
// super.dispose();
// }
// @override
// Widget build(BuildContext context) {
// return Scaffold(
// body: ListView(
// controller: _scrollController,
// physics: const PageScrollPhysics(),
// children: [
// Container(
// constraints: BoxConstraints.expand(height: MediaQuery.sizeOf(context).height),
// decoration: const BoxDecoration(color: Colors.green),
// ),
// AnimatedOpacity(
// opacity: _opacity,
// duration: const Duration(milliseconds: 300),
// child: Container(
// constraints: const BoxConstraints.expand(height: 2000),
// decoration: const BoxDecoration(color: Colors.blue),
// ),
// ),
// ],
// ),
// );
// }
// }
// class _ImmichUIShowcasePageState extends State<ImmichUIShowcasePage> {
// final ScrollController _scrollController = ScrollController();
// double _opacity = 0.0;
// @override
// void initState() {
// super.initState();
// _scrollController.addListener(_onScroll);
// }
// void _onScroll() {
// print('Scroll offset: ${_scrollController.offset}');
// setState(() {
// _opacity = (_scrollController.offset / 50).clamp(0.0, 1.0);
// });
// }
// @override
// void dispose() {
// _scrollController.dispose();
// super.dispose();
// }
// @override
// Widget build(BuildContext context) {
// return Scaffold(
// body: ListView(
// controller: _scrollController,
// physics: const PageScrollPhysics(),
// children: [
// Container(
// constraints: BoxConstraints.expand(height: MediaQuery.sizeOf(context).height),
// decoration: const BoxDecoration(color: Colors.green),
// ),
// AnimatedOpacity(
// opacity: _opacity,
// duration: const Duration(milliseconds: 300),
// child: Container(
// constraints: const BoxConstraints.expand(height: 2000),
// decoration: const BoxDecoration(color: Colors.blue),
// ),
// ),
// ],
// ),
// );
// }
// }
// @RoutePage()
// class ImmichUIShowcasePage extends StatelessWidget {
// const ImmichUIShowcasePage({super.key});
// @override
// Widget build(BuildContext context) {
// return Scaffold(
// body: LayoutBuilder(
// builder: (context, constraints) {
// final itemHeight = constraints.maxHeight * 0.5; // Each item takes 50% of screen
// return PageView.builder(
// scrollDirection: Axis.vertical,
// controller: PageController(
// viewportFraction: 1, // Shows 2 items at once
// ),
// itemCount: 2,
// itemBuilder: (context, index) {
// final colors = [Colors.blue, Colors.green];
// final labels = ['First Item', 'Second Item'];
// return Center(
// child: Container(
// height: index == 0 ? 100 : 30000,
// width: constraints.maxWidth,
// padding: const EdgeInsets.all(24),
// color: colors[index],
// child: Text(labels[index], style: const TextStyle(color: Colors.white, fontSize: 24)),
// ),
// );
// },
// );
// },
// ),
// );
// }
// }

View File

@@ -0,0 +1,454 @@
import 'dart:async';
import 'dart:math' as math;
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/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 AssetDetails extends ConsumerWidget {
final double minHeight;
const AssetDetails({required this.minHeight, super.key});
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) {
print("null asset");
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 Container(
constraints: BoxConstraints(minHeight: minHeight),
decoration: BoxDecoration(color: context.isDarkTheme ? context.colorScheme.surface : Colors.white),
child: Column(
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);
},
),
],
),
),
],
// 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),
),
),
);
}
}
class _DragHandle extends StatelessWidget {
const _DragHandle({this.dragHandleColor, this.dragHandleSize});
final Color? dragHandleColor;
final Size? dragHandleSize;
@override
Widget build(BuildContext context) {
final BottomSheetThemeData bottomSheetTheme = Theme.of(context).bottomSheetTheme;
final BottomSheetThemeData m3Defaults = _BottomSheetDefaultsM3(context);
final Size handleSize = dragHandleSize ?? bottomSheetTheme.dragHandleSize ?? m3Defaults.dragHandleSize!;
return Semantics(
label: MaterialLocalizations.of(context).modalBarrierDismissLabel,
container: true,
button: true,
child: SizedBox(
width: math.max(handleSize.width, kMinInteractiveDimension),
height: math.max(handleSize.height, kMinInteractiveDimension),
child: Center(
child: Container(
height: handleSize.height,
width: handleSize.width,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(handleSize.height / 2),
color: m3Defaults.dragHandleColor,
),
),
),
),
);
}
}
class _BottomSheetDefaultsM3 extends BottomSheetThemeData {
_BottomSheetDefaultsM3(this.context)
: super(
elevation: 1.0,
modalElevation: 1.0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28.0))),
constraints: const BoxConstraints(maxWidth: 640),
);
final BuildContext context;
late final ColorScheme _colors = Theme.of(context).colorScheme;
@override
Color? get backgroundColor => _colors.surfaceContainerLow;
@override
Color? get surfaceTintColor => Colors.transparent;
@override
Color? get shadowColor => Colors.transparent;
@override
Color? get dragHandleColor => _colors.onSurfaceVariant;
@override
Size? get dragHandleSize => const Size(32, 4);
@override
BoxConstraints? get constraints => const BoxConstraints(maxWidth: 640.0);
}

View File

@@ -1,4 +1,6 @@
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';
@@ -15,6 +17,7 @@ import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
@@ -135,11 +138,15 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
KeepAliveLink? _stackChildrenKeepAlive;
final ScrollController _scrollController = ScrollController();
double _assetDetailsOpacity = 0.0;
@override
void initState() {
super.initState();
assert(ref.read(currentAssetNotifier) != null, "Current asset should not be null when opening the AssetViewer");
pageController = PageController(initialPage: widget.initialIndex);
_scrollController.addListener(_onScroll);
totalAssets = ref.read(timelineServiceProvider).totalAssets;
bottomSheetController = DraggableScrollableController();
WidgetsBinding.instance.addPostFrameCallback(_onAssetInit);
@@ -156,8 +163,15 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
}
void _onScroll() {
setState(() {
_assetDetailsOpacity = (_scrollController.offset / 50).clamp(0.0, 1.0);
});
}
@override
void dispose() {
_scrollController.dispose();
pageController.dispose();
bottomSheetController.dispose();
_cancelTimers();
@@ -690,38 +704,129 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
child: const DownloadStatusFloatingButton(),
),
),
body: 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()],
),
body: LayoutBuilder(
builder: (context, constraints) {
final imageHeight = 200.0; // Your image's height
final viewportHeight = constraints.maxHeight;
// Calculate padding to center the image in the viewport
final topPadding = (viewportHeight - imageHeight) / 2;
final snapOffset = math.min(topPadding + (imageHeight / 2), viewportHeight / 3);
return SingleChildScrollView(
controller: _scrollController,
physics: VariableHeightSnappingPhysics(snapStart: 0, snapEnd: snapOffset, snapOffset: snapOffset),
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,
),
),
Opacity(
opacity: _assetDetailsOpacity,
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()],
// ),
// ),
// ],
// ),
],
),
],
);
},
),
),
);
}
}
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
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);
}
}