You've already forked immich
							
							
				mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 00:18:28 +02:00 
			
		
		
		
	feat: Add description (#2237)
* Added dto, logic to insert description and web implementation * create text field and update on remote database * Update description and save changes * styling * fix web test * fix server test * preserve description on metadata extraction job run * handle exif info is null situation * pr feedback * format openapi spec * update createAssetDto * refactor logic to service * move files * only owner can update description * Render description correctly in shared album * Render description correctly in shared link * disable description edit for not owner of asset on mobile * localization and clean up * fix test * Uses providers for description text (#2244) * uses providers for description text * comments * fixes initial data setting * fixes notifier --------- Co-authored-by: martyfuhry <martyfuhry@gmail.com>
This commit is contained in:
		| @@ -262,5 +262,11 @@ | ||||
|   "version_announcement_overlay_text_1": "Hi friend, there is a new release of", | ||||
|   "version_announcement_overlay_text_2": "please take your time to visit the ", | ||||
|   "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", | ||||
|   "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89" | ||||
|   "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", | ||||
|   "advanced_settings_tile_title": "Advanced", | ||||
|   "advanced_settings_tile_subtitle": "Advanced user's settings", | ||||
|   "advanced_settings_troubleshooting_title": "Troubleshooting", | ||||
|   "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", | ||||
|   "description_input_submit_error": "Error updating description, check the log for more details", | ||||
|   "description_input_hint_text": "Add description..." | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								mobile/flutter_01.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								mobile/flutter_01.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 679 KiB | 
| @@ -0,0 +1,93 @@ | ||||
| import 'dart:async'; | ||||
|  | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/asset_viewer/services/asset_description.service.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/models/exif_info.dart'; | ||||
| import 'package:immich_mobile/shared/providers/db.provider.dart'; | ||||
| import 'package:isar/isar.dart'; | ||||
|  | ||||
| class AssetDescriptionNotifier extends StateNotifier<String> { | ||||
|   final Isar _db; | ||||
|   final AssetDescriptionService _service; | ||||
|   final Asset _asset; | ||||
|  | ||||
|   AssetDescriptionNotifier( | ||||
|     this._db, | ||||
|     this._service,  | ||||
|     this._asset, | ||||
|   ) : super('') { | ||||
|     _fetchLocalDescription(); | ||||
|     _fetchRemoteDescription(); | ||||
|   } | ||||
|  | ||||
|   String get description => state; | ||||
|  | ||||
|   /// Fetches the local database value for description | ||||
|   /// and writes it to [state] | ||||
|   void _fetchLocalDescription() async { | ||||
|     final localExifId = _asset.exifInfo?.id; | ||||
|  | ||||
|     // Guard [localExifId] null | ||||
|     if (localExifId == null) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Subscribe to local changes | ||||
|     final exifInfo = await _db | ||||
|         .exifInfos | ||||
|         .get(localExifId); | ||||
|  | ||||
|     // Guard | ||||
|     if (exifInfo?.description == null) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     state = exifInfo!.description!; | ||||
|   } | ||||
|  | ||||
|   /// Fetches the remote value and sets the state | ||||
|   void _fetchRemoteDescription() async { | ||||
|     final remoteAssetId = _asset.remoteId; | ||||
|     final localExifId = _asset.exifInfo?.id; | ||||
|  | ||||
|     // Guard [remoteAssetId] and [localExifId] null | ||||
|     if (remoteAssetId == null || localExifId == null) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Reads the latest from the remote and writes it to DB in the service | ||||
|     final latest = await _service.readLatest(remoteAssetId, localExifId); | ||||
|  | ||||
|     state = latest; | ||||
|   } | ||||
|  | ||||
|   /// Sets the description to [description] | ||||
|   /// Uses the service to set the asset value | ||||
|   Future<void> setDescription(String description) async { | ||||
|     state = description; | ||||
|  | ||||
|     final remoteAssetId = _asset.remoteId; | ||||
|     final localExifId = _asset.exifInfo?.id; | ||||
|  | ||||
|     // Guard [remoteAssetId] and [localExifId] null | ||||
|     if (remoteAssetId == null || localExifId == null) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     return _service | ||||
|         .setDescription(description, remoteAssetId, localExifId); | ||||
|   } | ||||
| } | ||||
|  | ||||
| final assetDescriptionProvider = StateNotifierProvider | ||||
|     .autoDispose | ||||
|     .family<AssetDescriptionNotifier, String, Asset>( | ||||
|   (ref, asset) => AssetDescriptionNotifier( | ||||
|     ref.watch(dbProvider), | ||||
|     ref.watch(assetDescriptionServiceProvider), | ||||
|     asset, | ||||
|   ), | ||||
| ); | ||||
|  | ||||
|  | ||||
| @@ -0,0 +1,62 @@ | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/shared/models/exif_info.dart'; | ||||
| import 'package:immich_mobile/shared/providers/api.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/db.provider.dart'; | ||||
| import 'package:immich_mobile/shared/services/api.service.dart'; | ||||
| import 'package:isar/isar.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
|  | ||||
| class AssetDescriptionService { | ||||
|   AssetDescriptionService(this._db, this._api); | ||||
|  | ||||
|   final Isar _db; | ||||
|   final ApiService _api; | ||||
|  | ||||
|   setDescription( | ||||
|     String description, | ||||
|     String remoteAssetId, | ||||
|     int localExifId, | ||||
|   ) async { | ||||
|     final result = await _api.assetApi.updateAsset( | ||||
|       remoteAssetId, | ||||
|       UpdateAssetDto(description: description), | ||||
|     ); | ||||
|  | ||||
|     if (result?.exifInfo?.description != null) { | ||||
|       var exifInfo = await _db.exifInfos.get(localExifId); | ||||
|  | ||||
|       if (exifInfo != null) { | ||||
|         exifInfo.description = result!.exifInfo!.description; | ||||
|         await _db.writeTxn( | ||||
|           () => _db.exifInfos.put(exifInfo), | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<String> readLatest(String assetRemoteId, int localExifId) async { | ||||
|     final latestAssetFromServer = | ||||
|         await _api.assetApi.getAssetById(assetRemoteId); | ||||
|     final localExifInfo = await _db.exifInfos.get(localExifId); | ||||
|  | ||||
|     if (latestAssetFromServer != null && localExifInfo != null) { | ||||
|       localExifInfo.description = | ||||
|           latestAssetFromServer.exifInfo?.description ?? ''; | ||||
|  | ||||
|       await _db.writeTxn( | ||||
|         () => _db.exifInfos.put(localExifInfo), | ||||
|       ); | ||||
|  | ||||
|       return localExifInfo.description!; | ||||
|     } | ||||
|  | ||||
|     return ""; | ||||
|   } | ||||
| } | ||||
|  | ||||
| final assetDescriptionServiceProvider = Provider( | ||||
|   (ref) => AssetDescriptionService( | ||||
|     ref.watch(dbProvider), | ||||
|     ref.watch(apiServiceProvider), | ||||
|   ), | ||||
| ); | ||||
							
								
								
									
										103
									
								
								mobile/lib/modules/asset_viewer/ui/description_input.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								mobile/lib/modules/asset_viewer/ui/description_input.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,103 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/asset_viewer/providers/asset_description.provider.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_toast.dart'; | ||||
| import 'package:logging/logging.dart'; | ||||
| import 'package:immich_mobile/shared/models/store.dart' as store; | ||||
|  | ||||
| class DescriptionInput extends HookConsumerWidget { | ||||
|   DescriptionInput({ | ||||
|     super.key, | ||||
|     required this.asset, | ||||
|   }); | ||||
|  | ||||
|   final Asset asset; | ||||
|   final Logger _log = Logger('DescriptionInput'); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final isDarkTheme = Theme.of(context).brightness == Brightness.dark; | ||||
|     final textColor = isDarkTheme ? Colors.white : Colors.black; | ||||
|     final controller = useTextEditingController(); | ||||
|     final focusNode = useFocusNode(); | ||||
|     final isFocus = useState(false); | ||||
|     final isTextEmpty = useState(controller.text.isEmpty); | ||||
|     final descriptionProvider = ref.watch(assetDescriptionProvider(asset).notifier); | ||||
|     final description = ref.watch(assetDescriptionProvider(asset)); | ||||
|     final owner = store.Store.get(store.StoreKey.currentUser); | ||||
|     final hasError = useState(false); | ||||
|  | ||||
|     controller.text = description; | ||||
|  | ||||
|     submitDescription(String description) async { | ||||
|       hasError.value = false; | ||||
|       try { | ||||
|         await descriptionProvider.setDescription( | ||||
|           description, | ||||
|         ); | ||||
|       } catch (error, stack) { | ||||
|         hasError.value = true; | ||||
|         _log.severe("Error updating description $error", error, stack); | ||||
|         ImmichToast.show( | ||||
|           context: context, | ||||
|           msg: "description_input_submit_error".tr(), | ||||
|           toastType: ToastType.error, | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Widget? suffixIcon; | ||||
|     if (hasError.value) { | ||||
|       suffixIcon = const Icon(Icons.warning_outlined); | ||||
|     } else if (!isTextEmpty.value && isFocus.value) { | ||||
|       suffixIcon = IconButton( | ||||
|         onPressed: () { | ||||
|           controller.clear(); | ||||
|           isTextEmpty.value = true; | ||||
|         }, | ||||
|         icon: Icon( | ||||
|           Icons.cancel_rounded, | ||||
|           color: Colors.grey[500], | ||||
|         ), | ||||
|         splashRadius: 10, | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return TextField( | ||||
|       enabled: owner.isarId == asset.ownerId, | ||||
|       focusNode: focusNode, | ||||
|       onTap: () => isFocus.value = true, | ||||
|       onChanged: (value) { | ||||
|         isTextEmpty.value = false; | ||||
|       }, | ||||
|       onTapOutside: (a) async { | ||||
|         isFocus.value = false; | ||||
|         focusNode.unfocus(); | ||||
|  | ||||
|         if (description != controller.text) { | ||||
|           await submitDescription(controller.text); | ||||
|         } | ||||
|       }, | ||||
|       autofocus: false, | ||||
|       maxLines: null, | ||||
|       keyboardType: TextInputType.multiline, | ||||
|       controller: controller, | ||||
|       style: const TextStyle( | ||||
|         fontSize: 14, | ||||
|       ), | ||||
|       decoration: InputDecoration( | ||||
|         hintText: 'description_input_hint_text'.tr(), | ||||
|         border: InputBorder.none, | ||||
|         hintStyle: TextStyle( | ||||
|           fontWeight: FontWeight.normal, | ||||
|           fontSize: 12, | ||||
|           color: textColor.withOpacity(0.5), | ||||
|         ), | ||||
|         suffixIcon: suffixIcon, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -2,25 +2,25 @@ import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_map/flutter_map.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/models/exif_info.dart'; | ||||
| import 'package:immich_mobile/shared/ui/drag_sheet.dart'; | ||||
| import 'package:latlong2/latlong.dart'; | ||||
| import 'package:immich_mobile/utils/bytes_units.dart'; | ||||
|  | ||||
| class ExifBottomSheet extends HookConsumerWidget { | ||||
|   final Asset assetDetail; | ||||
|   final Asset asset; | ||||
|  | ||||
|   const ExifBottomSheet({Key? key, required this.assetDetail}) | ||||
|       : super(key: key); | ||||
|   const ExifBottomSheet({Key? key, required this.asset}) : super(key: key); | ||||
|  | ||||
|   bool get showMap => | ||||
|       assetDetail.exifInfo?.latitude != null && | ||||
|       assetDetail.exifInfo?.longitude != null; | ||||
|       asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final ExifInfo? exifInfo = assetDetail.exifInfo; | ||||
|     final exifInfo = asset.exifInfo; | ||||
|     var isDarkTheme = Theme.of(context).brightness == Brightness.dark; | ||||
|     var textColor = isDarkTheme ? Colors.white : Colors.black; | ||||
|  | ||||
|     buildMap() { | ||||
|       return Padding( | ||||
| @@ -76,19 +76,6 @@ class ExifBottomSheet extends HookConsumerWidget { | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     final textColor = Theme.of(context).primaryColor; | ||||
|  | ||||
|     buildLocationText() { | ||||
|       return Text( | ||||
|         "${exifInfo?.city}, ${exifInfo?.state}", | ||||
|         style: TextStyle( | ||||
|           fontSize: 12, | ||||
|           fontWeight: FontWeight.bold, | ||||
|           color: textColor, | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     buildSizeText(Asset a) { | ||||
|       String resolution = a.width != null && a.height != null | ||||
|           ? "${a.height} x ${a.width}  " | ||||
| @@ -128,13 +115,39 @@ class ExifBottomSheet extends HookConsumerWidget { | ||||
|             children: [ | ||||
|               Text( | ||||
|                 "exif_bottom_sheet_location", | ||||
|                 style: TextStyle(fontSize: 11, color: textColor), | ||||
|                 style: TextStyle( | ||||
|                   fontSize: 11, | ||||
|                   color: textColor, | ||||
|                   fontWeight: FontWeight.bold, | ||||
|                 ), | ||||
|               ).tr(), | ||||
|               buildMap(), | ||||
|               if (exifInfo != null && | ||||
|                   exifInfo.city != null && | ||||
|                   exifInfo.state != null) | ||||
|                 buildLocationText(), | ||||
|               RichText( | ||||
|                 text: TextSpan( | ||||
|                   style: TextStyle( | ||||
|                     fontSize: 12, | ||||
|                     fontWeight: FontWeight.bold, | ||||
|                     color: textColor, | ||||
|                     fontFamily: 'WorkSans', | ||||
|                   ), | ||||
|                   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: const TextStyle(fontSize: 12), | ||||
| @@ -146,7 +159,7 @@ class ExifBottomSheet extends HookConsumerWidget { | ||||
|     } | ||||
|  | ||||
|     buildDate() { | ||||
|       final fileCreatedAt = assetDetail.fileCreatedAt.toLocal(); | ||||
|       final fileCreatedAt = asset.fileCreatedAt.toLocal(); | ||||
|       final date = DateFormat.yMMMEd().format(fileCreatedAt); | ||||
|       final time = DateFormat.jm().format(fileCreatedAt); | ||||
|  | ||||
| @@ -167,27 +180,37 @@ class ExifBottomSheet extends HookConsumerWidget { | ||||
|             padding: const EdgeInsets.only(bottom: 8.0), | ||||
|             child: Text( | ||||
|               "exif_bottom_sheet_details", | ||||
|               style: TextStyle(fontSize: 11, color: textColor), | ||||
|               style: TextStyle( | ||||
|                 fontSize: 11, | ||||
|                 color: textColor, | ||||
|                 fontWeight: FontWeight.bold, | ||||
|               ), | ||||
|             ).tr(), | ||||
|           ), | ||||
|           ListTile( | ||||
|             contentPadding: const EdgeInsets.all(0), | ||||
|             dense: true, | ||||
|             leading: const Icon(Icons.image), | ||||
|             leading: Icon( | ||||
|               Icons.image, | ||||
|               color: textColor.withAlpha(200), | ||||
|             ), | ||||
|             title: Text( | ||||
|               assetDetail.fileName, | ||||
|               asset.fileName, | ||||
|               style: TextStyle( | ||||
|                 fontWeight: FontWeight.bold, | ||||
|                 color: textColor, | ||||
|               ), | ||||
|             ), | ||||
|             subtitle: buildSizeText(assetDetail), | ||||
|             subtitle: buildSizeText(asset), | ||||
|           ), | ||||
|           if (exifInfo?.make != null) | ||||
|             ListTile( | ||||
|               contentPadding: const EdgeInsets.all(0), | ||||
|               dense: true, | ||||
|               leading: const Icon(Icons.camera), | ||||
|               leading: Icon( | ||||
|                 Icons.camera, | ||||
|                 color: textColor.withAlpha(200), | ||||
|               ), | ||||
|               title: Text( | ||||
|                 "${exifInfo!.make} ${exifInfo.model}", | ||||
|                 style: TextStyle( | ||||
| @@ -203,80 +226,75 @@ class ExifBottomSheet extends HookConsumerWidget { | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return SingleChildScrollView( | ||||
|       child: Card( | ||||
|         shape: const RoundedRectangleBorder( | ||||
|           borderRadius: BorderRadius.only( | ||||
|             topLeft: Radius.circular(15), | ||||
|             topRight: Radius.circular(15), | ||||
|     return GestureDetector( | ||||
|       onTap: () { | ||||
|         // FocusScope.of(context).unfocus(); | ||||
|       }, | ||||
|       child: SingleChildScrollView( | ||||
|         child: Card( | ||||
|           shape: const RoundedRectangleBorder( | ||||
|             borderRadius: BorderRadius.only( | ||||
|               topLeft: Radius.circular(15), | ||||
|               topRight: Radius.circular(15), | ||||
|             ), | ||||
|           ), | ||||
|         ), | ||||
|         margin: const EdgeInsets.all(0), | ||||
|         child: Container( | ||||
|           margin: const EdgeInsets.symmetric(horizontal: 8.0), | ||||
|           child: LayoutBuilder( | ||||
|             builder: (context, constraints) { | ||||
|               if (constraints.maxWidth > 600) { | ||||
|                 // Two column | ||||
|                 return Padding( | ||||
|                   padding: const EdgeInsets.symmetric(horizontal: 12.0), | ||||
|                   child: Column( | ||||
|                     crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                     children: [ | ||||
|                       buildDragHeader(), | ||||
|                       buildDate(), | ||||
|                       const SizedBox(height: 32.0), | ||||
|                       Row( | ||||
|                         mainAxisAlignment: MainAxisAlignment.spaceEvenly, | ||||
|                         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                         children: [ | ||||
|                           Flexible( | ||||
|                             flex: showMap ? 5 : 0, | ||||
|                             child: Padding( | ||||
|                               padding: const EdgeInsets.only(right: 8.0), | ||||
|                               child: buildLocation(), | ||||
|           margin: const EdgeInsets.all(0), | ||||
|           child: Container( | ||||
|             margin: const EdgeInsets.symmetric(horizontal: 16.0), | ||||
|             child: LayoutBuilder( | ||||
|               builder: (context, constraints) { | ||||
|                 if (constraints.maxWidth > 600) { | ||||
|                   // Two column | ||||
|                   return Padding( | ||||
|                     padding: const EdgeInsets.symmetric(horizontal: 12.0), | ||||
|                     child: Column( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                       children: [ | ||||
|                         buildDragHeader(), | ||||
|                         buildDate(), | ||||
|                         if (asset.isRemote) DescriptionInput(asset: asset), | ||||
|                         Row( | ||||
|                           mainAxisAlignment: MainAxisAlignment.spaceEvenly, | ||||
|                           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                           children: [ | ||||
|                             Flexible( | ||||
|                               flex: showMap ? 5 : 0, | ||||
|                               child: Padding( | ||||
|                                 padding: const EdgeInsets.only(right: 8.0), | ||||
|                                 child: buildLocation(), | ||||
|                               ), | ||||
|                             ), | ||||
|                           ), | ||||
|                           Flexible( | ||||
|                             flex: 5, | ||||
|                             child: Padding( | ||||
|                               padding: const EdgeInsets.only(left: 8.0), | ||||
|                               child: buildDetail(), | ||||
|                             Flexible( | ||||
|                               flex: 5, | ||||
|                               child: Padding( | ||||
|                                 padding: const EdgeInsets.only(left: 8.0), | ||||
|                                 child: buildDetail(), | ||||
|                               ), | ||||
|                             ), | ||||
|                           ), | ||||
|                         ], | ||||
|                       ), | ||||
|                       const SizedBox(height: 50), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ); | ||||
|               } | ||||
|  | ||||
|               // One column | ||||
|               return Column( | ||||
|                 crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                 children: [ | ||||
|                   buildDragHeader(), | ||||
|                   buildDate(), | ||||
|                   const SizedBox(height: 16.0), | ||||
|                   if (showMap) | ||||
|                     Divider( | ||||
|                       thickness: 1, | ||||
|                       color: Colors.grey[600], | ||||
|                           ], | ||||
|                         ), | ||||
|                         const SizedBox(height: 50), | ||||
|                       ], | ||||
|                     ), | ||||
|                   const SizedBox(height: 16.0), | ||||
|                   buildLocation(), | ||||
|                   const SizedBox(height: 16.0), | ||||
|                   Divider( | ||||
|                     thickness: 1, | ||||
|                     color: Colors.grey[600], | ||||
|                   ), | ||||
|                   const SizedBox(height: 16.0), | ||||
|                   buildDetail(), | ||||
|                   const SizedBox(height: 50), | ||||
|                 ], | ||||
|               ); | ||||
|             }, | ||||
|                   ); | ||||
|                 } | ||||
|  | ||||
|                 // One column | ||||
|                 return Column( | ||||
|                   crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                   children: [ | ||||
|                     buildDragHeader(), | ||||
|                     buildDate(), | ||||
|                     if (asset.isRemote) DescriptionInput(asset: asset), | ||||
|                     const SizedBox(height: 8.0), | ||||
|                     buildLocation(), | ||||
|                     SizedBox(height: showMap ? 16.0 : 0.0), | ||||
|                     buildDetail(), | ||||
|                     const SizedBox(height: 50), | ||||
|                   ], | ||||
|                 ); | ||||
|               }, | ||||
|             ), | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|   | ||||
| @@ -195,7 +195,12 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|               .getSetting<bool>(AppSettingsEnum.advancedTroubleshooting)) { | ||||
|             return AdvancedBottomSheet(assetDetail: assetDetail!); | ||||
|           } | ||||
|           return ExifBottomSheet(assetDetail: assetDetail!); | ||||
|           return Padding( | ||||
|             padding: EdgeInsets.only( | ||||
|               bottom: MediaQuery.of(context).viewInsets.bottom, | ||||
|             ), | ||||
|             child: ExifBottomSheet(asset: assetDetail!), | ||||
|           ); | ||||
|         }, | ||||
|       ); | ||||
|     } | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| @@ -25,19 +26,25 @@ class AdvancedSettings extends HookConsumerWidget { | ||||
|     return ExpansionTile( | ||||
|       textColor: Theme.of(context).primaryColor, | ||||
|       title: const Text( | ||||
|         "Advanced", | ||||
|         "advanced_settings_tile_title", | ||||
|         style: TextStyle( | ||||
|           fontWeight: FontWeight.bold, | ||||
|         ), | ||||
|       ), | ||||
|       ).tr(), | ||||
|       subtitle: const Text( | ||||
|         "advanced_settings_tile_subtitle", | ||||
|         style: TextStyle( | ||||
|           fontSize: 13, | ||||
|         ), | ||||
|       ).tr(), | ||||
|       children: [ | ||||
|         SettingsSwitchListTile( | ||||
|           enabled: true, | ||||
|           appSettingService: appSettingService, | ||||
|           valueNotifier: isEnabled, | ||||
|           settingsEnum: AppSettingsEnum.advancedTroubleshooting, | ||||
|           title: "Troubleshooting", | ||||
|           subtitle: "Enable additional features for troubleshooting", | ||||
|           title: "advanced_settings_troubleshooting_title".tr(), | ||||
|           subtitle: "advanced_settings_troubleshooting_subtitle".tr(), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   | ||||
| @@ -21,6 +21,7 @@ class ExifInfo { | ||||
|   String? city; | ||||
|   String? state; | ||||
|   String? country; | ||||
|   String? description; | ||||
|  | ||||
|   @ignore | ||||
|   String get exposureTime { | ||||
| @@ -58,7 +59,8 @@ class ExifInfo { | ||||
|         long = dto.longitude?.toDouble(), | ||||
|         city = dto.city, | ||||
|         state = dto.state, | ||||
|         country = dto.country; | ||||
|         country = dto.country, | ||||
|         description = dto.description; | ||||
|  | ||||
|   ExifInfo({ | ||||
|     this.fileSize, | ||||
| @@ -74,6 +76,7 @@ class ExifInfo { | ||||
|     this.city, | ||||
|     this.state, | ||||
|     this.country, | ||||
|     this.description, | ||||
|   }); | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -27,58 +27,63 @@ const ExifInfoSchema = CollectionSchema( | ||||
|       name: r'country', | ||||
|       type: IsarType.string, | ||||
|     ), | ||||
|     r'exposureSeconds': PropertySchema( | ||||
|     r'description': PropertySchema( | ||||
|       id: 2, | ||||
|       name: r'description', | ||||
|       type: IsarType.string, | ||||
|     ), | ||||
|     r'exposureSeconds': PropertySchema( | ||||
|       id: 3, | ||||
|       name: r'exposureSeconds', | ||||
|       type: IsarType.float, | ||||
|     ), | ||||
|     r'f': PropertySchema( | ||||
|       id: 3, | ||||
|       id: 4, | ||||
|       name: r'f', | ||||
|       type: IsarType.float, | ||||
|     ), | ||||
|     r'fileSize': PropertySchema( | ||||
|       id: 4, | ||||
|       id: 5, | ||||
|       name: r'fileSize', | ||||
|       type: IsarType.long, | ||||
|     ), | ||||
|     r'iso': PropertySchema( | ||||
|       id: 5, | ||||
|       id: 6, | ||||
|       name: r'iso', | ||||
|       type: IsarType.int, | ||||
|     ), | ||||
|     r'lat': PropertySchema( | ||||
|       id: 6, | ||||
|       id: 7, | ||||
|       name: r'lat', | ||||
|       type: IsarType.float, | ||||
|     ), | ||||
|     r'lens': PropertySchema( | ||||
|       id: 7, | ||||
|       id: 8, | ||||
|       name: r'lens', | ||||
|       type: IsarType.string, | ||||
|     ), | ||||
|     r'long': PropertySchema( | ||||
|       id: 8, | ||||
|       id: 9, | ||||
|       name: r'long', | ||||
|       type: IsarType.float, | ||||
|     ), | ||||
|     r'make': PropertySchema( | ||||
|       id: 9, | ||||
|       id: 10, | ||||
|       name: r'make', | ||||
|       type: IsarType.string, | ||||
|     ), | ||||
|     r'mm': PropertySchema( | ||||
|       id: 10, | ||||
|       id: 11, | ||||
|       name: r'mm', | ||||
|       type: IsarType.float, | ||||
|     ), | ||||
|     r'model': PropertySchema( | ||||
|       id: 11, | ||||
|       id: 12, | ||||
|       name: r'model', | ||||
|       type: IsarType.string, | ||||
|     ), | ||||
|     r'state': PropertySchema( | ||||
|       id: 12, | ||||
|       id: 13, | ||||
|       name: r'state', | ||||
|       type: IsarType.string, | ||||
|     ) | ||||
| @@ -115,6 +120,12 @@ int _exifInfoEstimateSize( | ||||
|       bytesCount += 3 + value.length * 3; | ||||
|     } | ||||
|   } | ||||
|   { | ||||
|     final value = object.description; | ||||
|     if (value != null) { | ||||
|       bytesCount += 3 + value.length * 3; | ||||
|     } | ||||
|   } | ||||
|   { | ||||
|     final value = object.lens; | ||||
|     if (value != null) { | ||||
| @@ -150,17 +161,18 @@ void _exifInfoSerialize( | ||||
| ) { | ||||
|   writer.writeString(offsets[0], object.city); | ||||
|   writer.writeString(offsets[1], object.country); | ||||
|   writer.writeFloat(offsets[2], object.exposureSeconds); | ||||
|   writer.writeFloat(offsets[3], object.f); | ||||
|   writer.writeLong(offsets[4], object.fileSize); | ||||
|   writer.writeInt(offsets[5], object.iso); | ||||
|   writer.writeFloat(offsets[6], object.lat); | ||||
|   writer.writeString(offsets[7], object.lens); | ||||
|   writer.writeFloat(offsets[8], object.long); | ||||
|   writer.writeString(offsets[9], object.make); | ||||
|   writer.writeFloat(offsets[10], object.mm); | ||||
|   writer.writeString(offsets[11], object.model); | ||||
|   writer.writeString(offsets[12], object.state); | ||||
|   writer.writeString(offsets[2], object.description); | ||||
|   writer.writeFloat(offsets[3], object.exposureSeconds); | ||||
|   writer.writeFloat(offsets[4], object.f); | ||||
|   writer.writeLong(offsets[5], object.fileSize); | ||||
|   writer.writeInt(offsets[6], object.iso); | ||||
|   writer.writeFloat(offsets[7], object.lat); | ||||
|   writer.writeString(offsets[8], object.lens); | ||||
|   writer.writeFloat(offsets[9], object.long); | ||||
|   writer.writeString(offsets[10], object.make); | ||||
|   writer.writeFloat(offsets[11], object.mm); | ||||
|   writer.writeString(offsets[12], object.model); | ||||
|   writer.writeString(offsets[13], object.state); | ||||
| } | ||||
|  | ||||
| ExifInfo _exifInfoDeserialize( | ||||
| @@ -172,17 +184,18 @@ ExifInfo _exifInfoDeserialize( | ||||
|   final object = ExifInfo( | ||||
|     city: reader.readStringOrNull(offsets[0]), | ||||
|     country: reader.readStringOrNull(offsets[1]), | ||||
|     exposureSeconds: reader.readFloatOrNull(offsets[2]), | ||||
|     f: reader.readFloatOrNull(offsets[3]), | ||||
|     fileSize: reader.readLongOrNull(offsets[4]), | ||||
|     iso: reader.readIntOrNull(offsets[5]), | ||||
|     lat: reader.readFloatOrNull(offsets[6]), | ||||
|     lens: reader.readStringOrNull(offsets[7]), | ||||
|     long: reader.readFloatOrNull(offsets[8]), | ||||
|     make: reader.readStringOrNull(offsets[9]), | ||||
|     mm: reader.readFloatOrNull(offsets[10]), | ||||
|     model: reader.readStringOrNull(offsets[11]), | ||||
|     state: reader.readStringOrNull(offsets[12]), | ||||
|     description: reader.readStringOrNull(offsets[2]), | ||||
|     exposureSeconds: reader.readFloatOrNull(offsets[3]), | ||||
|     f: reader.readFloatOrNull(offsets[4]), | ||||
|     fileSize: reader.readLongOrNull(offsets[5]), | ||||
|     iso: reader.readIntOrNull(offsets[6]), | ||||
|     lat: reader.readFloatOrNull(offsets[7]), | ||||
|     lens: reader.readStringOrNull(offsets[8]), | ||||
|     long: reader.readFloatOrNull(offsets[9]), | ||||
|     make: reader.readStringOrNull(offsets[10]), | ||||
|     mm: reader.readFloatOrNull(offsets[11]), | ||||
|     model: reader.readStringOrNull(offsets[12]), | ||||
|     state: reader.readStringOrNull(offsets[13]), | ||||
|   ); | ||||
|   object.id = id; | ||||
|   return object; | ||||
| @@ -200,27 +213,29 @@ P _exifInfoDeserializeProp<P>( | ||||
|     case 1: | ||||
|       return (reader.readStringOrNull(offset)) as P; | ||||
|     case 2: | ||||
|       return (reader.readFloatOrNull(offset)) as P; | ||||
|       return (reader.readStringOrNull(offset)) as P; | ||||
|     case 3: | ||||
|       return (reader.readFloatOrNull(offset)) as P; | ||||
|     case 4: | ||||
|       return (reader.readLongOrNull(offset)) as P; | ||||
|       return (reader.readFloatOrNull(offset)) as P; | ||||
|     case 5: | ||||
|       return (reader.readIntOrNull(offset)) as P; | ||||
|       return (reader.readLongOrNull(offset)) as P; | ||||
|     case 6: | ||||
|       return (reader.readFloatOrNull(offset)) as P; | ||||
|       return (reader.readIntOrNull(offset)) as P; | ||||
|     case 7: | ||||
|       return (reader.readStringOrNull(offset)) as P; | ||||
|       return (reader.readFloatOrNull(offset)) as P; | ||||
|     case 8: | ||||
|       return (reader.readFloatOrNull(offset)) as P; | ||||
|       return (reader.readStringOrNull(offset)) as P; | ||||
|     case 9: | ||||
|       return (reader.readStringOrNull(offset)) as P; | ||||
|     case 10: | ||||
|       return (reader.readFloatOrNull(offset)) as P; | ||||
|     case 11: | ||||
|     case 10: | ||||
|       return (reader.readStringOrNull(offset)) as P; | ||||
|     case 11: | ||||
|       return (reader.readFloatOrNull(offset)) as P; | ||||
|     case 12: | ||||
|       return (reader.readStringOrNull(offset)) as P; | ||||
|     case 13: | ||||
|       return (reader.readStringOrNull(offset)) as P; | ||||
|     default: | ||||
|       throw IsarError('Unknown property with id $propertyId'); | ||||
|   } | ||||
| @@ -607,6 +622,155 @@ extension ExifInfoQueryFilter | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> descriptionIsNull() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(const FilterCondition.isNull( | ||||
|         property: r'description', | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> | ||||
|       descriptionIsNotNull() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(const FilterCondition.isNotNull( | ||||
|         property: r'description', | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> descriptionEqualTo( | ||||
|     String? value, { | ||||
|     bool caseSensitive = true, | ||||
|   }) { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(FilterCondition.equalTo( | ||||
|         property: r'description', | ||||
|         value: value, | ||||
|         caseSensitive: caseSensitive, | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> | ||||
|       descriptionGreaterThan( | ||||
|     String? value, { | ||||
|     bool include = false, | ||||
|     bool caseSensitive = true, | ||||
|   }) { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(FilterCondition.greaterThan( | ||||
|         include: include, | ||||
|         property: r'description', | ||||
|         value: value, | ||||
|         caseSensitive: caseSensitive, | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> descriptionLessThan( | ||||
|     String? value, { | ||||
|     bool include = false, | ||||
|     bool caseSensitive = true, | ||||
|   }) { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(FilterCondition.lessThan( | ||||
|         include: include, | ||||
|         property: r'description', | ||||
|         value: value, | ||||
|         caseSensitive: caseSensitive, | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> descriptionBetween( | ||||
|     String? lower, | ||||
|     String? upper, { | ||||
|     bool includeLower = true, | ||||
|     bool includeUpper = true, | ||||
|     bool caseSensitive = true, | ||||
|   }) { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(FilterCondition.between( | ||||
|         property: r'description', | ||||
|         lower: lower, | ||||
|         includeLower: includeLower, | ||||
|         upper: upper, | ||||
|         includeUpper: includeUpper, | ||||
|         caseSensitive: caseSensitive, | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> descriptionStartsWith( | ||||
|     String value, { | ||||
|     bool caseSensitive = true, | ||||
|   }) { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(FilterCondition.startsWith( | ||||
|         property: r'description', | ||||
|         value: value, | ||||
|         caseSensitive: caseSensitive, | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> descriptionEndsWith( | ||||
|     String value, { | ||||
|     bool caseSensitive = true, | ||||
|   }) { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(FilterCondition.endsWith( | ||||
|         property: r'description', | ||||
|         value: value, | ||||
|         caseSensitive: caseSensitive, | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> descriptionContains( | ||||
|       String value, | ||||
|       {bool caseSensitive = true}) { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(FilterCondition.contains( | ||||
|         property: r'description', | ||||
|         value: value, | ||||
|         caseSensitive: caseSensitive, | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> descriptionMatches( | ||||
|       String pattern, | ||||
|       {bool caseSensitive = true}) { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(FilterCondition.matches( | ||||
|         property: r'description', | ||||
|         wildcard: pattern, | ||||
|         caseSensitive: caseSensitive, | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> descriptionIsEmpty() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(FilterCondition.equalTo( | ||||
|         property: r'description', | ||||
|         value: '', | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> | ||||
|       descriptionIsNotEmpty() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(FilterCondition.greaterThan( | ||||
|         property: r'description', | ||||
|         value: '', | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> | ||||
|       exposureSecondsIsNull() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
| @@ -1825,6 +1989,18 @@ extension ExifInfoQuerySortBy on QueryBuilder<ExifInfo, ExifInfo, QSortBy> { | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByDescription() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addSortBy(r'description', Sort.asc); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByDescriptionDesc() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addSortBy(r'description', Sort.desc); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByExposureSeconds() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addSortBy(r'exposureSeconds', Sort.asc); | ||||
| @@ -1984,6 +2160,18 @@ extension ExifInfoQuerySortThenBy | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByDescription() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addSortBy(r'description', Sort.asc); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByDescriptionDesc() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addSortBy(r'description', Sort.desc); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByExposureSeconds() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addSortBy(r'exposureSeconds', Sort.asc); | ||||
| @@ -2145,6 +2333,13 @@ extension ExifInfoQueryWhereDistinct | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByDescription( | ||||
|       {bool caseSensitive = true}) { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addDistinctBy(r'description', caseSensitive: caseSensitive); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByExposureSeconds() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addDistinctBy(r'exposureSeconds'); | ||||
| @@ -2236,6 +2431,12 @@ extension ExifInfoQueryProperty | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<ExifInfo, String?, QQueryOperations> descriptionProperty() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addPropertyName(r'description'); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<ExifInfo, double?, QQueryOperations> exposureSecondsProperty() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addPropertyName(r'exposureSeconds'); | ||||
|   | ||||
							
								
								
									
										1
									
								
								mobile/openapi/doc/ExifResponseDto.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/doc/ExifResponseDto.md
									
									
									
										generated
									
									
									
								
							| @@ -27,6 +27,7 @@ Name | Type | Description | Notes | ||||
| **city** | **String** |  | [optional]  | ||||
| **state** | **String** |  | [optional]  | ||||
| **country** | **String** |  | [optional]  | ||||
| **description** | **String** |  | [optional]  | ||||
| 
 | ||||
| [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) | ||||
| 
 | ||||
|   | ||||
							
								
								
									
										1
									
								
								mobile/openapi/doc/UpdateAssetDto.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/doc/UpdateAssetDto.md
									
									
									
										generated
									
									
									
								
							| @@ -11,6 +11,7 @@ Name | Type | Description | Notes | ||||
| **tagIds** | **List<String>** |  | [optional] [default to const []] | ||||
| **isFavorite** | **bool** |  | [optional]  | ||||
| **isArchived** | **bool** |  | [optional]  | ||||
| **description** | **String** |  | [optional]  | ||||
| 
 | ||||
| [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) | ||||
| 
 | ||||
|   | ||||
							
								
								
									
										17
									
								
								mobile/openapi/lib/model/exif_response_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										17
									
								
								mobile/openapi/lib/model/exif_response_dto.dart
									
									
									
										generated
									
									
									
								
							| @@ -32,6 +32,7 @@ class ExifResponseDto { | ||||
|     this.city, | ||||
|     this.state, | ||||
|     this.country, | ||||
|     this.description, | ||||
|   }); | ||||
| 
 | ||||
|   int? fileSizeInByte; | ||||
| @@ -72,6 +73,8 @@ class ExifResponseDto { | ||||
| 
 | ||||
|   String? country; | ||||
| 
 | ||||
|   String? description; | ||||
| 
 | ||||
|   @override | ||||
|   bool operator ==(Object other) => identical(this, other) || other is ExifResponseDto && | ||||
|      other.fileSizeInByte == fileSizeInByte && | ||||
| @@ -92,7 +95,8 @@ class ExifResponseDto { | ||||
|      other.longitude == longitude && | ||||
|      other.city == city && | ||||
|      other.state == state && | ||||
|      other.country == country; | ||||
|      other.country == country && | ||||
|      other.description == description; | ||||
| 
 | ||||
|   @override | ||||
|   int get hashCode => | ||||
| @@ -115,10 +119,11 @@ class ExifResponseDto { | ||||
|     (longitude == null ? 0 : longitude!.hashCode) + | ||||
|     (city == null ? 0 : city!.hashCode) + | ||||
|     (state == null ? 0 : state!.hashCode) + | ||||
|     (country == null ? 0 : country!.hashCode); | ||||
|     (country == null ? 0 : country!.hashCode) + | ||||
|     (description == null ? 0 : description!.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'ExifResponseDto[fileSizeInByte=$fileSizeInByte, make=$make, model=$model, exifImageWidth=$exifImageWidth, exifImageHeight=$exifImageHeight, orientation=$orientation, dateTimeOriginal=$dateTimeOriginal, modifyDate=$modifyDate, timeZone=$timeZone, lensModel=$lensModel, fNumber=$fNumber, focalLength=$focalLength, iso=$iso, exposureTime=$exposureTime, latitude=$latitude, longitude=$longitude, city=$city, state=$state, country=$country]'; | ||||
|   String toString() => 'ExifResponseDto[fileSizeInByte=$fileSizeInByte, make=$make, model=$model, exifImageWidth=$exifImageWidth, exifImageHeight=$exifImageHeight, orientation=$orientation, dateTimeOriginal=$dateTimeOriginal, modifyDate=$modifyDate, timeZone=$timeZone, lensModel=$lensModel, fNumber=$fNumber, focalLength=$focalLength, iso=$iso, exposureTime=$exposureTime, latitude=$latitude, longitude=$longitude, city=$city, state=$state, country=$country, description=$description]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
| @@ -217,6 +222,11 @@ class ExifResponseDto { | ||||
|     } else { | ||||
|       // json[r'country'] = null; | ||||
|     } | ||||
|     if (this.description != null) { | ||||
|       json[r'description'] = this.description; | ||||
|     } else { | ||||
|       // json[r'description'] = null; | ||||
|     } | ||||
|     return json; | ||||
|   } | ||||
| 
 | ||||
| @@ -272,6 +282,7 @@ class ExifResponseDto { | ||||
|         city: mapValueOfType<String>(json, r'city'), | ||||
|         state: mapValueOfType<String>(json, r'state'), | ||||
|         country: mapValueOfType<String>(json, r'country'), | ||||
|         description: mapValueOfType<String>(json, r'description'), | ||||
|       ); | ||||
|     } | ||||
|     return null; | ||||
|   | ||||
							
								
								
									
										23
									
								
								mobile/openapi/lib/model/update_asset_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										23
									
								
								mobile/openapi/lib/model/update_asset_dto.dart
									
									
									
										generated
									
									
									
								
							| @@ -16,6 +16,7 @@ class UpdateAssetDto { | ||||
|     this.tagIds = const [], | ||||
|     this.isFavorite, | ||||
|     this.isArchived, | ||||
|     this.description, | ||||
|   }); | ||||
| 
 | ||||
|   List<String> tagIds; | ||||
| @@ -36,21 +37,31 @@ class UpdateAssetDto { | ||||
|   /// | ||||
|   bool? isArchived; | ||||
| 
 | ||||
|   /// | ||||
|   /// Please note: This property should have been non-nullable! Since the specification file | ||||
|   /// does not include a default value (using the "default:" property), however, the generated | ||||
|   /// source code must fall back to having a nullable type. | ||||
|   /// Consider adding a "default:" property in the specification file to hide this note. | ||||
|   /// | ||||
|   String? description; | ||||
| 
 | ||||
|   @override | ||||
|   bool operator ==(Object other) => identical(this, other) || other is UpdateAssetDto && | ||||
|      other.tagIds == tagIds && | ||||
|      other.isFavorite == isFavorite && | ||||
|      other.isArchived == isArchived; | ||||
|      other.isArchived == isArchived && | ||||
|      other.description == description; | ||||
| 
 | ||||
|   @override | ||||
|   int get hashCode => | ||||
|     // ignore: unnecessary_parenthesis | ||||
|     (tagIds.hashCode) + | ||||
|     (isFavorite == null ? 0 : isFavorite!.hashCode) + | ||||
|     (isArchived == null ? 0 : isArchived!.hashCode); | ||||
|     (isArchived == null ? 0 : isArchived!.hashCode) + | ||||
|     (description == null ? 0 : description!.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'UpdateAssetDto[tagIds=$tagIds, isFavorite=$isFavorite, isArchived=$isArchived]'; | ||||
|   String toString() => 'UpdateAssetDto[tagIds=$tagIds, isFavorite=$isFavorite, isArchived=$isArchived, description=$description]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
| @@ -65,6 +76,11 @@ class UpdateAssetDto { | ||||
|     } else { | ||||
|       // json[r'isArchived'] = null; | ||||
|     } | ||||
|     if (this.description != null) { | ||||
|       json[r'description'] = this.description; | ||||
|     } else { | ||||
|       // json[r'description'] = null; | ||||
|     } | ||||
|     return json; | ||||
|   } | ||||
| 
 | ||||
| @@ -92,6 +108,7 @@ class UpdateAssetDto { | ||||
|             : const [], | ||||
|         isFavorite: mapValueOfType<bool>(json, r'isFavorite'), | ||||
|         isArchived: mapValueOfType<bool>(json, r'isArchived'), | ||||
|         description: mapValueOfType<String>(json, r'description'), | ||||
|       ); | ||||
|     } | ||||
|     return null; | ||||
|   | ||||
							
								
								
									
										5
									
								
								mobile/openapi/test/exif_response_dto_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5
									
								
								mobile/openapi/test/exif_response_dto_test.dart
									
									
									
										generated
									
									
									
								
							| @@ -111,6 +111,11 @@ void main() { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // String description | ||||
|     test('to test the property `description`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
| 
 | ||||
|   }); | ||||
| 
 | ||||
|   | ||||
							
								
								
									
										5
									
								
								mobile/openapi/test/update_asset_dto_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5
									
								
								mobile/openapi/test/update_asset_dto_test.dart
									
									
									
										generated
									
									
									
								
							| @@ -31,6 +31,11 @@ void main() { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // String description | ||||
|     test('to test the property `description`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
| 
 | ||||
|   }); | ||||
| 
 | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import { SearchPropertiesDto } from './dto/search-properties.dto'; | ||||
| import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto'; | ||||
| import { AssetEntity, AssetType } from '@app/infra/entities'; | ||||
| import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { InjectRepository } from '@nestjs/typeorm'; | ||||
| import { Repository } from 'typeorm/repository/Repository'; | ||||
| @@ -55,6 +55,7 @@ export class AssetRepository implements IAssetRepository { | ||||
|     private assetRepository: Repository<AssetEntity>, | ||||
|  | ||||
|     @Inject(ITagRepository) private _tagRepository: ITagRepository, | ||||
|     @InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>, | ||||
|   ) {} | ||||
|  | ||||
|   async getAllVideos(): Promise<AssetEntity[]> { | ||||
| @@ -268,6 +269,17 @@ export class AssetRepository implements IAssetRepository { | ||||
|       asset.tags = tags; | ||||
|     } | ||||
|  | ||||
|     if (asset.exifInfo != null) { | ||||
|       asset.exifInfo.description = dto.description || ''; | ||||
|       await this.exifRepository.save(asset.exifInfo); | ||||
|     } else { | ||||
|       const exifInfo = new ExifEntity(); | ||||
|       exifInfo.description = dto.description || ''; | ||||
|       exifInfo.asset = asset; | ||||
|       await this.exifRepository.save(exifInfo); | ||||
|       asset.exifInfo = exifInfo; | ||||
|     } | ||||
|  | ||||
|     return await this.assetRepository.save(asset); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import { Module } from '@nestjs/common'; | ||||
| import { AssetService } from './asset.service'; | ||||
| import { AssetController } from './asset.controller'; | ||||
| import { TypeOrmModule } from '@nestjs/typeorm'; | ||||
| import { AssetEntity } from '@app/infra/entities'; | ||||
| import { AssetEntity, ExifEntity } from '@app/infra/entities'; | ||||
| import { AssetRepository, IAssetRepository } from './asset-repository'; | ||||
| import { DownloadModule } from '../../modules/download/download.module'; | ||||
| import { TagModule } from '../tag/tag.module'; | ||||
| @@ -16,7 +16,7 @@ const ASSET_REPOSITORY_PROVIDER = { | ||||
| @Module({ | ||||
|   imports: [ | ||||
|     // | ||||
|     TypeOrmModule.forFeature([AssetEntity]), | ||||
|     TypeOrmModule.forFeature([AssetEntity, ExifEntity]), | ||||
|     DownloadModule, | ||||
|     TagModule, | ||||
|     AlbumModule, | ||||
|   | ||||
| @@ -25,4 +25,8 @@ export class UpdateAssetDto { | ||||
|     ], | ||||
|   }) | ||||
|   tagIds?: string[]; | ||||
|  | ||||
|   @IsOptional() | ||||
|   @IsString() | ||||
|   description?: string; | ||||
| } | ||||
|   | ||||
| @@ -223,7 +223,6 @@ export class MetadataExtractionProcessor { | ||||
|  | ||||
|       const newExif = new ExifEntity(); | ||||
|       newExif.assetId = asset.id; | ||||
|       newExif.description = ''; | ||||
|       newExif.fileSizeInByte = data.format.size || null; | ||||
|       newExif.dateTimeOriginal = fileCreatedAt ? new Date(fileCreatedAt) : null; | ||||
|       newExif.modifyDate = null; | ||||
|   | ||||
| @@ -3675,6 +3675,11 @@ | ||||
|             "type": "string", | ||||
|             "nullable": true, | ||||
|             "default": null | ||||
|           }, | ||||
|           "description": { | ||||
|             "type": "string", | ||||
|             "nullable": true, | ||||
|             "default": null | ||||
|           } | ||||
|         } | ||||
|       }, | ||||
| @@ -5283,6 +5288,9 @@ | ||||
|           }, | ||||
|           "isArchived": { | ||||
|             "type": "boolean" | ||||
|           }, | ||||
|           "description": { | ||||
|             "type": "string" | ||||
|           } | ||||
|         } | ||||
|       }, | ||||
|   | ||||
| @@ -23,6 +23,7 @@ export class ExifResponseDto { | ||||
|   city?: string | null = null; | ||||
|   state?: string | null = null; | ||||
|   country?: string | null = null; | ||||
|   description?: string | null = null; | ||||
| } | ||||
|  | ||||
| export function mapExif(entity: ExifEntity): ExifResponseDto { | ||||
| @@ -46,5 +47,6 @@ export function mapExif(entity: ExifEntity): ExifResponseDto { | ||||
|     city: entity.city, | ||||
|     state: entity.state, | ||||
|     country: entity.country, | ||||
|     description: entity.description, | ||||
|   }; | ||||
| } | ||||
|   | ||||
| @@ -343,6 +343,7 @@ const assetInfo: ExifResponseDto = { | ||||
|   city: 'city', | ||||
|   state: 'state', | ||||
|   country: 'country', | ||||
|   description: 'description', | ||||
| }; | ||||
|  | ||||
| const assetResponse: AssetResponseDto = { | ||||
|   | ||||
							
								
								
									
										12
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										12
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							| @@ -1208,6 +1208,12 @@ export interface ExifResponseDto { | ||||
|      * @memberof ExifResponseDto | ||||
|      */ | ||||
|     'country'?: string | null; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof ExifResponseDto | ||||
|      */ | ||||
|     'description'?: string | null; | ||||
| } | ||||
| /** | ||||
|  *  | ||||
| @@ -2341,6 +2347,12 @@ export interface UpdateAssetDto { | ||||
|      * @memberof UpdateAssetDto | ||||
|      */ | ||||
|     'isArchived'?: boolean; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof UpdateAssetDto | ||||
|      */ | ||||
|     'description'?: string; | ||||
| } | ||||
| /** | ||||
|  *  | ||||
|   | ||||
| @@ -251,6 +251,18 @@ | ||||
| 			} | ||||
| 		}); | ||||
| 	}; | ||||
|  | ||||
| 	const disableKeyDownEvent = () => { | ||||
| 		if (browser) { | ||||
| 			document.removeEventListener('keydown', onKeyboardPress); | ||||
| 		} | ||||
| 	}; | ||||
|  | ||||
| 	const enableKeyDownEvent = () => { | ||||
| 		if (browser) { | ||||
| 			document.addEventListener('keydown', onKeyboardPress); | ||||
| 		} | ||||
| 	}; | ||||
| </script> | ||||
|  | ||||
| <section | ||||
| @@ -352,7 +364,13 @@ | ||||
| 			class="bg-immich-bg w-[360px] row-span-full transition-all overflow-y-auto dark:bg-immich-dark-bg dark:border-l dark:border-l-immich-dark-gray" | ||||
| 			translate="yes" | ||||
| 		> | ||||
| 			<DetailPanel {asset} albums={appearsInAlbums} on:close={() => (isShowDetail = false)} /> | ||||
| 			<DetailPanel | ||||
| 				{asset} | ||||
| 				albums={appearsInAlbums} | ||||
| 				on:close={() => (isShowDetail = false)} | ||||
| 				on:description-focus-in={disableKeyDownEvent} | ||||
| 				on:description-focus-out={enableKeyDownEvent} | ||||
| 			/> | ||||
| 		</div> | ||||
| 	{/if} | ||||
|  | ||||
|   | ||||
| @@ -5,14 +5,26 @@ | ||||
| 	import CameraIris from 'svelte-material-icons/CameraIris.svelte'; | ||||
| 	import MapMarkerOutline from 'svelte-material-icons/MapMarkerOutline.svelte'; | ||||
| 	import { createEventDispatcher } from 'svelte'; | ||||
| 	import { AssetResponseDto, AlbumResponseDto } from '@api'; | ||||
| 	import { AssetResponseDto, AlbumResponseDto, api } from '@api'; | ||||
| 	import { asByteUnitString } from '../../utils/byte-units'; | ||||
| 	import { locale } from '$lib/stores/preferences.store'; | ||||
| 	import { DateTime } from 'luxon'; | ||||
| 	import type { LatLngTuple } from 'leaflet'; | ||||
| 	import { page } from '$app/stores'; | ||||
|  | ||||
| 	export let asset: AssetResponseDto; | ||||
| 	export let albums: AlbumResponseDto[] = []; | ||||
| 	let textarea: HTMLTextAreaElement; | ||||
| 	let description: string; | ||||
|  | ||||
| 	$: { | ||||
| 		// Get latest description from server | ||||
| 		if (asset.id) { | ||||
| 			api.assetApi | ||||
| 				.getAssetById(asset.id) | ||||
| 				.then((res) => (textarea.value = res.data?.exifInfo?.description || '')); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	$: latlng = (() => { | ||||
| 		const lat = asset.exifInfo?.latitude; | ||||
| @@ -34,6 +46,27 @@ | ||||
|  | ||||
| 		return undefined; | ||||
| 	}; | ||||
|  | ||||
| 	const autoGrowHeight = (e: Event) => { | ||||
| 		const target = e.target as HTMLTextAreaElement; | ||||
| 		target.style.height = 'auto'; | ||||
| 		target.style.height = `${target.scrollHeight}px`; | ||||
| 	}; | ||||
|  | ||||
| 	const handleFocusIn = () => { | ||||
| 		dispatch('description-focus-in'); | ||||
| 	}; | ||||
|  | ||||
| 	const handleFocusOut = async () => { | ||||
| 		dispatch('description-focus-out'); | ||||
| 		try { | ||||
| 			await api.assetApi.updateAsset(asset.id, { | ||||
| 				description: description | ||||
| 			}); | ||||
| 		} catch (error) { | ||||
| 			console.error(error); | ||||
| 		} | ||||
| 	}; | ||||
| </script> | ||||
|  | ||||
| <section class="p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg"> | ||||
| @@ -48,6 +81,23 @@ | ||||
| 		<p class="text-immich-fg dark:text-immich-dark-fg text-lg">Info</p> | ||||
| 	</div> | ||||
|  | ||||
| 	<div class="mx-4 mt-10"> | ||||
| 		<textarea | ||||
| 			bind:this={textarea} | ||||
| 			class="max-h-[500px] | ||||
|       text-base text-black bg-transparent dark:text-white border-b focus:border-b-2 border-gray-500 w-full focus:border-immich-primary dark:focus:border-immich-dark-primary transition-all resize-none overflow-hidden outline-none disabled:border-none" | ||||
| 			placeholder={$page?.data?.user?.id !== asset.ownerId ? '' : 'Add a description'} | ||||
| 			style:display={$page?.data?.user?.id !== asset.ownerId && textarea?.value == '' | ||||
| 				? 'none' | ||||
| 				: 'block'} | ||||
| 			on:focusin={handleFocusIn} | ||||
| 			on:focusout={handleFocusOut} | ||||
| 			on:input={autoGrowHeight} | ||||
| 			bind:value={description} | ||||
| 			disabled={$page?.data?.user?.id !== asset.ownerId} | ||||
| 		/> | ||||
| 	</div> | ||||
|  | ||||
| 	<div class="px-4 py-4"> | ||||
| 		{#if !asset.exifInfo} | ||||
| 			<p class="text-sm pb-4">NO EXIF INFO AVAILABLE</p> | ||||
| @@ -178,7 +228,7 @@ | ||||
| <section class="p-2 dark:text-immich-dark-fg"> | ||||
| 	<div class="px-4 py-4"> | ||||
| 		{#if albums.length > 0} | ||||
| 			<p class="text-sm pb-4 ">APPEARS IN</p> | ||||
| 			<p class="text-sm pb-4">APPEARS IN</p> | ||||
| 		{/if} | ||||
| 		{#each albums as album} | ||||
| 			<a data-sveltekit-preload-data="hover" href={`/albums/${album.id}`}> | ||||
|   | ||||
| @@ -24,29 +24,50 @@ | ||||
| 	}; | ||||
|  | ||||
| 	const getFavoriteCount = async () => { | ||||
| 		const { data: assets } = await api.assetApi.getAllAssets(true, undefined); | ||||
| 		try { | ||||
| 			const { data: assets } = await api.assetApi.getAllAssets(true, undefined); | ||||
|  | ||||
| 		return { | ||||
| 			favorites: assets.length | ||||
| 		}; | ||||
| 			return { | ||||
| 				favorites: assets.length | ||||
| 			}; | ||||
| 		} catch { | ||||
| 			return { | ||||
| 				favorites: 0 | ||||
| 			}; | ||||
| 		} | ||||
| 	}; | ||||
|  | ||||
| 	const getAlbumCount = async () => { | ||||
| 		const { data: albumCount } = await api.albumApi.getAlbumCountByUserId(); | ||||
| 		return { | ||||
| 			shared: albumCount.shared, | ||||
| 			sharing: albumCount.sharing, | ||||
| 			owned: albumCount.owned | ||||
| 		}; | ||||
| 		try { | ||||
| 			const { data: albumCount } = await api.albumApi.getAlbumCountByUserId(); | ||||
| 			return { | ||||
| 				shared: albumCount.shared, | ||||
| 				sharing: albumCount.sharing, | ||||
| 				owned: albumCount.owned | ||||
| 			}; | ||||
| 		} catch { | ||||
| 			return { | ||||
| 				shared: 0, | ||||
| 				sharing: 0, | ||||
| 				owned: 0 | ||||
| 			}; | ||||
| 		} | ||||
| 	}; | ||||
|  | ||||
| 	const getArchivedAssetsCount = async () => { | ||||
| 		const { data: assetCount } = await api.assetApi.getArchivedAssetCountByUserId(); | ||||
| 		try { | ||||
| 			const { data: assetCount } = await api.assetApi.getArchivedAssetCountByUserId(); | ||||
|  | ||||
| 		return { | ||||
| 			videos: assetCount.videos, | ||||
| 			photos: assetCount.photos | ||||
| 		}; | ||||
| 			return { | ||||
| 				videos: assetCount.videos, | ||||
| 				photos: assetCount.photos | ||||
| 			}; | ||||
| 		} catch { | ||||
| 			return { | ||||
| 				videos: 0, | ||||
| 				photos: 0 | ||||
| 			}; | ||||
| 		} | ||||
| 	}; | ||||
| </script> | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user