diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index 6d0d3686..2ce9e08d 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -852,7 +852,12 @@ class ChatRoomScreen extends HookConsumerWidget { top: 8, right: 16, child: Container( - padding: const EdgeInsets.all(8), + padding: EdgeInsets.fromLTRB( + 8, + 8, + 8, + 8 + MediaQuery.of(context).padding.bottom, + ), decoration: BoxDecoration( color: Theme.of( context, @@ -914,74 +919,68 @@ class ChatRoomScreen extends HookConsumerWidget { Positioned( left: 0, right: 0, - bottom: 0, // At the very bottom, above gradient + bottom: MediaQuery.of( + context, + ).padding.bottom, // At the very bottom, above gradient child: chatRoom.when( - data: (room) => Column( + data: (room) => ChatInput( key: inputKey, - mainAxisSize: MainAxisSize.min, - children: [ - ChatInput( - messageController: messageController, - chatRoom: room!, - onSend: sendMessage, - onClear: () { - if (messageEditingTo.value != null) { - attachments.value.clear(); - messageController.clear(); - } - messageEditingTo.value = null; - messageReplyingTo.value = null; - messageForwardingTo.value = null; - selectedPoll.value = null; - selectedFund.value = null; - }, - messageEditingTo: messageEditingTo.value, - messageReplyingTo: messageReplyingTo.value, - messageForwardingTo: messageForwardingTo.value, - selectedPoll: selectedPoll.value, - onPollSelected: (poll) => selectedPoll.value = poll, - selectedFund: selectedFund.value, - onFundSelected: (fund) => selectedFund.value = fund, - onPickFile: (bool isPhoto) { - if (isPhoto) { - pickPhotoMedia(); - } else { - pickVideoMedia(); - } - }, - onPickAudio: pickAudioMedia, - onPickGeneralFile: pickGeneralFile, - onLinkAttachment: linkAttachment, - attachments: attachments.value, - onUploadAttachment: uploadAttachment, - onDeleteAttachment: (index) async { - final attachment = attachments.value[index]; - if (attachment.isOnCloud && !attachment.isLink) { - final client = ref.watch(apiClientProvider); - await client.delete( - '/drive/files/${attachment.data.id}', - ); - } - final clone = List.of(attachments.value); - clone.removeAt(index); - attachments.value = clone; - }, - onMoveAttachment: (idx, delta) { - if (idx + delta < 0 || - idx + delta >= attachments.value.length) { - return; - } - final clone = List.of(attachments.value); - clone.insert(idx + delta, clone.removeAt(idx)); - attachments.value = clone; - }, - onAttachmentsChanged: (newAttachments) { - attachments.value = newAttachments; - }, - attachmentProgress: attachmentProgress.value, - ), - Gap(MediaQuery.of(context).padding.bottom), - ], + messageController: messageController, + chatRoom: room!, + onSend: sendMessage, + onClear: () { + if (messageEditingTo.value != null) { + attachments.value.clear(); + messageController.clear(); + } + messageEditingTo.value = null; + messageReplyingTo.value = null; + messageForwardingTo.value = null; + selectedPoll.value = null; + selectedFund.value = null; + }, + messageEditingTo: messageEditingTo.value, + messageReplyingTo: messageReplyingTo.value, + messageForwardingTo: messageForwardingTo.value, + selectedPoll: selectedPoll.value, + onPollSelected: (poll) => selectedPoll.value = poll, + selectedFund: selectedFund.value, + onFundSelected: (fund) => selectedFund.value = fund, + onPickFile: (bool isPhoto) { + if (isPhoto) { + pickPhotoMedia(); + } else { + pickVideoMedia(); + } + }, + onPickAudio: pickAudioMedia, + onPickGeneralFile: pickGeneralFile, + onLinkAttachment: linkAttachment, + attachments: attachments.value, + onUploadAttachment: uploadAttachment, + onDeleteAttachment: (index) async { + final attachment = attachments.value[index]; + if (attachment.isOnCloud && !attachment.isLink) { + final client = ref.watch(apiClientProvider); + await client.delete('/drive/files/${attachment.data.id}'); + } + final clone = List.of(attachments.value); + clone.removeAt(index); + attachments.value = clone; + }, + onMoveAttachment: (idx, delta) { + if (idx + delta < 0 || + idx + delta >= attachments.value.length) { + return; + } + final clone = List.of(attachments.value); + clone.insert(idx + delta, clone.removeAt(idx)); + attachments.value = clone; + }, + onAttachmentsChanged: (newAttachments) { + attachments.value = newAttachments; + }, + attachmentProgress: attachmentProgress.value, ), error: (_, _) => const SizedBox.shrink(), loading: () => const SizedBox.shrink(), diff --git a/lib/widgets/content/cloud_file_lightbox.dart b/lib/widgets/content/cloud_file_lightbox.dart index cbfc6c4a..49616698 100644 --- a/lib/widgets/content/cloud_file_lightbox.dart +++ b/lib/widgets/content/cloud_file_lightbox.dart @@ -7,14 +7,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/file.dart'; import 'package:island/pods/config.dart'; import 'package:island/services/file_download.dart'; +import 'package:island/widgets/content/cloud_files.dart'; +import 'package:island/widgets/content/exif_info_overlay.dart'; import 'package:island/widgets/content/file_action_button.dart'; import 'package:island/widgets/content/file_info_sheet.dart'; import 'package:island/widgets/content/image_control_overlay.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:photo_view/photo_view.dart'; -import 'cloud_files.dart'; - class CloudFileLightbox extends HookConsumerWidget { final SnCloudFile item; final String heroTag; @@ -30,7 +30,9 @@ class CloudFileLightbox extends HookConsumerWidget { final photoViewController = useMemoized(() => PhotoViewController(), []); final rotation = useState(0); + final hasExifData = ExifInfoOverlay.precheck(item); final showOriginal = useState(false); + final showExif = useState(hasExifData); void saveToGallery() { FileDownloadService(ref).saveToGallery(item); @@ -106,6 +108,14 @@ class CloudFileLightbox extends HookConsumerWidget { ], ), ), + // EXIF Info Overlay (positioned below the top buttons) + if (showExif.value) + Positioned( + bottom: MediaQuery.of(context).padding.bottom + 60, + left: 16, + right: 16, + child: ExifInfoOverlay(item: item), + ), ImageControlOverlay( photoViewController: photoViewController, rotation: rotation, @@ -113,6 +123,11 @@ class CloudFileLightbox extends HookConsumerWidget { onToggleQuality: () { showOriginal.value = !showOriginal.value; }, + showExifInfo: showExif.value, + onToggleExif: () { + showExif.value = !showExif.value; + }, + hasExifData: hasExifData, extraButtons: [ FileActionButton.info( onPressed: showInfoSheet, diff --git a/lib/widgets/content/exif_info_overlay.dart b/lib/widgets/content/exif_info_overlay.dart new file mode 100644 index 00000000..33554a07 --- /dev/null +++ b/lib/widgets/content/exif_info_overlay.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import 'package:island/models/file.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class ExifInfoOverlay extends StatelessWidget { + final SnCloudFile item; + + const ExifInfoOverlay({super.key, required this.item}); + + static bool precheck(SnCloudFile item) { + final exifData = item.fileMeta?['exif'] as Map? ?? {}; + + if (exifData.isEmpty) return false; + + final dateTime = exifData['ifd0-DateTime']; + final model = exifData['ifd0-Model']; + final iso = exifData['ifd2-ISOSpeedRatings']; + final fnumber = exifData['ifd2-FNumber']; + final exposureTime = exifData['ifd2-ExposureTime']; + final focalLength = exifData['ifd2-FocalLength']; + + return (dateTime != null && dateTime.isNotEmpty) || + (model != null && model.isNotEmpty) || + iso != null || + fnumber != null || + exposureTime != null || + focalLength != null; + } + + bool _isPreferredValue(String key, String value) { + if ([ + 'ExposureTime', + 'FNumber', + 'FocalLength', + 'ApertureValue', + 'DateTime', + ].contains(key)) { + return true; + } + + return false; + } + + String _formatExifValue(String key, String value) { + final lastOpen = value.lastIndexOf('('); + final lastClose = value.endsWith(')') ? value.length - 1 : -1; + + if (lastOpen == -1 || lastClose == -1 || lastOpen > lastClose) { + return value; + } + + final inside = value.substring(lastOpen + 1, lastClose); + final commaIndex = inside.indexOf(','); + + if (commaIndex != -1) { + final candidate = inside.substring(0, commaIndex).trim(); + + if (_isPreferredValue(key, candidate)) { + return candidate; + } + } + + if (lastOpen == -1) { + return value; + } + + return value.substring(0, lastOpen).trimRight(); + } + + @override + Widget build(BuildContext context) { + final exifData = item.fileMeta?['exif'] as Map? ?? {}; + + if (exifData.isEmpty) return const SizedBox.shrink(); + + final dateTime = exifData['ifd0-DateTime']; + final model = exifData['ifd0-Model']; + final iso = exifData['ifd2-ISOSpeedRatings']; + final fnumber = exifData['ifd2-FNumber']; + final exposureTime = exifData['ifd2-ExposureTime']; + final focalLength = exifData['ifd2-FocalLength']; + + final items = []; + + if (dateTime != null && dateTime.isNotEmpty) { + items.add(_buildExifItem('DateTime', dateTime, Symbols.calendar_check)); + } + if (model != null && model.isNotEmpty) { + items.add(_buildExifItem('Model', model, Symbols.camera_alt)); + } + if (iso != null) { + items.add(_buildExifItem('ISO', iso, Icons.iso)); + } + if (fnumber != null) { + items.add(_buildExifItem('FNumber', fnumber, Symbols.camera_enhance)); + } + if (exposureTime != null) { + items.add( + _buildExifItem('ExposureTime', exposureTime, Icons.shutter_speed), + ); + } + if (focalLength != null) { + items.add( + _buildExifItem( + 'FocalLength', + focalLength, + Symbols.photo_size_select_large, + ), + ); + } + + if (items.isEmpty) return const SizedBox.shrink(); + + return Material( + color: Colors.transparent, + child: Container( + margin: const EdgeInsets.only(bottom: 16), + child: Wrap( + alignment: WrapAlignment.start, + children: items + .map( + (item) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: item, + ), + ) + .toList(), + ), + ), + ); + } + + Widget _buildExifItem(String key, String value, IconData icon) { + final formattedValue = _formatExifValue(key, value); + final shadow = [ + Shadow( + color: Colors.black54, + blurRadius: 5.0, + offset: const Offset(1.0, 1.0), + ), + ]; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: Colors.white70, shadows: shadow), + const SizedBox(width: 6), + Flexible( + child: Text( + formattedValue, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w500, + shadows: shadow, + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/content/file_viewer_contents.dart b/lib/widgets/content/file_viewer_contents.dart index 6c23412f..ca53fa60 100644 --- a/lib/widgets/content/file_viewer_contents.dart +++ b/lib/widgets/content/file_viewer_contents.dart @@ -14,6 +14,7 @@ import 'package:island/services/file_download.dart'; import 'package:island/utils/format.dart'; import 'package:island/widgets/content/audio.dart'; import 'package:island/widgets/content/cloud_files.dart'; +import 'package:island/widgets/content/exif_info_overlay.dart'; import 'package:island/widgets/content/file_info_sheet.dart'; import 'package:island/widgets/content/image_control_overlay.dart'; import 'package:island/widgets/content/video.dart'; @@ -136,7 +137,10 @@ class ImageFileContent extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final photoViewController = useMemoized(() => PhotoViewController(), []); final rotation = useState(0); + + final hasExifData = ExifInfoOverlay.precheck(item); final showOriginal = useState(false); + final showExif = useState(hasExifData); return Stack( children: [ @@ -177,6 +181,13 @@ class ImageFileContent extends HookConsumerWidget { ), ), ), + if (showExif.value) + Positioned( + bottom: MediaQuery.of(context).padding.bottom + 60, + left: 16, + right: 16, + child: ExifInfoOverlay(item: item), + ), ImageControlOverlay( photoViewController: photoViewController, rotation: rotation, @@ -184,6 +195,11 @@ class ImageFileContent extends HookConsumerWidget { onToggleQuality: () { showOriginal.value = !showOriginal.value; }, + showExifInfo: showExif.value, + onToggleExif: () { + showExif.value = !showExif.value; + }, + hasExifData: hasExifData, ), ], ); diff --git a/lib/widgets/content/image_control_overlay.dart b/lib/widgets/content/image_control_overlay.dart index e4417c6e..961620bd 100644 --- a/lib/widgets/content/image_control_overlay.dart +++ b/lib/widgets/content/image_control_overlay.dart @@ -13,6 +13,9 @@ class ImageControlOverlay extends HookWidget { final VoidCallback onToggleQuality; final List? extraButtons; final bool showExtraOnLeft; + final bool showExifInfo; + final VoidCallback onToggleExif; + final bool hasExifData; const ImageControlOverlay({ super.key, @@ -22,12 +25,19 @@ class ImageControlOverlay extends HookWidget { required this.onToggleQuality, this.extraButtons, this.showExtraOnLeft = false, + this.showExifInfo = false, + required this.onToggleExif, + this.hasExifData = true, }); @override Widget build(BuildContext context) { final shadow = [ - Shadow(color: Colors.black54, blurRadius: 5.0, offset: Offset(1.0, 1.0)), + Shadow( + color: Colors.black54, + blurRadius: 5.0, + offset: const Offset(1.0, 1.0), + ), ]; final controlButtons = [ @@ -58,6 +68,17 @@ class ImageControlOverlay extends HookWidget { photoViewController.rotation = rotation.value * -math.pi / 2; }, ), + if (hasExifData) ...[ + const Gap(8), + IconButton( + icon: Icon( + showExifInfo ? Icons.visibility : Icons.visibility_off, + color: Colors.white, + shadows: shadow, + ), + onPressed: onToggleExif, + ), + ], ]; return Positioned(