mirror of
https://github.com/immich-app/immich.git
synced 2025-08-11 09:16:31 -04:00
* feat: drift edit time and date action * feat: add edit button on asset viewer bottom sheet * update localDateTime column in addition to createdAt to keep consistency * fix: dont update local dateTime Server calcs this anyway and it will be synced when the change is applied. We don't use localDateTime on mobile so there is no reason to update this value * fix: padding around edit icon in ListTile Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> * chore: format * fix: hide date edit control when asset does not have a remote * fix: pull timezones correctly from image --------- Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
335 lines
13 KiB
Dart
335 lines
13 KiB
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/translate_extensions.dart';
|
|
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
|
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
|
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
|
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/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 = ' • ';
|
|
|
|
class AssetDetailBottomSheet extends ConsumerWidget {
|
|
final DraggableScrollableController? controller;
|
|
final double initialChildSize;
|
|
|
|
const AssetDetailBottomSheet({this.controller, this.initialChildSize = 0.35, super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final asset = ref.watch(currentAssetNotifier);
|
|
if (asset == null) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
|
|
|
final isInLockedView = ref.watch(inLockedViewProvider);
|
|
|
|
final actions = <Widget>[
|
|
const ShareActionButton(source: ActionSource.viewer),
|
|
if (asset.hasRemote) ...[
|
|
const ShareLinkActionButton(source: ActionSource.viewer),
|
|
const ArchiveActionButton(source: ActionSource.viewer),
|
|
if (!asset.hasLocal) const DownloadActionButton(source: ActionSource.viewer),
|
|
isTrashEnable
|
|
? const TrashActionButton(source: ActionSource.viewer)
|
|
: const DeletePermanentActionButton(source: ActionSource.viewer),
|
|
const DeleteActionButton(source: ActionSource.viewer),
|
|
const MoveToLockFolderActionButton(source: ActionSource.viewer),
|
|
],
|
|
if (asset.storage == AssetState.local) ...[
|
|
const DeleteLocalActionButton(source: ActionSource.viewer),
|
|
const UploadActionButton(source: ActionSource.timeline),
|
|
],
|
|
];
|
|
|
|
final lockedViewActions = <Widget>[];
|
|
|
|
return BaseBottomSheet(
|
|
actions: isInLockedView ? lockedViewActions : actions,
|
|
slivers: const [_AssetDetailBottomSheet()],
|
|
controller: controller,
|
|
initialChildSize: initialChildSize,
|
|
minChildSize: 0.1,
|
|
maxChildSize: 0.88,
|
|
expand: false,
|
|
shouldCloseOnMinExtent: false,
|
|
resizeOnScroll: false,
|
|
backgroundColor: context.isDarkTheme ? Colors.black : Colors.white,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AssetDetailBottomSheet extends ConsumerWidget {
|
|
const _AssetDetailBottomSheet();
|
|
|
|
String _getDateTime(BuildContext ctx, BaseAsset asset) {
|
|
final dateTime = asset.createdAt.toLocal();
|
|
final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime);
|
|
final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime);
|
|
final timezone = dateTime.timeZoneOffset.isNegative
|
|
? 'UTC-${dateTime.timeZoneOffset.inHours.abs().toString().padLeft(2, '0')}:${(dateTime.timeZoneOffset.inMinutes.abs() % 60).toString().padLeft(2, '0')}'
|
|
: 'UTC+${dateTime.timeZoneOffset.inHours.toString().padLeft(2, '0')}:${(dateTime.timeZoneOffset.inMinutes.abs() % 60).toString().padLeft(2, '0')}';
|
|
return '$date$_kSeparator$time $timezone';
|
|
}
|
|
|
|
String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) {
|
|
final height = asset.height ?? exifInfo?.height;
|
|
final width = asset.width ?? exifInfo?.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 fNumber = exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null;
|
|
final exposureTime = exifInfo.exposureTime.isNotEmpty ? exifInfo.exposureTime : null;
|
|
final focalLength = exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null;
|
|
final iso = exifInfo.iso != null ? 'ISO ${exifInfo.iso}' : null;
|
|
|
|
return [fNumber, exposureTime, focalLength, iso].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final asset = ref.watch(currentAssetNotifier);
|
|
if (asset == null) {
|
|
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
|
}
|
|
|
|
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(
|
|
title: 'exif_bottom_sheet_details'.t(context: context),
|
|
titleStyle: context.textTheme.labelMedium?.copyWith(
|
|
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
// File info
|
|
_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.textTheme.bodyMedium?.color?.withAlpha(155),
|
|
),
|
|
),
|
|
// Camera info
|
|
if (cameraTitle != null)
|
|
_SheetTile(
|
|
title: cameraTitle,
|
|
titleStyle: context.textTheme.labelLarge,
|
|
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
|
|
subtitle: _getCameraInfoSubtitle(exifInfo),
|
|
subtitleStyle: context.textTheme.bodyMedium?.copyWith(
|
|
color: context.textTheme.bodyMedium?.color?.withAlpha(155),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
this.trailing,
|
|
this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final Widget titleWidget;
|
|
if (leading == null) {
|
|
titleWidget = LimitedBox(
|
|
maxWidth: double.infinity,
|
|
child: Text(title, style: titleStyle),
|
|
);
|
|
} else {
|
|
titleWidget = Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.only(left: 15),
|
|
child: Text(title, style: titleStyle),
|
|
);
|
|
}
|
|
|
|
final Widget? subtitleWidget;
|
|
if (leading == null && subtitle != null) {
|
|
subtitleWidget = Text(subtitle!, style: subtitleStyle);
|
|
} else if (leading != null && subtitle != null) {
|
|
subtitleWidget = Padding(
|
|
padding: const EdgeInsets.only(left: 15),
|
|
child: Text(subtitle!, style: subtitleStyle),
|
|
);
|
|
} else {
|
|
subtitleWidget = null;
|
|
}
|
|
|
|
return ListTile(
|
|
dense: true,
|
|
visualDensity: VisualDensity.compact,
|
|
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),
|
|
),
|
|
);
|
|
}
|
|
}
|