diff --git a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart deleted file mode 100644 index c84e857eef..0000000000 --- a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart +++ /dev/null @@ -1,482 +0,0 @@ -import 'dart:io'; -import 'dart:math' as math; - -import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/asset_extensions.dart'; -import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/duration_extensions.dart'; -import 'package:immich_mobile/modules/asset_viewer/providers/asset_people.provider.dart'; -import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart'; -import 'package:immich_mobile/modules/map/widgets/map_thumbnail.dart'; -import 'package:immich_mobile/modules/search/models/curated_content.dart'; -import 'package:immich_mobile/modules/search/ui/curated_people_row.dart'; -import 'package:immich_mobile/modules/search/ui/person_name_edit_form.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/shared/models/asset.dart'; -import 'package:immich_mobile/shared/providers/asset.provider.dart'; -import 'package:immich_mobile/utils/selection_handlers.dart'; -import 'package:immich_mobile/utils/bytes_units.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; -import 'package:url_launcher/url_launcher.dart'; - -class ExifBottomSheet extends HookConsumerWidget { - final Asset asset; - - const ExifBottomSheet({super.key, required this.asset}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final assetWithExif = ref.watch(assetDetailProvider(asset)); - final exifInfo = (assetWithExif.value ?? asset).exifInfo; - final peopleProvider = - ref.watch(assetPeopleNotifierProvider(asset).notifier); - final people = ref.watch(assetPeopleNotifierProvider(asset)); - final double imageSize = math.min(context.width / 3, 150); - var textColor = context.isDarkTheme ? Colors.white : Colors.black; - - bool hasCoordinates() => - exifInfo != null && - exifInfo.latitude != null && - exifInfo.longitude != null && - exifInfo.latitude != 0 && - exifInfo.longitude != 0; - - String formattedDateTime() { - final (dt, timeZone) = - (assetWithExif.value ?? asset).getTZAdjustedTimeAndOffset(); - final date = DateFormat.yMMMEd().format(dt); - final time = DateFormat.jm().format(dt); - - return '$date • $time GMT${timeZone.formatAsOffset()}'; - } - - Future createCoordinatesUri() async { - if (!hasCoordinates()) { - return null; - } - - final double latitude = exifInfo!.latitude!; - final double longitude = exifInfo.longitude!; - - const zoomLevel = 16; - - if (Platform.isAndroid) { - Uri uri = Uri( - scheme: 'geo', - host: '$latitude,$longitude', - queryParameters: { - 'z': '$zoomLevel', - 'q': '$latitude,$longitude($formattedDateTime)', - }, - ); - if (await canLaunchUrl(uri)) { - return uri; - } - } else if (Platform.isIOS) { - var params = { - 'll': '$latitude,$longitude', - 'q': formattedDateTime, - 'z': '$zoomLevel', - }; - Uri uri = Uri.https('maps.apple.com', '/', params); - if (await canLaunchUrl(uri)) { - return uri; - } - } - - return Uri( - scheme: 'https', - host: 'openstreetmap.org', - queryParameters: {'mlat': '$latitude', 'mlon': '$longitude'}, - fragment: 'map=$zoomLevel/$latitude/$longitude', - ); - } - - buildMap() { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: LayoutBuilder( - builder: (context, constraints) { - return MapThumbnail( - centre: LatLng( - exifInfo?.latitude ?? 0, - exifInfo?.longitude ?? 0, - ), - height: 150, - width: constraints.maxWidth, - zoom: 12.0, - assetMarkerRemoteId: asset.remoteId, - onTap: (tapPosition, latLong) async { - Uri? uri = await createCoordinatesUri(); - - if (uri == null) { - return; - } - - debugPrint('Opening Map Uri: $uri'); - launchUrl(uri); - }, - ); - }, - ), - ); - } - - buildSizeText(Asset a) { - String resolution = a.width != null && a.height != null - ? "${a.height} x ${a.width} " - : ""; - String fileSize = a.exifInfo?.fileSize != null - ? formatBytes(a.exifInfo!.fileSize!) - : ""; - String text = resolution + fileSize; - return text.isNotEmpty ? text : null; - } - - buildLocation() { - // Guard no lat/lng - if (!hasCoordinates()) { - return asset.isRemote && !asset.isReadOnly - ? ListTile( - minLeadingWidth: 0, - contentPadding: const EdgeInsets.all(0), - leading: const Icon(Icons.location_on), - title: Text( - "exif_bottom_sheet_location_add", - style: context.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - color: context.primaryColor, - ), - ).tr(), - onTap: () => handleEditLocation( - ref, - context, - [assetWithExif.value ?? asset], - ), - ) - : const SizedBox.shrink(); - } - - return Column( - children: [ - // Location - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "exif_bottom_sheet_location", - style: context.textTheme.labelMedium?.copyWith( - color: - context.textTheme.labelMedium?.color?.withAlpha(200), - fontWeight: FontWeight.w600, - ), - ).tr(), - if (asset.isRemote && !asset.isReadOnly) - IconButton( - onPressed: () => handleEditLocation( - ref, - context, - [assetWithExif.value ?? asset], - ), - icon: const Icon(Icons.edit_outlined), - iconSize: 20, - ), - ], - ), - buildMap(), - RichText( - text: TextSpan( - style: context.textTheme.labelLarge, - children: [ - if (exifInfo != null && exifInfo.city != null) - TextSpan( - text: exifInfo.city, - ), - if (exifInfo != null && - exifInfo.city != null && - exifInfo.state != null) - const TextSpan( - text: ", ", - ), - if (exifInfo != null && exifInfo.state != null) - TextSpan( - text: "${exifInfo.state}", - ), - ], - ), - ), - Text( - "${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo.longitude!.toStringAsFixed(4)}", - style: context.textTheme.labelMedium?.copyWith( - color: context.textTheme.labelMedium?.color?.withAlpha(150), - ), - ), - ], - ), - ], - ); - } - - showPersonNameEditModel( - String personId, - String personName, - ) { - return showDialog( - context: context, - builder: (BuildContext context) { - return PersonNameEditForm(personId: personId, personName: personName); - }, - ).then((_) { - // ensure the people list is up-to-date. - peopleProvider.refresh(); - }); - } - - buildPeople() { - return people.widgetWhen( - onData: (data) { - // either the server is not reachable or this asset has no people - if (data.isEmpty) { - return Container(); - } - - final curatedPeople = - data.map((p) => CuratedContent(id: p.id, label: p.name)).toList(); - - return Column( - children: [ - Align( - alignment: Alignment.topLeft, - child: Text( - "exif_bottom_sheet_people", - style: context.textTheme.labelMedium?.copyWith( - color: context.textTheme.labelMedium?.color?.withAlpha(200), - fontWeight: FontWeight.w600, - ), - ).tr(), - ), - SizedBox( - height: imageSize, - child: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: CuratedPeopleRow( - content: curatedPeople, - onTap: (content, index) { - context - .pushRoute( - PersonResultRoute( - personId: content.id, - personName: content.label, - ), - ) - .then((_) => peopleProvider.refresh()); - }, - onNameTap: (person, index) => { - showPersonNameEditModel(person.id, person.label), - }, - ), - ), - ), - ], - ); - }, - ); - } - - buildDate() { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - formattedDateTime(), - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ), - if (asset.isRemote && !asset.isReadOnly) - IconButton( - onPressed: () => handleEditDateTime( - ref, - context, - [assetWithExif.value ?? asset], - ), - icon: const Icon(Icons.edit_outlined), - iconSize: 20, - ), - ], - ); - } - - buildImageProperties() { - // Helper to create the ListTile and avoid repeating code - createImagePropertiesListStyle(title, subtitle) => ListTile( - contentPadding: const EdgeInsets.all(0), - dense: true, - leading: Icon( - Icons.image, - color: textColor.withAlpha(200), - ), - titleAlignment: ListTileTitleAlignment.center, - title: Text( - title, - style: context.textTheme.labelLarge, - ), - subtitle: subtitle, - ); - - final imgSizeString = buildSizeText(asset); - - if (imgSizeString == null && asset.fileName.isNotEmpty) { - // There is only filename - return createImagePropertiesListStyle( - asset.fileName, - null, - ); - } else if (imgSizeString != null && asset.fileName.isNotEmpty) { - // There is both filename and size information - return createImagePropertiesListStyle( - asset.fileName, - Text(imgSizeString, style: context.textTheme.bodySmall), - ); - } else if (imgSizeString != null && asset.fileName.isEmpty) { - // There is only size information - return createImagePropertiesListStyle( - imgSizeString, - null, - ); - } - } - - buildDetail() { - final imgProperties = buildImageProperties(); - - // There are no details - if (imgProperties == null && - (exifInfo == null || exifInfo.make == null)) { - return Container(); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: Text( - "exif_bottom_sheet_details", - style: context.textTheme.labelMedium?.copyWith( - color: context.textTheme.labelMedium?.color?.withAlpha(200), - fontWeight: FontWeight.w600, - ), - ).tr(), - ), - if (imgProperties != null) imgProperties, - if (exifInfo?.make != null) - ListTile( - contentPadding: const EdgeInsets.all(0), - dense: true, - leading: Icon( - Icons.camera, - color: textColor.withAlpha(200), - ), - title: Text( - "${exifInfo!.make} ${exifInfo.model}", - style: context.textTheme.labelLarge, - ), - subtitle: exifInfo.f != null || - exifInfo.exposureSeconds != null || - exifInfo.mm != null || - exifInfo.iso != null - ? Text( - "ƒ/${exifInfo.fNumber} ${exifInfo.exposureTime} ${exifInfo.focalLength} mm ISO ${exifInfo.iso ?? ''} ", - style: context.textTheme.bodySmall, - ) - : null, - ), - ], - ); - } - - return SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 16.0), - child: LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth > 600) { - // Two column - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - buildDate(), - if (asset.isRemote) DescriptionInput(asset: asset), - Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.only(right: 8.0), - child: buildLocation(), - ), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.only(right: 8.0), - child: buildPeople(), - ), - ), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 300), - child: Padding( - padding: const EdgeInsets.only(left: 8.0), - child: buildDetail(), - ), - ), - ], - ), - const SizedBox(height: 50), - ], - ); - } - - // One column - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - buildDate(), - assetWithExif.when( - data: (data) => DescriptionInput(asset: data), - error: (error, stackTrace) => Icon( - Icons.image_not_supported_outlined, - color: context.primaryColor, - ), - loading: () => const SizedBox( - width: 75, - height: 75, - child: CircularProgressIndicator.adaptive(), - ), - ), - const SizedBox(height: 16), - buildPeople(), - buildLocation(), - SizedBox(height: hasCoordinates() ? 16.0 : 6.0), - buildDetail(), - const SizedBox(height: 50), - ], - ); - }, - ), - ), - ); - } -} diff --git a/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_bottom_sheet.dart b/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_bottom_sheet.dart new file mode 100644 index 0000000000..00d5a1ae6b --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_bottom_sheet.dart @@ -0,0 +1,210 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/asset_extensions.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/duration_extensions.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/exif_sheet/exif_detail.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/exif_sheet/exif_image_properties.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/exif_sheet/exif_location.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/exif_sheet/exif_people.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/models/exif_info.dart'; +import 'package:immich_mobile/shared/providers/asset.provider.dart'; +import 'package:immich_mobile/utils/selection_handlers.dart'; + +class ExifBottomSheet extends HookConsumerWidget { + final Asset asset; + + const ExifBottomSheet({super.key, required this.asset}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final assetWithExif = ref.watch(assetDetailProvider(asset)); + var textColor = context.isDarkTheme ? Colors.white : Colors.black; + final ExifInfo? exifInfo = (assetWithExif.value ?? asset).exifInfo; + // Format the date time with the timezone + final (dt, timeZone) = + (assetWithExif.value ?? asset).getTZAdjustedTimeAndOffset(); + final date = DateFormat.yMMMEd().format(dt); + final time = DateFormat.jm().format(dt); + + String formattedDateTime = '$date • $time GMT${timeZone.formatAsOffset()}'; + + final dateWidget = Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + formattedDateTime, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + if (asset.isRemote && !asset.isReadOnly) + IconButton( + onPressed: () => handleEditDateTime( + ref, + context, + [assetWithExif.value ?? asset], + ), + icon: const Icon(Icons.edit_outlined), + iconSize: 20, + ), + ], + ); + + return SingleChildScrollView( + padding: const EdgeInsets.only( + bottom: 50, + ), + child: LayoutBuilder( + builder: (context, constraints) { + final horizontalPadding = constraints.maxWidth > 600 ? 24.0 : 16.0; + if (constraints.maxWidth > 600) { + // Two column + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), + child: Column( + children: [ + dateWidget, + if (asset.isRemote) DescriptionInput(asset: asset), + ], + ), + ), + ExifPeople( + asset: asset, + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + ), + ), + Padding( + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 8.0), + child: ExifLocation( + asset: asset, + exifInfo: exifInfo, + editLocation: () => handleEditLocation( + ref, + context, + [assetWithExif.value ?? asset], + ), + formattedDateTime: formattedDateTime, + ), + ), + ), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300), + child: Padding( + padding: const EdgeInsets.only(left: 8.0), + child: ExifDetail(asset: asset, exifInfo: exifInfo), + ), + ), + ], + ), + ), + ], + ); + } + + // One column + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + ), + child: Column( + children: [ + dateWidget, + if (asset.isRemote) DescriptionInput(asset: asset), + Padding( + padding: EdgeInsets.only(top: asset.isRemote ? 0 : 16.0), + child: ExifLocation( + asset: asset, + exifInfo: exifInfo, + editLocation: () => handleEditLocation( + ref, + context, + [assetWithExif.value ?? asset], + ), + formattedDateTime: formattedDateTime, + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: ExifPeople( + asset: asset, + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + ), + ), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text( + "exif_bottom_sheet_details", + style: context.textTheme.labelMedium?.copyWith( + color: context.textTheme.labelMedium?.color + ?.withAlpha(200), + fontWeight: FontWeight.w600, + ), + ).tr(), + ), + ExifImageProperties(asset: asset), + if (exifInfo?.make != null) + ListTile( + contentPadding: const EdgeInsets.all(0), + dense: true, + leading: Icon( + Icons.camera, + color: textColor.withAlpha(200), + ), + title: Text( + "${exifInfo!.make} ${exifInfo.model}", + style: context.textTheme.labelLarge, + ), + subtitle: exifInfo.f != null || + exifInfo.exposureSeconds != null || + exifInfo.mm != null || + exifInfo.iso != null + ? Text( + "ƒ/${exifInfo.fNumber} ${exifInfo.exposureTime} ${exifInfo.focalLength} mm ISO ${exifInfo.iso ?? ''} ", + style: context.textTheme.bodySmall, + ) + : null, + ), + ], + ), + ), + const SizedBox(height: 50), + ], + ); + }, + ), + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_detail.dart b/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_detail.dart new file mode 100644 index 0000000000..4f49066206 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_detail.dart @@ -0,0 +1,60 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/exif_sheet/exif_image_properties.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/models/exif_info.dart'; + +class ExifDetail extends StatelessWidget { + final Asset asset; + final ExifInfo? exifInfo; + + const ExifDetail({ + super.key, + required this.asset, + this.exifInfo, + }); + + @override + Widget build(BuildContext context) { + final textColor = context.isDarkTheme ? Colors.white : Colors.black; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text( + "exif_bottom_sheet_details", + style: context.textTheme.labelMedium?.copyWith( + color: context.textTheme.labelMedium?.color?.withAlpha(200), + fontWeight: FontWeight.w600, + ), + ).tr(), + ), + ExifImageProperties(asset: asset), + if (exifInfo?.make != null) + ListTile( + contentPadding: const EdgeInsets.all(0), + dense: true, + leading: Icon( + Icons.camera, + color: textColor.withAlpha(200), + ), + title: Text( + "${exifInfo?.make} ${exifInfo?.model}", + style: context.textTheme.labelLarge, + ), + subtitle: exifInfo?.f != null || + exifInfo?.exposureSeconds != null || + exifInfo?.mm != null || + exifInfo?.iso != null + ? Text( + "ƒ/${exifInfo?.fNumber} ${exifInfo?.exposureTime} ${exifInfo?.focalLength} mm ISO ${exifInfo?.iso ?? ''} ", + style: context.textTheme.bodySmall, + ) + : null, + ), + ], + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_image_properties.dart b/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_image_properties.dart new file mode 100644 index 0000000000..4f584d1c9c --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_image_properties.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/utils/bytes_units.dart'; + +class ExifImageProperties extends StatelessWidget { + final Asset asset; + + const ExifImageProperties({ + super.key, + required this.asset, + }); + + @override + Widget build(BuildContext context) { + final textColor = context.isDarkTheme ? Colors.white : Colors.black; + + String resolution = asset.width != null && asset.height != null + ? "${asset.height} x ${asset.width} " + : ""; + String fileSize = asset.exifInfo?.fileSize != null + ? formatBytes(asset.exifInfo!.fileSize!) + : ""; + String text = resolution + fileSize; + final imgSizeString = text.isNotEmpty ? text : null; + + String? title; + String? subtitle; + + if (imgSizeString == null && asset.fileName.isNotEmpty) { + // There is only filename + title = asset.fileName; + } else if (imgSizeString != null && asset.fileName.isNotEmpty) { + // There is both filename and size information + title = asset.fileName; + subtitle = imgSizeString; + } else if (imgSizeString != null && asset.fileName.isEmpty) { + title = imgSizeString; + } else { + return const SizedBox.shrink(); + } + + return ListTile( + contentPadding: const EdgeInsets.all(0), + dense: true, + leading: Icon( + Icons.image, + color: textColor.withAlpha(200), + ), + titleAlignment: ListTileTitleAlignment.center, + title: Text( + title, + style: context.textTheme.labelLarge, + ), + subtitle: subtitle == null ? null : Text(subtitle), + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_location.dart b/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_location.dart new file mode 100644 index 0000000000..c4a8b9d508 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_location.dart @@ -0,0 +1,105 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/exif_sheet/exif_map.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/models/exif_info.dart'; + +class ExifLocation extends StatelessWidget { + final Asset asset; + final ExifInfo? exifInfo; + final void Function() editLocation; + final String formattedDateTime; + + const ExifLocation({ + super.key, + required this.asset, + required this.exifInfo, + required this.editLocation, + required this.formattedDateTime, + }); + + @override + Widget build(BuildContext context) { + final hasCoordinates = exifInfo?.hasCoordinates ?? false; + // Guard no lat/lng + if (!hasCoordinates) { + return asset.isRemote && !asset.isReadOnly + ? ListTile( + minLeadingWidth: 0, + contentPadding: const EdgeInsets.all(0), + leading: const Icon(Icons.location_on), + title: Text( + "exif_bottom_sheet_location_add", + style: context.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: context.primaryColor, + ), + ).tr(), + onTap: editLocation, + ) + : const SizedBox.shrink(); + } + + return Column( + children: [ + // Location + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "exif_bottom_sheet_location", + style: context.textTheme.labelMedium?.copyWith( + color: context.textTheme.labelMedium?.color?.withAlpha(200), + fontWeight: FontWeight.w600, + ), + ).tr(), + if (asset.isRemote && !asset.isReadOnly) + IconButton( + onPressed: editLocation, + icon: const Icon(Icons.edit_outlined), + iconSize: 20, + ), + ], + ), + ExifMap( + exifInfo: exifInfo!, + formattedDateTime: formattedDateTime, + markerId: asset.remoteId, + ), + RichText( + text: TextSpan( + style: context.textTheme.labelLarge, + children: [ + if (exifInfo != null && exifInfo?.city != null) + TextSpan( + text: exifInfo!.city, + ), + if (exifInfo != null && + exifInfo?.city != null && + exifInfo?.state != null) + const TextSpan( + text: ", ", + ), + if (exifInfo != null && exifInfo?.state != null) + TextSpan( + text: exifInfo!.state, + ), + ], + ), + ), + Text( + "${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo!.longitude!.toStringAsFixed(4)}", + style: context.textTheme.labelMedium?.copyWith( + color: context.textTheme.labelMedium?.color?.withAlpha(150), + ), + ), + ], + ), + ], + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_map.dart b/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_map.dart new file mode 100644 index 0000000000..6c0050aeea --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_map.dart @@ -0,0 +1,94 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:immich_mobile/modules/map/widgets/map_thumbnail.dart'; +import 'package:immich_mobile/shared/models/exif_info.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class ExifMap extends StatelessWidget { + final ExifInfo exifInfo; + final String formattedDateTime; + final String? markerId; + + const ExifMap({ + super.key, + required this.exifInfo, + required this.formattedDateTime, + this.markerId = 'marker', + }); + + @override + Widget build(BuildContext context) { + final hasCoordinates = exifInfo.hasCoordinates; + Future createCoordinatesUri() async { + if (!hasCoordinates) { + return null; + } + + final double latitude = exifInfo.latitude!; + final double longitude = exifInfo.longitude!; + + const zoomLevel = 16; + + if (Platform.isAndroid) { + Uri uri = Uri( + scheme: 'geo', + host: '$latitude,$longitude', + queryParameters: { + 'z': '$zoomLevel', + 'q': '$latitude,$longitude($formattedDateTime)', + }, + ); + if (await canLaunchUrl(uri)) { + return uri; + } + } else if (Platform.isIOS) { + var params = { + 'll': '$latitude,$longitude', + 'q': formattedDateTime, + 'z': '$zoomLevel', + }; + Uri uri = Uri.https('maps.apple.com', '/', params); + if (await canLaunchUrl(uri)) { + return uri; + } + } + + return Uri( + scheme: 'https', + host: 'openstreetmap.org', + queryParameters: {'mlat': '$latitude', 'mlon': '$longitude'}, + fragment: 'map=$zoomLevel/$latitude/$longitude', + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: LayoutBuilder( + builder: (context, constraints) { + return MapThumbnail( + centre: LatLng( + exifInfo.latitude ?? 0, + exifInfo.longitude ?? 0, + ), + height: 150, + width: constraints.maxWidth, + zoom: 12.0, + assetMarkerRemoteId: markerId, + onTap: (tapPosition, latLong) async { + Uri? uri = await createCoordinatesUri(); + + if (uri == null) { + return; + } + + debugPrint('Opening Map Uri: $uri'); + launchUrl(uri); + }, + ); + }, + ), + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_people.dart b/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_people.dart new file mode 100644 index 0000000000..a94a1239f6 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_people.dart @@ -0,0 +1,94 @@ +import 'dart:math' as math; + +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/asset_people.provider.dart'; +import 'package:immich_mobile/modules/search/models/curated_content.dart'; +import 'package:immich_mobile/modules/search/ui/curated_people_row.dart'; +import 'package:immich_mobile/modules/search/ui/person_name_edit_form.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; + +class ExifPeople extends ConsumerWidget { + final Asset asset; + final EdgeInsets? padding; + + const ExifPeople({super.key, required this.asset, this.padding}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final peopleProvider = + ref.watch(assetPeopleNotifierProvider(asset).notifier); + final people = ref.watch(assetPeopleNotifierProvider(asset)); + final double imageSize = math.min(context.width / 3, 150); + + showPersonNameEditModel( + String personId, + String personName, + ) { + return showDialog( + context: context, + builder: (BuildContext context) { + return PersonNameEditForm(personId: personId, personName: personName); + }, + ).then((_) { + // ensure the people list is up-to-date. + peopleProvider.refresh(); + }); + } + + if (people.value?.isEmpty ?? true) { + // Empty list or loading + return Container(); + } + + final curatedPeople = people.value + ?.map((p) => CuratedContent(id: p.id, label: p.name)) + .toList() ?? + []; + + return Column( + children: [ + Padding( + padding: padding ?? EdgeInsets.zero, + child: Align( + alignment: Alignment.topLeft, + child: Text( + "exif_bottom_sheet_people", + style: context.textTheme.labelMedium?.copyWith( + color: context.textTheme.labelMedium?.color?.withAlpha(200), + fontWeight: FontWeight.w600, + ), + ).tr(), + ), + ), + SizedBox( + height: imageSize, + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: CuratedPeopleRow( + padding: padding, + content: curatedPeople, + onTap: (content, index) { + context + .pushRoute( + PersonResultRoute( + personId: content.id, + personName: content.label, + ), + ) + .then((_) => peopleProvider.refresh()); + }, + onNameTap: (person, index) => { + showPersonNameEditModel(person.id, person.label), + }, + ), + ), + ), + ], + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 09225a35fc..059c0c976d 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -15,7 +15,7 @@ import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provi import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/bottom_gallery_bar.dart'; -import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/exif_sheet/exif_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/gallery_app_bar.dart'; import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; @@ -133,15 +133,18 @@ class GalleryViewerPage extends HookConsumerWidget { context: context, useSafeArea: true, builder: (context) { - return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.viewInsetsOf(context).bottom, + return FractionallySizedBox( + heightFactor: 0.75, + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewInsetsOf(context).bottom, + ), + child: ref + .watch(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.advancedTroubleshooting) + ? AdvancedBottomSheet(assetDetail: asset) + : ExifBottomSheet(asset: asset), ), - child: ref - .watch(appSettingsServiceProvider) - .getSetting(AppSettingsEnum.advancedTroubleshooting) - ? AdvancedBottomSheet(assetDetail: asset) - : ExifBottomSheet(asset: asset), ); }, ); diff --git a/mobile/lib/modules/search/ui/curated_people_row.dart b/mobile/lib/modules/search/ui/curated_people_row.dart index f85f13e602..78dc1af4f1 100644 --- a/mobile/lib/modules/search/ui/curated_people_row.dart +++ b/mobile/lib/modules/search/ui/curated_people_row.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/utils/image_url_builder.dart'; class CuratedPeopleRow extends StatelessWidget { final List content; + final EdgeInsets? padding; /// Callback with the content and the index when tapped final Function(CuratedContent, int)? onTap; @@ -16,6 +17,7 @@ class CuratedPeopleRow extends StatelessWidget { super.key, required this.content, this.onTap, + this.padding, required this.onNameTap, }); @@ -43,6 +45,7 @@ class CuratedPeopleRow extends StatelessWidget { } return ListView.builder( + padding: padding, scrollDirection: Axis.horizontal, itemBuilder: (context, index) { final person = content[index]; diff --git a/mobile/lib/shared/models/exif_info.dart b/mobile/lib/shared/models/exif_info.dart index a61fd2c289..f2bd02375c 100644 --- a/mobile/lib/shared/models/exif_info.dart +++ b/mobile/lib/shared/models/exif_info.dart @@ -24,6 +24,10 @@ class ExifInfo { String? country; String? description; + @ignore + bool get hasCoordinates => + latitude != null && longitude != null && latitude != 0 && longitude != 0; + @ignore String get exposureTime { if (exposureSeconds == null) { diff --git a/mobile/lib/shared/providers/asset.provider.dart b/mobile/lib/shared/providers/asset.provider.dart index ad163d5cd3..bbe3cefc0b 100644 --- a/mobile/lib/shared/providers/asset.provider.dart +++ b/mobile/lib/shared/providers/asset.provider.dart @@ -329,7 +329,9 @@ final assetDetailProvider = yield await ref.watch(assetServiceProvider).loadExif(asset); final db = ref.watch(dbProvider); await for (final a in db.assets.watchObject(asset.id)) { - if (a != null) yield await ref.watch(assetServiceProvider).loadExif(a); + if (a != null) { + yield await ref.watch(assetServiceProvider).loadExif(a); + } } });