feat(mobile): edit date time action

This commit is contained in:
wuzihao051119 2025-07-02 17:45:31 +08:00
parent f2f3db3a79
commit 12764666e9
12 changed files with 196 additions and 26 deletions

View File

@ -790,6 +790,7 @@
"edit_avatar": "Edit avatar",
"edit_date": "Edit date",
"edit_date_and_time": "Edit date and time",
"edit_date_time_action_prompt": "{count} date and time edited",
"edit_description": "Edit description",
"edit_description_prompt": "Please select a new description:",
"edit_exclusion_pattern": "Edit exclusion pattern",

View File

@ -12,6 +12,7 @@ class RemoteAsset extends BaseAsset {
final String id;
final String? localId;
final String? thumbHash;
final DateTime? localDateTime;
final AssetVisibility visibility;
final String ownerId;
@ -29,6 +30,7 @@ class RemoteAsset extends BaseAsset {
super.durationInSeconds,
super.isFavorite = false,
this.thumbHash,
this.localDateTime,
this.visibility = AssetVisibility.timeline,
});
@ -51,6 +53,7 @@ class RemoteAsset extends BaseAsset {
localId: ${localId ?? "<NA>"},
isFavorite: $isFavorite,
thumbHash: ${thumbHash ?? "<NA>"},
localDateTime: ${localDateTime ?? "<NA>"},
visibility: $visibility,
}''';
}
@ -64,6 +67,7 @@ class RemoteAsset extends BaseAsset {
ownerId == other.ownerId &&
localId == other.localId &&
thumbHash == other.thumbHash &&
localDateTime == other.localDateTime &&
visibility == other.visibility;
}
@ -74,5 +78,6 @@ class RemoteAsset extends BaseAsset {
ownerId.hashCode ^
localId.hashCode ^
thumbHash.hashCode ^
localDateTime.hashCode ^
visibility.hashCode;
}

View File

@ -1,5 +1,6 @@
import 'package:drift/drift.dart' hide Query;
import 'package:immich_mobile/domain/models/exif.model.dart' as domain;
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
@ -143,3 +144,22 @@ class RemoteExifEntity extends Table with DriftDefaultsMixin {
@override
Set<Column> get primaryKey => {assetId};
}
extension RemoteExifEntityDataDomainEx on RemoteExifEntityData {
domain.ExifInfo toDto() => domain.ExifInfo (
fileSize: fileSize,
description: description,
orientation: orientation,
timeZone: timeZone,
dateTimeOriginal: dateTimeOriginal,
latitude: latitude,
longitude: longitude,
city: city,
state: state,
country: country,
make: make,
model: model,
f: fNumber,
iso: iso,
);
}

View File

@ -49,6 +49,7 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
isFavorite: isFavorite,
height: height,
width: width,
localDateTime: localDateTime,
thumbHash: thumbHash,
visibility: visibility,
localId: null,

View File

@ -2,7 +2,6 @@ import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'
as entity;
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:isar/isar.dart';
@ -52,27 +51,6 @@ class DriftRemoteExifRepository extends DriftDatabaseRepository {
final query = _db.remoteExifEntity.select()
..where((exif) => exif.assetId.equals(assetId));
return query.map((asset) => asset.toDto()).getSingleOrNull();
}
}
extension on RemoteExifEntityData {
ExifInfo toDto() {
return ExifInfo(
fileSize: fileSize,
description: description,
orientation: orientation,
timeZone: timeZone,
dateTimeOriginal: dateTimeOriginal,
latitude: latitude,
longitude: longitude,
city: city,
state: state,
country: country,
make: make,
model: model,
f: fNumber,
iso: iso,
);
return query.map((exif) => exif.toDto()).getSingleOrNull();
}
}

View File

@ -1,6 +1,7 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@ -9,6 +10,13 @@ class DriftRemoteAssetRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftRemoteAssetRepository(this._db) : super(_db);
Future<RemoteAsset?> get(String assetId) {
final query = _db.remoteAssetEntity.select()
..where((asset) => asset.id.equals(assetId));
return query.map((asset) => asset.toDto()).getSingleOrNull();
}
Future<void> updateFavorite(List<String> ids, bool isFavorite) {
return _db.batch((batch) async {
for (final id in ids) {
@ -47,4 +55,26 @@ class DriftRemoteAssetRepository extends DriftDatabaseRepository {
}
});
}
Future<void> updateDateTime(List<String> ids, String dateTime) {
final localDateTime = dateTime.replaceAll(RegExp(r'[\+|-][0-9]{2}:[0-9]{2}'), '');
return _db.batch((batch) async {
for (final id in ids) {
batch.update(
_db.remoteAssetEntity,
RemoteAssetEntityCompanion(
localDateTime: Value(DateTime.parse(localDateTime).toUtc()),
),
where: (e) => e.id.equals(id),
);
batch.update(
_db.remoteExifEntity,
RemoteExifEntityCompanion(
dateTimeOriginal: Value(DateTime.parse(dateTime)),
),
where: (e) => e.assetId.equals(id),
);
}
});
}
}

View File

@ -1,10 +1,49 @@
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/infrastructure/timeline.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;
}
await ref.read(timelineServiceProvider).reloadBucket();
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'edit_date_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 +51,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

@ -5,6 +5,7 @@ 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/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@ -24,6 +25,7 @@ class EditLocationActionButton extends ConsumerWidget {
return;
}
await ref.read(timelineServiceProvider).reloadBucket();
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'edit_location_action_prompt'.t(

View File

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

View File

@ -194,6 +194,28 @@ class ActionNotifier extends Notifier<void> {
);
}
}
Future<ActionResult?> editDateTime(
ActionSource source,
BuildContext context,
) async {
final ids = _getOwnedRemoteForSource(source);
try {
final isEdited = await _service.editDateTime(ids, context);
if (!isEdited) {
return null;
}
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to edit date and time for assets', error, stack);
return ActionResult(
count: ids.length,
success: false,
error: error.toString(),
);
}
}
}
extension on Iterable<RemoteAsset> {

View File

@ -79,6 +79,18 @@ class AssetApiRepository extends ApiRepository {
);
}
Future<void> updateDateTime(
List<String> ids,
String dateTime,
) async {
return _api.updateAssets(
AssetBulkUpdateDto(
ids: ids,
dateTimeOriginal: dateTime,
),
);
}
_mapVisibility(AssetVisibilityEnum visibility) => switch (visibility) {
AssetVisibilityEnum.timeline => AssetVisibility.timeline,
AssetVisibilityEnum.hidden => AssetVisibility.hidden,

View File

@ -8,9 +8,11 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/exif.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
import 'package:immich_mobile/widgets/common/location_picker.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:timezone/timezone.dart';
final actionServiceProvider = Provider<ActionService>(
(ref) => ActionService(
@ -99,7 +101,7 @@ class ActionService {
) async {
LatLng? initialLatLng;
if (remoteIds.length == 1) {
final exif = await _remoteExifRepository.get(remoteIds[0]);
final exif = await _remoteExifRepository.get(remoteIds.first);
if (exif?.latitude != null && exif?.longitude != null) {
initialLatLng = LatLng(exif!.latitude!, exif.longitude!);
@ -126,4 +128,61 @@ class ActionService {
return true;
}
Future<bool> editDateTime(
List<String> remoteIds,
BuildContext context,
) async {
DateTime? initialDateTime;
Duration? initialOffset;
String? initialTimeZone;
if (remoteIds.length == 1) {
final asset = await _remoteAssetRepository.get(remoteIds.first);
final exif = await _remoteExifRepository.get(remoteIds.first);
initialDateTime = asset?.localDateTime;
initialTimeZone = exif?.timeZone;
if (initialDateTime != null && initialTimeZone != null) {
try {
final location = getLocation(initialTimeZone);
initialOffset = TZDateTime.from(initialDateTime, location).timeZoneOffset;
} on LocationNotFoundException {
RegExp re = RegExp(
r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$',
caseSensitive: false,
);
final m = re.firstMatch(initialTimeZone);
if (m != null) {
final offset = Duration(
hours: int.parse(m.group(1) ?? '0'),
minutes: int.parse(m.group(2) ?? '0'),
);
initialOffset = offset;
}
}
}
}
final dateTime = await showDateTimePicker(
context: context,
initialDateTime: initialDateTime,
initialTZ: initialTimeZone,
initialTZOffset: initialOffset,
);
if (dateTime == null) {
return false;
}
await _assetApiRepository.updateDateTime(
remoteIds,
dateTime,
);
await _remoteAssetRepository.updateDateTime(
remoteIds,
dateTime,
);
return true;
}
}