Merge branch 'main' into feat/toggle-video-auto-play

This commit is contained in:
Saschl 2025-08-01 11:33:37 +02:00 committed by GitHub
commit c9e788b2e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
473 changed files with 21026 additions and 6307 deletions

View file

@ -1,10 +1,44 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class EditDateTimeActionButton extends ConsumerWidget {
const EditDateTimeActionButton({super.key});
final ActionSource source;
const EditDateTimeActionButton({super.key, required this.source});
_onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).editDateTime(source, context);
if (result == null) {
return;
}
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'edit_date_and_time_action_prompt'.t(
context: context,
args: {'count': result.count.toString()},
);
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
@ -12,6 +46,7 @@ class EditDateTimeActionButton extends ConsumerWidget {
maxWidth: 95.0,
iconData: Icons.edit_calendar_outlined,
label: "control_bottom_app_bar_edit_time".t(context: context),
onPressed: () => _onTap(context, ref),
);
}
}

View file

@ -14,6 +14,7 @@ import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@ -578,6 +579,7 @@ class AddToAlbumHeader extends ConsumerWidget {
return;
}
ref.read(currentRemoteAlbumProvider.notifier).setAlbum(newAlbum);
context.pushRoute(RemoteAlbumRoute(album: newAlbum));
}

View file

