mirror of
https://github.com/immich-app/immich.git
synced 2026-03-22 16:09:29 +03:00
scroll physics, asset details
This commit is contained in:
BIN
mobile/flutter_01.png
Normal file
BIN
mobile/flutter_01.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -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)),
|
||||
// ),
|
||||
// );
|
||||
// },
|
||||
// );
|
||||
// },
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user