@ -218,7 +218,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _onPageBuild(PhotoViewControllerBase controller) {
viewController ??= controller;
if (showingBottomSheet) {
if (showingBottomSheet && bottomSheetController.isAttached) {
final verticalOffset =
(context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent);
controller.position = Offset(0, -verticalOffset);
@ -463,7 +463,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
void _snapBottomSheet() {
if (bottomSheetController.size > _kBottomSheetSnapExtent || bottomSheetController.size < 0.4) {
if (!bottomSheetController.isAttached ||
bottomSheetController.size > _kBottomSheetSnapExtent ||
bottomSheetController.size < 0.4) {
return;
}
isSnapping = true;

View file

@ -16,12 +16,15 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.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/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
const _kSeparator = '';
@ -140,13 +143,21 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
final cameraTitle = _getCameraInfoTitle(exifInfo);
Future<void> editDateTime() async {
await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context);
}
return SliverList.list(
children: [
// Asset Date and Time
_SheetTile(
title: _getDateTime(context, asset),
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
trailing: asset.hasRemote ? const Icon(Icons.edit, size: 18) : null,
onTap: asset.hasRemote ? () async => await editDateTime() : null,
),
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo),
const SheetPeopleDetails(),
const SheetLocationDetails(),
// Details header
_SheetTile(
@ -189,11 +200,21 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
class _SheetTile extends StatelessWidget {
final String title;
final Widget? leading;
final Widget? trailing;
final String? subtitle;
final TextStyle? titleStyle;
final TextStyle? subtitleStyle;
final VoidCallback? onTap;
const _SheetTile({required this.title, this.titleStyle, this.leading, this.subtitle, this.subtitleStyle});
const _SheetTile({
required this.title,
this.titleStyle,
this.leading,
this.subtitle,
this.subtitleStyle,
this.trailing,
this.onTap,
});
@override
Widget build(BuildContext context) {
@ -229,8 +250,85 @@ class _SheetTile extends StatelessWidget {
title: titleWidget,
titleAlignment: ListTileTitleAlignment.center,
leading: leading,
trailing: trailing,
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
subtitle: subtitleWidget,
onTap: onTap,
);
}
}
class _SheetAssetDescription extends ConsumerStatefulWidget {
final ExifInfo exif;
const _SheetAssetDescription({required this.exif});
@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 ?? '';
if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) {
_controller.text = currentDescription;
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: TextField(
controller: _controller,
keyboardType: TextInputType.multiline,
focusNode: _descriptionFocus,
maxLines: null, // makes it grow as text is added
decoration: InputDecoration(
hintText: 'exif_bottom_sheet_description'.t(context: context),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
),
onTapOutside: (_) => saveDescription(currentExifInfo?.description),
),
);
}
}

View file

@ -0,0 +1,175 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/people.utils.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
class SheetPeopleDetails extends ConsumerStatefulWidget {
const SheetPeopleDetails({super.key});
@override
ConsumerState createState() => _SheetPeopleDetailsState();
}
class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
@override
Widget build(BuildContext context) {
final asset = ref.watch(currentAssetNotifier);
if (asset is! RemoteAsset) {
return const SizedBox.shrink();
}
final peopleFuture = ref.watch(driftPeopleAssetProvider(asset.id));
Future<void> showNameEditModal(DriftPerson person) async {
await showDialog(
context: context,
useRootNavigator: false,
builder: (BuildContext context) {
return DriftPersonNameEditForm(person: person);
},
);
ref.invalidate(driftPeopleAssetProvider(asset.id));
}
return peopleFuture.when(
data: (people) {
return AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16, top: 16, bottom: 16),
child: Text(
"people".t(context: context).toUpperCase(),
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
),
),
SizedBox(
height: 150,
child: ListView(
padding: const EdgeInsets.only(left: 16.0),
scrollDirection: Axis.horizontal,
children: [
for (final person in people)
_PeopleAvatar(
person: person,
assetFileCreatedAt: asset.createdAt,
onTap: () {
final previousRouteData = ref.read(previousRouteDataProvider);
final previousRouteArgs = previousRouteData?.arguments;
// Prevent circular navigation
if (previousRouteArgs is DriftPersonRouteArgs && previousRouteArgs.person.id == person.id) {
context.back();
return;
}
context.pop();
context.pushRoute(DriftPersonRoute(person: person));
},
onNameTap: () => showNameEditModal(person),
),
],
),
),
],
),
crossFadeState: people.isEmpty ? CrossFadeState.showFirst : CrossFadeState.showSecond,
duration: Durations.short4,
);
},
error: (error, stack) => Text("error_loading_people".t(context: context), style: context.textTheme.bodyMedium),
loading: () => const SizedBox.shrink(),
);
}
}
class _PeopleAvatar extends StatelessWidget {
final DriftPerson person;
final DateTime assetFileCreatedAt;
final VoidCallback? onTap;
final VoidCallback? onNameTap;
final double imageSize = 96;
const _PeopleAvatar({required this.person, required this.assetFileCreatedAt, this.onTap, this.onNameTap});
@override
Widget build(BuildContext context) {
final headers = ApiService.getRequestHeaders();
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 96),
child: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
GestureDetector(
onTap: onTap,
child: SizedBox(
height: imageSize,
child: Material(
shape: CircleBorder(side: BorderSide(color: context.primaryColor.withAlpha(50), width: 1.0)),
shadowColor: context.colorScheme.shadow,
elevation: 3,
child: CircleAvatar(
maxRadius: imageSize / 2,
backgroundImage: NetworkImage(getFaceThumbnailUrl(person.id), headers: headers),
),
),
),
),
const SizedBox(height: 4),
if (person.name.isEmpty)
GestureDetector(
onTap: () => onNameTap?.call(),
child: Text(
"add_a_name".t(context: context),
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
)
else
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
person.name,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelLarge,
maxLines: 1,
),
if (person.birthDate != null)
Text(
formatAge(person.birthDate!, assetFileCreatedAt),
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium?.copyWith(
color: context.textTheme.bodyMedium?.color?.withAlpha(175),
),
),
],
),
],
),
),
);
}
}

View file

@ -40,7 +40,7 @@ class ArchiveBottomSheet extends ConsumerWidget {
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
const StackActionButton(source: ActionSource.timeline),

View file

@ -40,7 +40,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
const StackActionButton(source: ActionSource.timeline),

View file

@ -76,7 +76,7 @@ class GeneralBottomSheet extends ConsumerWidget {
if (multiselect.hasLocal || multiselect.hasMerged) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),
],
const EditDateTimeActionButton(),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
const StackActionButton(source: ActionSource.timeline),

View file

@ -43,7 +43,7 @@ class RemoteAlbumBottomSheet extends ConsumerWidget {
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
const StackActionButton(source: ActionSource.timeline),

View file

@ -0,0 +1,116 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:scroll_date_picker/scroll_date_picker.dart';
class DriftPersonBirthdayEditForm extends ConsumerStatefulWidget {
final DriftPerson person;
const DriftPersonBirthdayEditForm({super.key, required this.person});
@override
ConsumerState<DriftPersonBirthdayEditForm> createState() => _DriftPersonNameEditFormState();
}
class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonBirthdayEditForm> {
late DateTime _selectedDate;
@override
void initState() {
super.initState();
_selectedDate = widget.person.birthDate ?? DateTime.now();
}
void saveBirthday() async {
try {
final result = await ref.read(driftPeopleServiceProvider).updateBrithday(widget.person.id, _selectedDate);
if (result != 0) {
ref.invalidate(driftGetAllPeopleProvider);
context.pop<DateTime>(_selectedDate);
}
} catch (error) {
debugPrint('Error updating birthday: $error');
if (!context.mounted) {
return;
}
ImmichToast.show(
context: context,
msg: 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(
"edit_birthday".t(context: context),
style: const TextStyle(fontWeight: FontWeight.bold),
),
content: SizedBox(
width: double.maxFinite,
height: 300,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
child: ScrollDatePicker(
options: DatePickerOptions(
backgroundColor: context.colorScheme.surfaceContainerHigh,
itemExtent: 50,
diameterRatio: 5,
),
scrollViewOptions: DatePickerScrollViewOptions(
day: ScrollViewDetailOptions(
margin: const EdgeInsets.all(12),
selectedTextStyle: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16),
),
month: ScrollViewDetailOptions(
margin: const EdgeInsets.all(12),
selectedTextStyle: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16),
),
year: ScrollViewDetailOptions(
margin: const EdgeInsets.all(12),
selectedTextStyle: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16),
),
),
selectedDate: _selectedDate,
locale: context.locale,
minimumDate: DateTime(1800, 1, 1),
onDateTimeChanged: (DateTime value) {
setState(() {
_selectedDate = value;
});
},
),
),
),
actions: [
TextButton(
onPressed: () => context.pop(null),
child: Text(
"cancel",
style: TextStyle(color: Colors.red[300], fontWeight: FontWeight.bold),
).tr(),
),
TextButton(
onPressed: () => saveBirthday(),
child: Text(
"save",
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold),
).tr(),
),
],
);
}
}

View file

@ -0,0 +1,82 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class DriftPersonNameEditForm extends ConsumerStatefulWidget {
final DriftPerson person;
const DriftPersonNameEditForm({super.key, required this.person});
@override
ConsumerState<DriftPersonNameEditForm> createState() => _DriftPersonNameEditFormState();
}
class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonNameEditForm> {
late TextEditingController _formController;
@override
void initState() {
super.initState();
_formController = TextEditingController(text: widget.person.name);
}
void onEdit(String personId, String newName) async {
try {
final result = await ref.read(driftPeopleServiceProvider).updateName(personId, newName);
if (result != 0) {
ref.invalidate(driftGetAllPeopleProvider);
context.pop<String>(newName);
}
} catch (error) {
debugPrint('Error updating name: $error');
if (!context.mounted) {
return;
}
ImmichToast.show(
context: context,
msg: 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text("edit_name", style: TextStyle(fontWeight: FontWeight.bold)).tr(),
content: SingleChildScrollView(
child: TextFormField(
controller: _formController,
textCapitalization: TextCapitalization.words,
autofocus: true,
decoration: InputDecoration(hintText: 'name'.tr(), border: const OutlineInputBorder()),
),
),
actions: [
TextButton(
onPressed: () => context.pop(null),
child: Text(
"cancel",
style: TextStyle(color: Colors.red[300], fontWeight: FontWeight.bold),
).tr(),
),
TextButton(
onPressed: () => onEdit(widget.person.id, _formController.text),
child: Text(
"save",
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold),
).tr(),
),
],
);
}
}

View file

@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
class PersonOptionSheet extends ConsumerWidget {
const PersonOptionSheet({super.key, this.onEditName, this.onEditBirthday});
final VoidCallback? onEditName;
final VoidCallback? onEditBirthday;
@override
Widget build(BuildContext context, WidgetRef ref) {
TextStyle textStyle = Theme.of(context).textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w600);
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24.0),
child: ListView(
shrinkWrap: true,
children: [
ListTile(
leading: const Icon(Icons.edit),
title: Text('edit_name'.t(context: context), style: textStyle),
onTap: onEditName,
),
ListTile(
leading: const Icon(Icons.cake),
title: Text('edit_birthday'.t(context: context), style: textStyle),
onTap: onEditBirthday,
),
],
),
),
);
}
}

View file

@ -1,15 +1,17 @@
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart';
import 'package:immich_mobile/presentation/widgets/timeline/header.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
@ -99,7 +101,7 @@ class _FixedSegmentRow extends ConsumerWidget {
}
if (timelineService.hasRange(assetIndex, assetCount)) {
return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount));
return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount), timelineService);
}
return FutureBuilder<List<BaseAsset>>(
@ -108,7 +110,7 @@ class _FixedSegmentRow extends ConsumerWidget {
if (snapshot.connectionState != ConnectionState.done) {
return _buildPlaceholder(context);
}
return _buildAssetRow(context, snapshot.requireData);
return _buildAssetRow(context, snapshot.requireData, timelineService);
},
);
}
@ -117,14 +119,22 @@ class _FixedSegmentRow extends ConsumerWidget {
return SegmentBuilder.buildPlaceholder(context, assetCount, size: Size.square(tileHeight), spacing: spacing);
}
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets) {
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) {
return FixedTimelineRow(
dimension: tileHeight,
spacing: spacing,
textDirection: Directionality.of(context),
children: [
for (int i = 0; i < assets.length; i++)
_AssetTileWidget(key: ValueKey(assets[i].heroTag), asset: assets[i], assetIndex: assetIndex + i),
TimelineAssetIndexWrapper(
assetIndex: assetIndex + i,
segmentIndex: 0, // For simplicity, using 0 for now
child: _AssetTileWidget(
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
asset: assets[i],
assetIndex: assetIndex + i,
),
),
],
);
}

View file

@ -1,11 +1,14 @@
import 'dart:async';
import 'dart:collection';
import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
@ -15,6 +18,7 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_s
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
@ -88,10 +92,29 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
final _scrollController = ScrollController();
StreamSubscription? _eventSubscription;
// Drag selection state
bool _dragging = false;
TimelineAssetIndex? _dragAnchorIndex;
final Set<BaseAsset> _draggedAssets = HashSet();
ScrollPhysics? _scrollPhysics;
int _perRow = 4;
double _scaleFactor = 3.0;
double _baseScaleFactor = 3.0;
@override
void initState() {
super.initState();
_eventSubscription = EventStream.shared.listen(_onEvent);
WidgetsBinding.instance.addPostFrameCallback((_) {
final currentTilesPerRow = ref.read(settingsProvider).get(Setting.tilesPerRow);
setState(() {
_perRow = currentTilesPerRow;
_scaleFactor = 7.0 - _perRow;
_baseScaleFactor = _scaleFactor;
});
});
}
void _onEvent(Event event) {
@ -150,6 +173,71 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
});
}
// Drag selection methods
void _setDragStartIndex(TimelineAssetIndex index) {
setState(() {
_scrollPhysics = const ClampingScrollPhysics();
_dragAnchorIndex = index;
_dragging = true;
});
}
void _stopDrag() {
WidgetsBinding.instance.addPostFrameCallback((_) {
// Update the physics post frame to prevent sudden change in physics on iOS.
setState(() {
_scrollPhysics = null;
});
});
setState(() {
_dragging = false;
_draggedAssets.clear();
});
// Reset the scrolling state after a small delay to allow bottom sheet to expand again
Future.delayed(const Duration(milliseconds: 300), () {
if (mounted) {
ref.read(timelineStateProvider.notifier).setScrolling(false);
}
});
}
void _dragScroll(ScrollDirection direction) {
_scrollController.animateTo(
_scrollController.offset + (direction == ScrollDirection.forward ? 175 : -175),
duration: const Duration(milliseconds: 125),
curve: Curves.easeOut,
);
}
void _handleDragAssetEnter(TimelineAssetIndex index) {
if (_dragAnchorIndex == null || !_dragging) return;
final timelineService = ref.read(timelineServiceProvider);
final dragAnchorIndex = _dragAnchorIndex!;
// Calculate the range of assets to select
final startIndex = math.min(dragAnchorIndex.assetIndex, index.assetIndex);
final endIndex = math.max(dragAnchorIndex.assetIndex, index.assetIndex);
final count = endIndex - startIndex + 1;
// Load the assets in the range
if (timelineService.hasRange(startIndex, count)) {
final selectedAssets = timelineService.getAssets(startIndex, count);
// Clear previous drag selection and add new range
final multiSelectNotifier = ref.read(multiSelectProvider.notifier);
for (final asset in _draggedAssets) {
multiSelectNotifier.deselectAsset(asset);
}
_draggedAssets.clear();
for (final asset in selectedAssets) {
multiSelectNotifier.selectAsset(asset);
_draggedAssets.add(asset);
}
}
}
@override
Widget build(BuildContext _) {
final asyncSegments = ref.watch(timelineSegmentProvider);
@ -177,43 +265,83 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
return PrimaryScrollController(
controller: _scrollController,
child: Stack(
children: [
Scrubber(
layoutSegments: segments,
timelineHeight: maxHeight,
topPadding: topPadding,
bottomPadding: bottomPadding,
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
child: CustomScrollView(
primary: true,
cacheExtent: maxHeight * 2,
slivers: [
if (isSelectionMode) const SelectionSliverAppBar() else if (widget.appBar != null) widget.appBar!,
if (widget.topSliverWidget != null) widget.topSliverWidget!,
_SliverSegmentedList(
segments: segments,
delegate: SliverChildBuilderDelegate(
(ctx, index) {
if (index >= childCount) return null;
final segment = segments.findByIndex(index);
return segment?.builder(ctx, index) ?? const SizedBox.shrink();
},
childCount: childCount,
addAutomaticKeepAlives: false,
// We add repaint boundary around tiles, so skip the auto boundaries
addRepaintBoundaries: false,
),
),
const SliverPadding(padding: EdgeInsets.only(bottom: scrubberBottomPadding)),
],
),
child: RawGestureDetector(
gestures: {
CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<CustomScaleGestureRecognizer>(
() => CustomScaleGestureRecognizer(),
(CustomScaleGestureRecognizer scale) {
scale.onStart = (details) {
_baseScaleFactor = _scaleFactor;
};
scale.onUpdate = (details) {
final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0);
final newPerRow = 7 - newScaleFactor.toInt();
if (newPerRow != _perRow) {
setState(() {
_scaleFactor = newScaleFactor;
_perRow = newPerRow;
});
ref.read(settingsProvider.notifier).set(Setting.tilesPerRow, _perRow);
}
};
},
),
if (!isSelectionMode && isMultiSelectEnabled) ...[
const Positioned(top: 60, left: 25, child: _MultiSelectStatusButton()),
if (widget.bottomSheet != null) widget.bottomSheet!,
],
],
},
child: TimelineDragRegion(
onStart: _setDragStartIndex,
onAssetEnter: _handleDragAssetEnter,
onEnd: _stopDrag,
onScroll: _dragScroll,
onScrollStart: () {
// Minimize the bottom sheet when drag selection starts
ref.read(timelineStateProvider.notifier).setScrolling(true);
},
child: Stack(
children: [
Scrubber(
layoutSegments: segments,
timelineHeight: maxHeight,
topPadding: topPadding,
bottomPadding: bottomPadding,
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
child: CustomScrollView(
primary: true,
physics: _scrollPhysics,
cacheExtent: maxHeight * 2,
slivers: [
if (isSelectionMode)
const SelectionSliverAppBar()
else if (widget.appBar != null)
widget.appBar!,
if (widget.topSliverWidget != null) widget.topSliverWidget!,
_SliverSegmentedList(
segments: segments,
delegate: SliverChildBuilderDelegate(
(ctx, index) {
if (index >= childCount) return null;
final segment = segments.findByIndex(index);
return segment?.builder(ctx, index) ?? const SizedBox.shrink();
},
childCount: childCount,
addAutomaticKeepAlives: false,
// We add repaint boundary around tiles, so skip the auto boundaries
addRepaintBoundaries: false,
),
),
const SliverPadding(padding: EdgeInsets.only(bottom: scrubberBottomPadding)),
],
),
),
if (!isSelectionMode && isMultiSelectEnabled) ...[
const Positioned(top: 60, left: 25, child: _MultiSelectStatusButton()),
if (widget.bottomSheet != null) widget.bottomSheet!,
],
],
),
),
),
);
},
@ -443,3 +571,11 @@ class _MultiSelectStatusButton extends ConsumerWidget {
);
}
}
/// accepts a gesture even though it should reject it (because child won)
class CustomScaleGestureRecognizer extends ScaleGestureRecognizer {
@override
void rejectGesture(int pointer) {
acceptGesture(pointer);
}
}

View file

@ -0,0 +1,212 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class TimelineDragRegion extends StatefulWidget {
final Widget child;
final void Function(TimelineAssetIndex valueKey)? onStart;
final void Function(TimelineAssetIndex valueKey)? onAssetEnter;
final void Function()? onEnd;
final void Function()? onScrollStart;
final void Function(ScrollDirection direction)? onScroll;
const TimelineDragRegion({
super.key,
required this.child,
this.onStart,
this.onAssetEnter,
this.onEnd,
this.onScrollStart,
this.onScroll,
});
@override
State createState() => _TimelineDragRegionState();
}
class _TimelineDragRegionState extends State<TimelineDragRegion> {
late TimelineAssetIndex? assetUnderPointer;
late TimelineAssetIndex? anchorAsset;
// Scroll related state
static const double scrollOffset = 0.10;
double? topScrollOffset;
double? bottomScrollOffset;
Timer? scrollTimer;
late bool scrollNotified;
@override
void initState() {
super.initState();
assetUnderPointer = null;
anchorAsset = null;
scrollNotified = false;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
topScrollOffset = null;
bottomScrollOffset = null;
}
@override
void dispose() {
scrollTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RawGestureDetector(
gestures: {
_CustomLongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers<_CustomLongPressGestureRecognizer>(
() => _CustomLongPressGestureRecognizer(),
_registerCallbacks,
),
},
child: widget.child,
);
}
void _registerCallbacks(_CustomLongPressGestureRecognizer recognizer) {
recognizer.onLongPressMoveUpdate = (details) => _onLongPressMove(details);
recognizer.onLongPressStart = (details) => _onLongPressStart(details);
recognizer.onLongPressUp = _onLongPressEnd;
}
TimelineAssetIndex? _getValueKeyAtPosition(Offset position) {
final box = context.findAncestorRenderObjectOfType<RenderBox>();
if (box == null) return null;
final hitTestResult = BoxHitTestResult();
final local = box.globalToLocal(position);
if (!box.hitTest(hitTestResult, position: local)) return null;
return (hitTestResult.path.firstWhereOrNull((hit) => hit.target is _TimelineAssetIndexProxy)?.target
as _TimelineAssetIndexProxy?)
?.index;
}
void _onLongPressStart(LongPressStartDetails event) {
/// Calculate widget height and scroll offset when long press starting instead of in [initState]
/// or [didChangeDependencies] as the grid might still be rendering into view to get the actual size
final height = context.size?.height;
if (height != null && (topScrollOffset == null || bottomScrollOffset == null)) {
topScrollOffset = height * scrollOffset;
bottomScrollOffset = height - topScrollOffset!;
}
final initialHit = _getValueKeyAtPosition(event.globalPosition);
anchorAsset = initialHit;
if (initialHit == null) return;
if (anchorAsset != null) {
widget.onStart?.call(anchorAsset!);
}
}
void _onLongPressEnd() {
scrollNotified = false;
scrollTimer?.cancel();
widget.onEnd?.call();
}
void _onLongPressMove(LongPressMoveUpdateDetails event) {
if (anchorAsset == null) return;
if (topScrollOffset == null || bottomScrollOffset == null) return;
final currentDy = event.localPosition.dy;
if (currentDy > bottomScrollOffset!) {
scrollTimer ??= Timer.periodic(
const Duration(milliseconds: 50),
(_) => widget.onScroll?.call(ScrollDirection.forward),
);
} else if (currentDy < topScrollOffset!) {
scrollTimer ??= Timer.periodic(
const Duration(milliseconds: 50),
(_) => widget.onScroll?.call(ScrollDirection.reverse),
);
} else {
scrollTimer?.cancel();
scrollTimer = null;
}
final currentlyTouchingAsset = _getValueKeyAtPosition(event.globalPosition);
if (currentlyTouchingAsset == null) return;
if (assetUnderPointer != currentlyTouchingAsset) {
if (!scrollNotified) {
scrollNotified = true;
widget.onScrollStart?.call();
}
widget.onAssetEnter?.call(currentlyTouchingAsset);
assetUnderPointer = currentlyTouchingAsset;
}
}
}
class _CustomLongPressGestureRecognizer extends LongPressGestureRecognizer {
@override
void rejectGesture(int pointer) {
acceptGesture(pointer);
}
}
class TimelineAssetIndexWrapper extends SingleChildRenderObjectWidget {
final int assetIndex;
final int segmentIndex;
const TimelineAssetIndexWrapper({
required Widget super.child,
required this.assetIndex,
required this.segmentIndex,
super.key,
});
@override
// ignore: library_private_types_in_public_api
_TimelineAssetIndexProxy createRenderObject(BuildContext context) {
return _TimelineAssetIndexProxy(
index: TimelineAssetIndex(assetIndex: assetIndex, segmentIndex: segmentIndex),
);
}
@override
void updateRenderObject(
BuildContext context,
// ignore: library_private_types_in_public_api
_TimelineAssetIndexProxy renderObject,
) {
renderObject.index = TimelineAssetIndex(assetIndex: assetIndex, segmentIndex: segmentIndex);
}
}
class _TimelineAssetIndexProxy extends RenderProxyBox {
TimelineAssetIndex index;
_TimelineAssetIndexProxy({required this.index});
}
class TimelineAssetIndex {
final int assetIndex;
final int segmentIndex;
const TimelineAssetIndex({required this.assetIndex, required this.segmentIndex});
@override
bool operator ==(covariant TimelineAssetIndex other) {
if (identical(this, other)) return true;
return other.assetIndex == assetIndex && other.segmentIndex == segmentIndex;
}
@override
int get hashCode => assetIndex.hashCode ^ segmentIndex.hashCode;
}