diff --git a/lib/messages.i18n.dart b/lib/messages.i18n.dart index d0342cc..7ea1ba7 100644 --- a/lib/messages.i18n.dart +++ b/lib/messages.i18n.dart @@ -67,6 +67,7 @@ class Messages { HomeMessages get home => HomeMessages(this); NavMessages get nav => NavMessages(this); ChecklistsMessages get checklists => ChecklistsMessages(this); + PhotoBoardMessages get photoBoard => PhotoBoardMessages(this); RecurrenceMessages get recurrence => RecurrenceMessages(this); } @@ -353,6 +354,117 @@ class SortChecklistsMessages { String get custom => """Custom"""; } +class PhotoBoardMessages { + final Messages _parent; + const PhotoBoardMessages(this._parent); + + /// ```dart + /// "No photos yet." + /// ``` + String get noPhotos => """No photos yet."""; + + /// ```dart + /// "Failed to load photos." + /// ``` + String get failedToLoad => """Failed to load photos."""; + + /// ```dart + /// "Failed to upload photo." + /// ``` + String get uploadFailed => """Failed to upload photo."""; + + /// ```dart + /// "Failed to delete photo." + /// ``` + String get deleteFailed => """Failed to delete photo."""; + + /// ```dart + /// "Delete this photo?" + /// ``` + String get deleteConfirm => """Delete this photo?"""; + + /// ```dart + /// "Delete folder" + /// ``` + String get deleteFolder => """Delete folder"""; + + /// ```dart + /// "Delete this folder?" + /// ``` + String get deleteFolderConfirm => """Delete this folder?"""; + + /// ```dart + /// "Move photos to root" + /// ``` + String get deleteFolderKeepPhotos => """Move photos to root"""; + + /// ```dart + /// "Delete folder and photos" + /// ``` + String get deleteFolderDeleteAll => """Delete folder and photos"""; + + /// ```dart + /// "New folder" + /// ``` + String get newFolder => """New folder"""; + + /// ```dart + /// "Folder name" + /// ``` + String get folderName => """Folder name"""; + + /// ```dart + /// "Rename folder" + /// ``` + String get renameFolder => """Rename folder"""; + + /// ```dart + /// "Caption" + /// ``` + String get caption => """Caption"""; + + /// ```dart + /// "$count" + /// ``` + String photoCount(int count) => """$count"""; + SortPhotoBoardMessages get sort => SortPhotoBoardMessages(this); +} + +class SortPhotoBoardMessages { + final PhotoBoardMessages _parent; + const SortPhotoBoardMessages(this._parent); + + /// ```dart + /// "Folders first" + /// ``` + String get foldersFirst => """Folders first"""; + + /// ```dart + /// "Newest first" + /// ``` + String get newestFirst => """Newest first"""; + + /// ```dart + /// "Oldest first" + /// ``` + String get oldestFirst => """Oldest first"""; + + /// ```dart + /// "Caption A–Z" + /// ``` + String get captionAZ => """Caption A–Z"""; + + /// ```dart + /// "Caption Z–A" + /// ``` + String get captionZA => """Caption Z–A"""; + + /// ```dart + /// "Custom" + /// ``` + String get custom => """Custom"""; +} + class RecurrenceMessages { final Messages _parent; const RecurrenceMessages(this._parent); @@ -648,6 +760,25 @@ Please complete login in your browser.""", """checklists.sort.nameAZ""": """Name A–Z""", """checklists.sort.nameZA""": """Name Z–A""", """checklists.sort.custom""": """Custom""", + """photoBoard.noPhotos""": """No photos yet.""", + """photoBoard.failedToLoad""": """Failed to load photos.""", + """photoBoard.uploadFailed""": """Failed to upload photo.""", + """photoBoard.deleteFailed""": """Failed to delete photo.""", + """photoBoard.deleteConfirm""": """Delete this photo?""", + """photoBoard.deleteFolder""": """Delete folder""", + """photoBoard.deleteFolderConfirm""": """Delete this folder?""", + """photoBoard.deleteFolderKeepPhotos""": """Move photos to root""", + """photoBoard.deleteFolderDeleteAll""": """Delete folder and photos""", + """photoBoard.newFolder""": """New folder""", + """photoBoard.folderName""": """Folder name""", + """photoBoard.renameFolder""": """Rename folder""", + """photoBoard.caption""": """Caption""", + """photoBoard.sort.foldersFirst""": """Folders first""", + """photoBoard.sort.newestFirst""": """Newest first""", + """photoBoard.sort.oldestFirst""": """Oldest first""", + """photoBoard.sort.captionAZ""": """Caption A–Z""", + """photoBoard.sort.captionZA""": """Caption Z–A""", + """photoBoard.sort.custom""": """Custom""", """recurrence.title""": """Recurrence""", """recurrence.presets""": """Presets""", """recurrence.daily""": """Daily""", diff --git a/lib/messages.i18n.yaml b/lib/messages.i18n.yaml index 99f5a9c..78a13e9 100644 --- a/lib/messages.i18n.yaml +++ b/lib/messages.i18n.yaml @@ -58,6 +58,29 @@ checklists: nameZA: "Name Z\u2013A" custom: Custom +photoBoard: + noPhotos: No photos yet. + failedToLoad: Failed to load photos. + uploadFailed: Failed to upload photo. + deleteFailed: Failed to delete photo. + deleteConfirm: Delete this photo? + deleteFolder: Delete folder + deleteFolderConfirm: Delete this folder? + deleteFolderKeepPhotos: Move photos to root + deleteFolderDeleteAll: Delete folder and photos + newFolder: New folder + folderName: Folder name + renameFolder: Rename folder + caption: Caption + photoCount(int count): "$count" + sort: + foldersFirst: Folders first + newestFirst: Newest first + oldestFirst: Oldest first + captionAZ: "Caption A\u2013Z" + captionZA: "Caption Z\u2013A" + custom: Custom + recurrence: title: Recurrence presets: Presets diff --git a/lib/models/photo.dart b/lib/models/photo.dart new file mode 100644 index 0000000..8c59323 --- /dev/null +++ b/lib/models/photo.dart @@ -0,0 +1,83 @@ +class Photo { + final int id; + final int houseId; + final int? folderId; + final int fileId; + final String? caption; + final String uploadedBy; + final int sortOrder; + final int createdAt; + final int updatedAt; + + const Photo({ + required this.id, + required this.houseId, + this.folderId, + required this.fileId, + this.caption, + required this.uploadedBy, + required this.sortOrder, + required this.createdAt, + required this.updatedAt, + }); + + factory Photo.fromJson(Map json) => Photo( + id: json['id'] as int, + houseId: json['houseId'] as int, + folderId: json['folderId'] as int?, + fileId: json['fileId'] as int, + caption: json['caption'] as String?, + uploadedBy: json['uploadedBy'] as String, + sortOrder: json['sortOrder'] as int, + createdAt: json['createdAt'] as int, + updatedAt: json['updatedAt'] as int, + ); + + Map toJson() => { + 'id': id, + 'houseId': houseId, + 'folderId': folderId, + 'fileId': fileId, + 'caption': caption, + 'uploadedBy': uploadedBy, + 'sortOrder': sortOrder, + 'createdAt': createdAt, + 'updatedAt': updatedAt, + }; +} + +class PhotoFolder { + final int id; + final int houseId; + final String name; + final int sortOrder; + final int createdAt; + final int updatedAt; + + const PhotoFolder({ + required this.id, + required this.houseId, + required this.name, + required this.sortOrder, + required this.createdAt, + required this.updatedAt, + }); + + factory PhotoFolder.fromJson(Map json) => PhotoFolder( + id: json['id'] as int, + houseId: json['houseId'] as int, + name: json['name'] as String, + sortOrder: json['sortOrder'] as int, + createdAt: json['createdAt'] as int, + updatedAt: json['updatedAt'] as int, + ); + + Map toJson() => { + 'id': id, + 'houseId': houseId, + 'name': name, + 'sortOrder': sortOrder, + 'createdAt': createdAt, + 'updatedAt': updatedAt, + }; +} diff --git a/lib/services/api_client.dart b/lib/services/api_client.dart index 5800221..4dc8ae0 100644 --- a/lib/services/api_client.dart +++ b/lib/services/api_client.dart @@ -94,6 +94,53 @@ class ApiClient { } } + /// Upload raw bytes (e.g. image) via POST with a given content type. + Future uploadBytes( + String path, { + required List bytes, + required String contentType, + Map? query, + required T Function(D data) fromJson, + }) async { + final headers = { + ..._credentials.basicAuthHeaders, + 'Accept': 'application/json', + 'Content-Type': contentType, + }; + final response = await http.post( + _uri(path, query), + headers: headers, + body: bytes, + ); + return _handleResponse(response, fromJson); + } + + /// Upload a file via multipart form POST. + Future uploadMultipart( + String path, { + required List bytes, + required String fileName, + required String mimeType, + String fieldName = 'file', + Map? fields, + required T Function(D data) fromJson, + }) async { + final request = http.MultipartRequest('POST', _uri(path)) + ..headers.addAll({ + ..._credentials.basicAuthHeaders, + 'Accept': 'application/json', + }) + ..files.add( + http.MultipartFile.fromBytes(fieldName, bytes, filename: fileName), + ); + if (fields != null) { + request.fields.addAll(fields); + } + final streamed = await request.send(); + final response = await http.Response.fromStream(streamed); + return _handleResponse(response, fromJson); + } + Uri buildUri(String path, [Map? query]) => _uri(path, query); Map get authHeaders => _credentials.basicAuthHeaders; diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 5a2e292..073a698 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -60,9 +60,6 @@ class AuthService { NextcloudCredentials? get credentials => _credentials; bool get isLoggedIn => _credentials != null; - Uint8List? _avatarBytes; - Uint8List? get avatarBytes => _avatarBytes; - /// First day of week from Nextcloud user settings. /// 0 = Sunday, 1 = Monday, ..., 6 = Saturday. int _firstDayOfWeek = _firstDayFromLocale(); @@ -72,25 +69,7 @@ class AuthService { final json = await _storage.read(key: _credentialsKey); if (json != null) { _credentials = NextcloudCredentials.fromJson(jsonDecode(json)); - await Future.wait([fetchAvatar(), fetchFirstDayOfWeek()]); - } - } - - Future fetchAvatar() async { - if (_credentials == null) return; - try { - final uri = Uri.parse( - '${_credentials!.serverUrl}/index.php/avatar/${_credentials!.loginName}/128', - ); - final response = await http.get( - uri, - headers: _credentials!.basicAuthHeaders, - ); - if (response.statusCode == 200 && response.bodyBytes.isNotEmpty) { - _avatarBytes = response.bodyBytes; - } - } catch (e) { - debugPrint('[AuthService] Failed to load avatar: $e'); + await fetchFirstDayOfWeek(); } } @@ -314,7 +293,6 @@ class AuthService { } } _credentials = null; - _avatarBytes = null; await _storage.delete(key: _credentialsKey); } } diff --git a/lib/services/photo_service.dart b/lib/services/photo_service.dart new file mode 100644 index 0000000..a783ba0 --- /dev/null +++ b/lib/services/photo_service.dart @@ -0,0 +1,213 @@ +import 'package:pantry/models/photo.dart'; +import 'package:pantry/services/api_client.dart'; +import 'package:pantry/services/cache_store.dart'; + +class PhotoService { + PhotoService._(); + static final PhotoService instance = PhotoService._(); + + final cache = CacheStore('photo_cache.json'); + + static const _photosKey = 'photos'; + static const _foldersKey = 'folders'; + static const _houseIdKey = 'houseId'; + static const _sortByKey = 'sortBy'; + static const _foldersFirstKey = 'foldersFirst'; + + // -- Cache accessors -- + + List? getCachedPhotos(int houseId) { + if (cache.get(_houseIdKey) != houseId) return null; + return cache.getList(_photosKey, Photo.fromJson); + } + + void cachePhotos(int houseId, List photos) { + cache.set(_houseIdKey, houseId); + cache.setList(_photosKey, photos, (p) => p.toJson()); + } + + List? getCachedFolders(int houseId) { + if (cache.get(_houseIdKey) != houseId) return null; + return cache.getList(_foldersKey, PhotoFolder.fromJson); + } + + void cacheFolders(int houseId, List folders) { + cache.set(_houseIdKey, houseId); + cache.setList(_foldersKey, folders, (f) => f.toJson()); + } + + String get cachedSortBy => cache.get(_sortByKey) ?? 'custom'; + set cachedSortBy(String value) => cache.set(_sortByKey, value); + + bool get cachedFoldersFirst => cache.get(_foldersFirstKey) ?? true; + set cachedFoldersFirst(bool value) => cache.set(_foldersFirstKey, value); + + // -- Photos -- + + Future> getPhotos( + int houseId, { + String sortBy = 'custom', + int limit = 200, + int offset = 0, + }) async { + return ApiClient.instance.get>( + '/houses/$houseId/photos', + query: {'sortBy': sortBy, 'limit': '$limit', 'offset': '$offset'}, + fromJson: (data) => + data.map((e) => Photo.fromJson(e as Map)).toList(), + ); + } + + Future uploadPhoto( + int houseId, { + required List bytes, + required String fileName, + required String mimeType, + int? folderId, + String? caption, + }) async { + final fields = {}; + if (folderId != null) fields['folderId'] = '$folderId'; + if (caption != null && caption.isNotEmpty) fields['caption'] = caption; + + return ApiClient.instance.uploadMultipart, Photo>( + '/houses/$houseId/photos', + bytes: bytes, + fileName: fileName, + mimeType: mimeType, + fieldName: 'image', + fields: fields.isNotEmpty ? fields : null, + fromJson: (data) => Photo.fromJson(data), + ); + } + + Future updatePhoto( + int houseId, + int photoId, { + String? caption, + int? folderId, + bool moveToRoot = false, + }) async { + return ApiClient.instance.patch, Photo>( + '/houses/$houseId/photos/$photoId', + body: { + if (caption != null) 'caption': caption, + if (moveToRoot) 'folderId': 0, + if (!moveToRoot && folderId != null) 'folderId': folderId, + }, + fromJson: (data) => Photo.fromJson(data), + ); + } + + Future deletePhoto(int houseId, int photoId) async { + await ApiClient.instance.delete('/houses/$houseId/photos/$photoId'); + } + + Future reorderPhotos( + int houseId, + List<({int id, int sortOrder})> order, + ) async { + await ApiClient.instance.post, void>( + '/houses/$houseId/photos/reorder', + body: { + 'items': order + .map((e) => {'id': e.id, 'sortOrder': e.sortOrder}) + .toList(), + }, + fromJson: (_) {}, + ); + } + + Uri photoPreviewUri(int houseId, int photoId, {int size = 300}) { + return ApiClient.instance.buildUri( + '/houses/$houseId/photos/$photoId/preview', + {'size': '$size'}, + ); + } + + // -- Folders -- + + Future> getFolders( + int houseId, { + String sortBy = 'custom', + }) async { + return ApiClient.instance.get>( + '/houses/$houseId/photos/folders', + query: {'sortBy': sortBy}, + fromJson: (data) => data + .map((e) => PhotoFolder.fromJson(e as Map)) + .toList(), + ); + } + + Future createFolder(int houseId, {required String name}) async { + return ApiClient.instance.post, PhotoFolder>( + '/houses/$houseId/photos/folders', + body: {'name': name}, + fromJson: (data) => PhotoFolder.fromJson(data), + ); + } + + Future updateFolder( + int houseId, + int folderId, { + String? name, + }) async { + return ApiClient.instance.patch, PhotoFolder>( + '/houses/$houseId/photos/folders/$folderId', + body: {if (name != null) 'name': name}, + fromJson: (data) => PhotoFolder.fromJson(data), + ); + } + + Future deleteFolder( + int houseId, + int folderId, { + bool deleteContents = false, + }) async { + // deleteContents as query param + final path = deleteContents + ? '/houses/$houseId/photos/folders/$folderId?deleteContents=true' + : '/houses/$houseId/photos/folders/$folderId'; + await ApiClient.instance.delete(path); + } + + Future reorderFolders( + int houseId, + List<({int id, int sortOrder})> order, + ) async { + await ApiClient.instance.post, void>( + '/houses/$houseId/photos/folders/reorder', + body: { + 'items': order + .map((e) => {'id': e.id, 'sortOrder': e.sortOrder}) + .toList(), + }, + fromJson: (_) {}, + ); + } + + // -- House Prefs -- + + Future> getHousePrefs(int houseId) async { + return ApiClient.instance.get, Map>( + '/houses/$houseId/prefs', + fromJson: (data) => data, + ); + } + + Future setHousePrefs( + int houseId, { + String? photoSort, + bool? photoFoldersFirst, + }) async { + await ApiClient.instance.put, void>( + '/houses/$houseId/prefs', + body: { + if (photoSort != null) 'photoSort': photoSort, + if (photoFoldersFirst != null) 'photoFoldersFirst': photoFoldersFirst, + }, + fromJson: (_) {}, + ); + } +} diff --git a/lib/views/checklists/checklist_item_tile.dart b/lib/views/checklists/checklist_item_tile.dart index 9a82d81..ba70ca4 100644 --- a/lib/views/checklists/checklist_item_tile.dart +++ b/lib/views/checklists/checklist_item_tile.dart @@ -1,3 +1,4 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:pantry/i18n.dart'; import 'package:pantry/models/category.dart' as models; @@ -197,13 +198,13 @@ class _ItemImage extends StatelessWidget { return ClipRRect( borderRadius: BorderRadius.circular(4), - child: Image.network( - uri.toString(), - headers: headers, + child: CachedNetworkImage( + imageUrl: uri.toString(), + httpHeaders: headers, width: 40, height: 40, fit: BoxFit.cover, - errorBuilder: (_, _, _) => const SizedBox( + errorWidget: (_, _, _) => const SizedBox( width: 40, height: 40, child: Icon(Icons.broken_image_outlined, size: 20), diff --git a/lib/views/checklists/item_detail_view.dart b/lib/views/checklists/item_detail_view.dart index cdf6ba1..afede9f 100644 --- a/lib/views/checklists/item_detail_view.dart +++ b/lib/views/checklists/item_detail_view.dart @@ -1,3 +1,4 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:pantry/i18n.dart'; import 'package:pantry/models/category.dart' as models; @@ -122,11 +123,11 @@ class _CoverImage extends StatelessWidget { ); final headers = AuthService.instance.credentials?.basicAuthHeaders ?? {}; - return Image.network( - uri.toString(), - headers: headers, + return CachedNetworkImage( + imageUrl: uri.toString(), + httpHeaders: headers, fit: BoxFit.cover, - errorBuilder: (_, _, _) => Center( + errorWidget: (_, _, _) => Center( child: Icon( Icons.broken_image_outlined, size: 48, diff --git a/lib/views/home/home_view.dart b/lib/views/home/home_view.dart index cda1cf9..6578441 100644 --- a/lib/views/home/home_view.dart +++ b/lib/views/home/home_view.dart @@ -1,5 +1,4 @@ -import 'dart:typed_data'; - +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -7,6 +6,7 @@ import 'package:pantry/i18n.dart'; import 'package:pantry/models/house.dart'; import 'package:pantry/services/auth_service.dart'; import 'package:pantry/views/checklists/checklists_view.dart'; +import 'package:pantry/views/photos/photo_board_view.dart'; import 'home_controller.dart'; class HomeView extends StatefulWidget { @@ -72,7 +72,6 @@ class _HomeViewBodyState extends State<_HomeViewBody> { _UserMenuButton( houses: controller.houses, currentHouse: controller.currentHouse, - avatarBytes: AuthService.instance.avatarBytes, onHouseSelected: controller.selectHouse, onLogout: widget.onLogout, ), @@ -132,7 +131,10 @@ class _HomeViewBodyState extends State<_HomeViewBody> { houseId: houseId, ); case 1: - return Center(child: Text(m.nav.photoBoard)); + return PhotoBoardView( + key: ValueKey('photos-$houseId'), + houseId: houseId, + ); case 2: return Center(child: Text(m.nav.notesWall)); default: @@ -144,31 +146,40 @@ class _HomeViewBodyState extends State<_HomeViewBody> { class _UserMenuButton extends StatelessWidget { final List houses; final House? currentHouse; - final Uint8List? avatarBytes; final ValueChanged onHouseSelected; final VoidCallback onLogout; const _UserMenuButton({ required this.houses, required this.currentHouse, - required this.avatarBytes, required this.onHouseSelected, required this.onLogout, }); @override Widget build(BuildContext context) { - final loginName = AuthService.instance.credentials?.loginName ?? ''; + final creds = AuthService.instance.credentials; + final loginName = creds?.loginName ?? ''; return PopupMenuButton( offset: const Offset(0, 48), tooltip: loginName, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), - child: avatarBytes != null - ? CircleAvatar( - radius: 16, - backgroundImage: MemoryImage(avatarBytes!), + child: creds != null + ? CachedNetworkImage( + imageUrl: '${creds.serverUrl}/index.php/avatar/$loginName/128', + httpHeaders: creds.basicAuthHeaders, + imageBuilder: (_, imageProvider) => + CircleAvatar(radius: 16, backgroundImage: imageProvider), + errorWidget: (_, _, _) => const CircleAvatar( + radius: 16, + child: Icon(Icons.person, size: 20), + ), + placeholder: (_, _) => const CircleAvatar( + radius: 16, + child: Icon(Icons.person, size: 20), + ), ) : const CircleAvatar( radius: 16, diff --git a/lib/views/photos/photo_board_controller.dart b/lib/views/photos/photo_board_controller.dart new file mode 100644 index 0000000..262c4e6 --- /dev/null +++ b/lib/views/photos/photo_board_controller.dart @@ -0,0 +1,428 @@ +import 'package:flutter/foundation.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:pantry/i18n.dart'; +import 'package:pantry/models/photo.dart'; +import 'package:pantry/services/photo_service.dart'; + +class UploadTask { + final String fileName; + final Uint8List? thumbnailBytes; + final String mimeType; + double progress; + bool done; + String? error; + Photo? result; + + UploadTask({ + required this.fileName, + this.thumbnailBytes, + this.mimeType = 'image/jpeg', + }) : progress = 0.0, + done = false; + + void reset() { + progress = 0.0; + done = false; + error = null; + result = null; + } +} + +class PhotoBoardController extends ChangeNotifier { + final int houseId; + + PhotoBoardController({required this.houseId}); + + PhotoService get _service => PhotoService.instance; + + List _photos = []; + List get photos => _photos; + + List _folders = []; + List get folders => _folders; + + /// Current folder we are viewing (null = root). + int? _currentFolderId; + int? get currentFolderId => _currentFolderId; + + PhotoFolder? get currentFolder => _currentFolderId != null + ? _folders.cast().firstWhere( + (f) => f!.id == _currentFolderId, + orElse: () => null, + ) + : null; + + String _sortBy = 'custom'; + String get sortBy => _sortBy; + + bool _foldersFirst = true; + bool get foldersFirst => _foldersFirst; + + bool _isLoading = true; + bool get isLoading => _isLoading; + + String? _error; + String? get error => _error; + + final List _uploads = []; + List get uploads => _uploads; + + /// Items visible in the current view (folders at root + photos in current folder). + List get visiblePhotos { + if (_currentFolderId != null) { + return _photos.where((p) => p.folderId == _currentFolderId).toList(); + } + return _photos.where((p) => p.folderId == null).toList(); + } + + List get visibleFolders { + if (_currentFolderId != null) return []; + return _folders; + } + + /// Count of photos inside a folder. + int folderPhotoCount(int folderId) => + _photos.where((p) => p.folderId == folderId).length; + + /// Most recent 3 photos in a folder for preview thumbnails. + List folderPreviewPhotos(int folderId) { + final inFolder = _photos.where((p) => p.folderId == folderId).toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + return inFolder.take(3).toList(); + } + + Future load() async { + _error = null; + + // Restore from cache immediately + _restoreFromCache(); + + if (_photos.isEmpty && _folders.isEmpty) { + _isLoading = true; + notifyListeners(); + } + + try { + // Load prefs first (non-fatal) to know sort order + try { + final prefs = await _service.getHousePrefs(houseId); + _sortBy = prefs['photoSort'] as String? ?? 'custom'; + _foldersFirst = prefs['photoFoldersFirst'] as bool? ?? true; + _service.cachedSortBy = _sortBy; + _service.cachedFoldersFirst = _foldersFirst; + } catch (e) { + debugPrint('[PhotoBoardController] Failed to load prefs: $e'); + } + + final results = await Future.wait([ + _service.getPhotos(houseId, sortBy: _sortBy), + _service.getFolders(houseId, sortBy: _sortBy), + ]); + + _photos = results[0] as List; + _folders = results[1] as List; + _service.cachePhotos(houseId, _photos); + _service.cacheFolders(houseId, _folders); + + _isLoading = false; + notifyListeners(); + } catch (e) { + debugPrint('[PhotoBoardController] Failed to load: $e'); + if (_photos.isEmpty && _folders.isEmpty) { + _error = m.photoBoard.failedToLoad; + } + _isLoading = false; + notifyListeners(); + } + } + + void _restoreFromCache() { + _sortBy = _service.cachedSortBy; + _foldersFirst = _service.cachedFoldersFirst; + + final cachedPhotos = _service.getCachedPhotos(houseId); + if (cachedPhotos != null && _photos.isEmpty) { + _photos = cachedPhotos; + } + + final cachedFolders = _service.getCachedFolders(houseId); + if (cachedFolders != null && _folders.isEmpty) { + _folders = cachedFolders; + } + + if (_photos.isNotEmpty || _folders.isNotEmpty) { + _isLoading = false; + notifyListeners(); + } + } + + Future refresh() async { + await load(); + } + + void enterFolder(int folderId) { + _currentFolderId = folderId; + notifyListeners(); + } + + void exitFolder() { + _currentFolderId = null; + notifyListeners(); + } + + Future setSortBy(String sort) async { + if (sort == _sortBy) return; + _sortBy = sort; + notifyListeners(); + _service.setHousePrefs(houseId, photoSort: sort); + await _reloadPhotos(); + } + + Future setFoldersFirst(bool value) async { + if (value == _foldersFirst) return; + _foldersFirst = value; + notifyListeners(); + _service.setHousePrefs(houseId, photoFoldersFirst: value); + } + + Future _reloadPhotos() async { + try { + final results = await Future.wait([ + _service.getPhotos(houseId, sortBy: _sortBy), + _service.getFolders(houseId, sortBy: _sortBy), + ]); + _photos = results[0] as List; + _folders = results[1] as List; + _service.cachePhotos(houseId, _photos); + _service.cacheFolders(houseId, _folders); + notifyListeners(); + } catch (e) { + debugPrint('[PhotoBoardController] Failed to reload: $e'); + } + } + + // -- Upload -- + + Future uploadPhotos(List files) async { + // Create all tasks up front with thumbnail bytes + final tasks = <(UploadTask, XFile)>[]; + for (final file in files) { + final bytes = await file.readAsBytes(); + final task = UploadTask( + fileName: file.name, + thumbnailBytes: bytes, + mimeType: file.mimeType ?? 'image/jpeg', + ); + _uploads.add(task); + tasks.add((task, file)); + } + notifyListeners(); + + for (final (task, _) in tasks) { + await _runUpload(task); + } + + _cleanUpDoneUploads(); + } + + Future _runUpload(UploadTask task) async { + try { + task.progress = 0.3; + notifyListeners(); + + final photo = await _service.uploadPhoto( + houseId, + bytes: task.thumbnailBytes!, + fileName: task.fileName, + mimeType: task.mimeType, + folderId: _currentFolderId, + ); + _photos.insert(0, photo); + _service.cachePhotos(houseId, _photos); + task.result = photo; + task.progress = 1.0; + task.done = true; + notifyListeners(); + } catch (e) { + debugPrint('[PhotoBoardController] Upload failed: $e'); + task.error = e.toString(); + task.done = true; + notifyListeners(); + } + } + + Future retryUpload(UploadTask task) async { + task.reset(); + notifyListeners(); + await _runUpload(task); + _cleanUpDoneUploads(); + } + + void dismissUpload(UploadTask task) { + _uploads.remove(task); + notifyListeners(); + } + + void _cleanUpDoneUploads() { + Future.delayed(const Duration(seconds: 2), () { + _uploads.removeWhere((t) => t.done && t.error == null); + notifyListeners(); + }); + } + + // -- Delete -- + + Future deletePhoto(Photo photo) async { + await _service.deletePhoto(houseId, photo.id); + _photos.removeWhere((p) => p.id == photo.id); + _service.cachePhotos(houseId, _photos); + notifyListeners(); + } + + // -- Move to folder -- + + Future movePhotoToFolder(int photoId, int? folderId) async { + await _service.updatePhoto( + houseId, + photoId, + folderId: folderId, + moveToRoot: folderId == null, + ); + final index = _photos.indexWhere((p) => p.id == photoId); + if (index != -1) { + // Reload the photo from server to get updated state + await _reloadPhotos(); + } + } + + // -- Caption -- + + Future updateCaption(Photo photo, String caption) async { + final updated = await _service.updatePhoto( + houseId, + photo.id, + caption: caption, + ); + final index = _photos.indexWhere((p) => p.id == photo.id); + if (index != -1) { + _photos[index] = updated; + _service.cachePhotos(houseId, _photos); + notifyListeners(); + } + } + + // -- Folders -- + + Future createFolder(String name) async { + final folder = await _service.createFolder(houseId, name: name); + _folders.add(folder); + _service.cacheFolders(houseId, _folders); + notifyListeners(); + return folder; + } + + Future renameFolder(PhotoFolder folder, String name) async { + final updated = await _service.updateFolder(houseId, folder.id, name: name); + final index = _folders.indexWhere((f) => f.id == folder.id); + if (index != -1) { + _folders[index] = updated; + _service.cacheFolders(houseId, _folders); + notifyListeners(); + } + } + + Future deleteFolder( + PhotoFolder folder, { + bool deleteContents = false, + }) async { + await _service.deleteFolder( + houseId, + folder.id, + deleteContents: deleteContents, + ); + _folders.removeWhere((f) => f.id == folder.id); + _service.cacheFolders(houseId, _folders); + if (!deleteContents) { + // Photos moved to root — reload + await _reloadPhotos(); + } else { + _photos.removeWhere((p) => p.folderId == folder.id); + _service.cachePhotos(houseId, _photos); + notifyListeners(); + } + } + + // -- Reorder -- + + int? _draggingId; + int? get draggingId => _draggingId; + + void startDrag(int photoId) { + _draggingId = photoId; + notifyListeners(); + } + + /// Called during drag when hovering over [targetId]. + /// Moves the dragged photo to that position, shifting others visually. + void hoverReorder(int targetId) { + if (_draggingId == null || _draggingId == targetId) return; + + final visible = visiblePhotos; + final fromIndex = visible.indexWhere((p) => p.id == _draggingId); + final toIndex = visible.indexWhere((p) => p.id == targetId); + if (fromIndex == -1 || toIndex == -1) return; + + _applyVisibleOrder(visible, fromIndex, toIndex); + notifyListeners(); + } + + /// Finalize drag — persist the current order to server. + void endDrag() { + if (_draggingId == null) return; + _draggingId = null; + + final visible = visiblePhotos; + final order = <({int id, int sortOrder})>[]; + for (var i = 0; i < visible.length; i++) { + order.add((id: visible[i].id, sortOrder: i)); + } + + _service.cachePhotos(houseId, _photos); + notifyListeners(); + _service.reorderPhotos(houseId, order); + } + + void cancelDrag() { + _draggingId = null; + notifyListeners(); + } + + void _applyVisibleOrder(List visible, int fromIndex, int toIndex) { + final item = visible.removeAt(fromIndex); + visible.insert(toIndex, item); + + final updatedOrder = {}; + for (var i = 0; i < visible.length; i++) { + updatedOrder[visible[i].id] = i; + } + + _photos = _photos.map((p) { + final newSort = updatedOrder[p.id]; + if (newSort != null) { + return Photo( + id: p.id, + houseId: p.houseId, + folderId: p.folderId, + fileId: p.fileId, + caption: p.caption, + uploadedBy: p.uploadedBy, + sortOrder: newSort, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + ); + } + return p; + }).toList(); + _photos.sort((a, b) => a.sortOrder.compareTo(b.sortOrder)); + } +} diff --git a/lib/views/photos/photo_board_view.dart b/lib/views/photos/photo_board_view.dart new file mode 100644 index 0000000..b1fefbf --- /dev/null +++ b/lib/views/photos/photo_board_view.dart @@ -0,0 +1,994 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:pantry/i18n.dart'; +import 'package:pantry/models/photo.dart'; +import 'package:pantry/services/auth_service.dart'; +import 'package:pantry/services/photo_service.dart'; +import 'package:provider/provider.dart'; +import 'photo_board_controller.dart'; + +class PhotoBoardView extends StatefulWidget { + final int houseId; + + const PhotoBoardView({super.key, required this.houseId}); + + @override + State createState() => _PhotoBoardViewState(); +} + +class _PhotoBoardViewState extends State { + late final _controller = PhotoBoardController(houseId: widget.houseId); + + @override + void initState() { + super.initState(); + _controller.load(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider.value( + value: _controller, + child: const _PhotoBoardBody(), + ); + } +} + +class _PhotoBoardBody extends StatelessWidget { + const _PhotoBoardBody(); + + @override + Widget build(BuildContext context) { + final controller = context.watch(); + + if (controller.isLoading && controller.photos.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.error != null && controller.photos.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(controller.error!, textAlign: TextAlign.center), + const SizedBox(height: 16), + FilledButton( + onPressed: controller.load, + child: Text(m.common.retry), + ), + ], + ), + ), + ); + } + + return Stack( + children: [ + Column( + children: [ + _TopBar(controller: controller), + Expanded( + child: RefreshIndicator( + onRefresh: controller.refresh, + child: _PhotoGrid(controller: controller), + ), + ), + ], + ), + Positioned( + right: 16, + bottom: 16, + child: _AddButton(controller: controller), + ), + ], + ); + } +} + +class _TopBar extends StatelessWidget { + final PhotoBoardController controller; + + const _TopBar({required this.controller}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 16, top: 8, bottom: 8, right: 4), + child: Row( + children: [ + if (controller.currentFolderId != null) ...[ + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: controller.exitFolder, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + controller.currentFolder?.name ?? '', + style: Theme.of(context).textTheme.titleMedium, + overflow: TextOverflow.ellipsis, + ), + ), + ] else + const Spacer(), + _SortButton(controller: controller), + ], + ), + ); + } +} + +class _SortButton extends StatelessWidget { + final PhotoBoardController controller; + + const _SortButton({required this.controller}); + + static const _sortKeys = [ + 'newest', + 'oldest', + 'description_asc', + 'description_desc', + 'custom', + ]; + + static String _label(String key) => switch (key) { + 'newest' => m.photoBoard.sort.newestFirst, + 'oldest' => m.photoBoard.sort.oldestFirst, + 'description_asc' => m.photoBoard.sort.captionAZ, + 'description_desc' => m.photoBoard.sort.captionZA, + 'custom' => m.photoBoard.sort.custom, + _ => key, + }; + + @override + Widget build(BuildContext context) { + return PopupMenuButton( + icon: const Icon(Icons.sort), + tooltip: '', + onSelected: (value) { + if (value == '_folders_first') { + controller.setFoldersFirst(!controller.foldersFirst); + } else { + controller.setSortBy(value); + } + }, + itemBuilder: (context) => [ + CheckedPopupMenuItem( + value: '_folders_first', + checked: controller.foldersFirst, + child: Text(m.photoBoard.sort.foldersFirst), + ), + const PopupMenuDivider(), + for (final key in _sortKeys) + PopupMenuItem( + value: key, + child: Row( + children: [ + Icon( + key == controller.sortBy + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + size: 20, + color: key == controller.sortBy + ? Theme.of(context).colorScheme.primary + : null, + ), + const SizedBox(width: 12), + Text(_label(key)), + ], + ), + ), + ], + ); + } +} + +class _PhotoGrid extends StatelessWidget { + final PhotoBoardController controller; + + const _PhotoGrid({required this.controller}); + + @override + Widget build(BuildContext context) { + final folders = controller.visibleFolders; + final photos = controller.visiblePhotos; + + if (folders.isEmpty && photos.isEmpty) { + return ListView( + children: [ + const SizedBox(height: 100), + Center(child: Text(m.photoBoard.noPhotos)), + ], + ); + } + + // Active uploads (not yet completed or with errors) + final activeUploads = controller.uploads + .where((t) => !t.done || t.error != null) + .toList(); + + // Build combined items list + final items = <_GridItem>[]; + if (controller.foldersFirst) { + for (final f in folders) { + items.add(_GridItem.folder(f)); + } + } + // Insert upload tiles before photos + for (final t in activeUploads) { + items.add(_GridItem.upload(t)); + } + for (final p in photos) { + if (p.id == controller.draggingId) { + // Show a placeholder in the dragged photo's current sorted position + items.add(_GridItem.placeholder()); + } else { + items.add(_GridItem.photo(p)); + } + } + if (!controller.foldersFirst) { + for (final f in folders) { + items.add(_GridItem.folder(f)); + } + } + + return GridView.builder( + padding: const EdgeInsets.all(8), + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 180, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + ), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + if (item.folder != null) { + return _FolderTile( + folder: item.folder!, + photoCount: controller.folderPhotoCount(item.folder!.id), + previewPhotos: controller.folderPreviewPhotos(item.folder!.id), + houseId: controller.houseId, + controller: controller, + ); + } + if (item.isPlaceholder) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.primary, + width: 2, + ), + color: Theme.of(context).colorScheme.primary.withAlpha(20), + ), + ); + } + if (item.upload != null) { + return _UploadTile(task: item.upload!, controller: controller); + } + return _PhotoTile( + photo: item.photo!, + houseId: controller.houseId, + controller: controller, + ); + }, + ); + } +} + +class _GridItem { + final Photo? photo; + final PhotoFolder? folder; + final UploadTask? upload; + final bool isPlaceholder; + + _GridItem.photo(this.photo) + : folder = null, + upload = null, + isPlaceholder = false; + _GridItem.folder(this.folder) + : photo = null, + upload = null, + isPlaceholder = false; + _GridItem.upload(this.upload) + : photo = null, + folder = null, + isPlaceholder = false; + _GridItem.placeholder() + : photo = null, + folder = null, + upload = null, + isPlaceholder = true; +} + +class _FolderTile extends StatelessWidget { + final PhotoFolder folder; + final int photoCount; + final List previewPhotos; + final int houseId; + final PhotoBoardController controller; + + const _FolderTile({ + required this.folder, + required this.photoCount, + required this.previewPhotos, + required this.houseId, + required this.controller, + }); + + // Rotation angles for stacked preview cards (bottom to top) + static const _angles = [-0.08, 0.05, 0.0]; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final headers = AuthService.instance.credentials?.basicAuthHeaders ?? {}; + + return DragTarget( + onAcceptWithDetails: (details) { + controller.movePhotoToFolder(details.data, folder.id); + }, + builder: (context, candidateData, rejectedData) { + final isHovering = candidateData.isNotEmpty; + return GestureDetector( + onTap: () => controller.enterFolder(folder.id), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: isHovering + ? Border.all(color: theme.colorScheme.primary, width: 2) + : null, + ), + child: Stack( + children: [ + // Photo stack or folder icon — full bleed + Positioned.fill( + child: Padding( + padding: const EdgeInsets.all(8), + child: previewPhotos.isNotEmpty + ? _buildPhotoStack(theme, headers) + : Center( + child: Icon( + Icons.folder, + size: 56, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ), + // Count badge + if (photoCount > 0) + Positioned( + top: 6, + left: 6, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: theme.colorScheme.inverseSurface, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + m.photoBoard.photoCount(photoCount), + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onInverseSurface, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + // Folder name with gradient + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + padding: const EdgeInsets.only( + left: 6, + right: 6, + bottom: 6, + top: 20, + ), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.black.withAlpha(180), + Colors.transparent, + ], + ), + ), + child: Text( + folder.name, + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.white, + ), + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ), + ), + // Menu + Positioned( + top: 2, + right: 2, + child: _TileMenuButton( + items: [ + PopupMenuItem( + value: 'rename', + child: Row( + children: [ + const Icon(Icons.edit, size: 18), + const SizedBox(width: 8), + Text(m.photoBoard.renameFolder), + ], + ), + ), + PopupMenuItem( + value: 'delete', + child: Row( + children: [ + const Icon(Icons.delete, size: 18), + const SizedBox(width: 8), + Text(m.photoBoard.deleteFolder), + ], + ), + ), + ], + onSelected: (value) { + switch (value) { + case 'rename': + _renameFolder(context); + case 'delete': + _deleteFolder(context); + } + }, + ), + ), + ], + ), + ), + ); + }, + ); + } + + Widget _buildPhotoStack(ThemeData theme, Map headers) { + // Show up to 3 photos stacked with slight rotations + final count = previewPhotos.length; + final cards = []; + + for (var i = 0; i < count; i++) { + final photo = previewPhotos[count - 1 - i]; // bottom-most first + final angle = + _angles[(_angles.length - count + i).clamp(0, _angles.length - 1)]; + final uri = PhotoService.instance.photoPreviewUri( + houseId, + photo.id, + size: 128, + ); + + cards.add( + Transform.rotate( + angle: angle, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: theme.colorScheme.outlineVariant, + width: 1.5, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(40), + blurRadius: 4, + offset: const Offset(1, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(5), + child: CachedNetworkImage( + imageUrl: uri.toString(), + httpHeaders: headers, + fit: BoxFit.cover, + ), + ), + ), + ), + ); + } + + return Stack(alignment: Alignment.center, children: cards); + } + + void _renameFolder(BuildContext context) { + final textController = TextEditingController(text: folder.name); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(m.photoBoard.renameFolder), + content: TextField( + controller: textController, + autofocus: true, + decoration: InputDecoration( + labelText: m.photoBoard.folderName, + border: const OutlineInputBorder(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text(m.common.cancel), + ), + FilledButton( + onPressed: () { + final name = textController.text.trim(); + if (name.isNotEmpty) { + controller.renameFolder(folder, name); + Navigator.pop(ctx); + } + }, + child: Text(m.common.save), + ), + ], + ), + ); + } + + void _deleteFolder(BuildContext context) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(m.photoBoard.deleteFolderConfirm), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text(m.common.cancel), + ), + OutlinedButton( + onPressed: () { + Navigator.pop(ctx); + controller.deleteFolder(folder); + }, + child: Text(m.photoBoard.deleteFolderKeepPhotos), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Theme.of(ctx).colorScheme.error, + ), + onPressed: () { + Navigator.pop(ctx); + controller.deleteFolder(folder, deleteContents: true); + }, + child: Text(m.photoBoard.deleteFolderDeleteAll), + ), + ], + ), + ); + } +} + +class _PhotoTile extends StatelessWidget { + final Photo photo; + final int houseId; + final PhotoBoardController controller; + + const _PhotoTile({ + required this.photo, + required this.houseId, + required this.controller, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final uri = PhotoService.instance.photoPreviewUri(houseId, photo.id); + final headers = AuthService.instance.credentials?.basicAuthHeaders ?? {}; + + return DragTarget( + onWillAcceptWithDetails: (details) => details.data != photo.id, + onAcceptWithDetails: (_) { + // Drop finalized — endDrag is called via onDragEnd + }, + onMove: (details) { + controller.hoverReorder(photo.id); + }, + builder: (context, candidateData, _) { + return LongPressDraggable( + data: photo.id, + onDragStarted: () => controller.startDrag(photo.id), + onDragEnd: (_) => controller.endDrag(), + onDraggableCanceled: (_, _) => controller.cancelDrag(), + feedback: Material( + elevation: 4, + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: 100, + height: 100, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: CachedNetworkImage( + imageUrl: uri.toString(), + httpHeaders: headers, + fit: BoxFit.cover, + ), + ), + ), + ), + child: _buildTileContent(context, theme, uri, headers), + ); + }, + ); + } + + Widget _buildTileContent( + BuildContext context, + ThemeData theme, + Uri uri, + Map headers, + ) { + return GestureDetector( + onTap: () => _showPhotoDetail(context, uri, headers), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Stack( + fit: StackFit.expand, + children: [ + CachedNetworkImage( + imageUrl: uri.toString(), + httpHeaders: headers, + fit: BoxFit.cover, + errorWidget: (_, _, _) => Container( + color: theme.colorScheme.surfaceContainerHighest, + child: const Icon(Icons.broken_image_outlined, size: 32), + ), + ), + Positioned( + top: 2, + right: 2, + child: _TileMenuButton( + items: [ + PopupMenuItem( + value: 'caption', + child: Row( + children: [ + const Icon(Icons.edit, size: 18), + const SizedBox(width: 8), + Text(m.photoBoard.caption), + ], + ), + ), + PopupMenuItem( + value: 'delete', + child: Row( + children: [ + const Icon(Icons.delete, size: 18), + const SizedBox(width: 8), + Text(m.common.delete), + ], + ), + ), + ], + onSelected: (value) { + switch (value) { + case 'caption': + _editCaption(context); + case 'delete': + _confirmDelete(context); + } + }, + ), + ), + if (photo.caption != null && photo.caption!.isNotEmpty) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 4, + ), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [Colors.black.withAlpha(180), Colors.transparent], + ), + ), + child: Text( + photo.caption!, + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.white, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), + ), + ); + } + + void _showPhotoDetail( + BuildContext context, + Uri previewUri, + Map headers, + ) { + final fullUri = PhotoService.instance.photoPreviewUri( + houseId, + photo.id, + size: 1024, + ); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => _PhotoDetailPage( + photo: photo, + imageUri: fullUri, + headers: headers, + controller: controller, + ), + ), + ); + } + + void _editCaption(BuildContext context) { + final textController = TextEditingController(text: photo.caption ?? ''); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(m.photoBoard.caption), + content: TextField( + controller: textController, + autofocus: true, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + labelText: m.photoBoard.caption, + border: const OutlineInputBorder(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text(m.common.cancel), + ), + FilledButton( + onPressed: () { + controller.updateCaption(photo, textController.text.trim()); + Navigator.pop(ctx); + }, + child: Text(m.common.save), + ), + ], + ), + ); + } + + void _confirmDelete(BuildContext context) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(m.photoBoard.deleteConfirm), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text(m.common.cancel), + ), + FilledButton( + onPressed: () { + Navigator.pop(ctx); + controller.deletePhoto(photo); + }, + child: Text(m.common.delete), + ), + ], + ), + ); + } +} + +class _PhotoDetailPage extends StatelessWidget { + final Photo photo; + final Uri imageUri; + final Map headers; + final PhotoBoardController controller; + + const _PhotoDetailPage({ + required this.photo, + required this.imageUri, + required this.headers, + required this.controller, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(photo.caption ?? '')), + body: InteractiveViewer( + child: Center( + child: CachedNetworkImage( + imageUrl: imageUri.toString(), + httpHeaders: headers, + fit: BoxFit.contain, + errorWidget: (_, _, _) => + const Icon(Icons.broken_image_outlined, size: 64), + ), + ), + ), + ); + } +} + +class _UploadTile extends StatelessWidget { + final UploadTask task; + final PhotoBoardController controller; + + const _UploadTile({required this.task, required this.controller}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final hasError = task.error != null; + + return GestureDetector( + onTap: hasError ? () => controller.retryUpload(task) : null, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Stack( + fit: StackFit.expand, + children: [ + if (task.thumbnailBytes != null) + Image.memory( + task.thumbnailBytes!, + fit: BoxFit.cover, + opacity: const AlwaysStoppedAnimation(0.4), + ) + else + Container(color: theme.colorScheme.surfaceContainerHighest), + Center( + child: hasError + ? Icon( + Icons.refresh, + color: theme.colorScheme.error, + size: 32, + ) + : SizedBox( + width: 36, + height: 36, + child: CircularProgressIndicator( + value: task.progress > 0 ? task.progress : null, + strokeWidth: 3, + ), + ), + ), + Positioned( + top: 4, + right: 4, + child: GestureDetector( + onTap: () => controller.dismissUpload(task), + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, + ), + child: const Icon(Icons.close, size: 16, color: Colors.white), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _AddButton extends StatelessWidget { + final PhotoBoardController controller; + + const _AddButton({required this.controller}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + FloatingActionButton.small( + heroTag: 'photo_folder', + onPressed: () => _createFolder(context), + child: const Icon(Icons.create_new_folder), + ), + const SizedBox(height: 8), + FloatingActionButton( + heroTag: 'photo_upload', + onPressed: () => _pickPhotos(context), + child: const Icon(Icons.add_photo_alternate), + ), + ], + ); + } + + Future _pickPhotos(BuildContext context) async { + final picker = ImagePicker(); + final files = await picker.pickMultiImage(); + if (files.isNotEmpty) { + controller.uploadPhotos(files); + } + } + + void _createFolder(BuildContext context) { + final textController = TextEditingController(); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(m.photoBoard.newFolder), + content: TextField( + controller: textController, + autofocus: true, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + labelText: m.photoBoard.folderName, + border: const OutlineInputBorder(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text(m.common.cancel), + ), + FilledButton( + onPressed: () { + final name = textController.text.trim(); + if (name.isNotEmpty) { + controller.createFolder(name); + Navigator.pop(ctx); + } + }, + child: Text(m.common.save), + ), + ], + ), + ); + } +} + +class _TileMenuButton extends StatelessWidget { + final List> items; + final ValueChanged onSelected; + + const _TileMenuButton({required this.items, required this.onSelected}); + + @override + Widget build(BuildContext context) { + return PopupMenuButton( + icon: Icon( + Icons.more_vert, + size: 20, + color: Colors.white, + shadows: const [Shadow(blurRadius: 4)], + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onSelected: onSelected, + itemBuilder: (_) => items, + ); + } +} diff --git a/lib/widgets/image_preview.dart b/lib/widgets/image_preview.dart index f9eb6e4..9d1ae51 100644 --- a/lib/widgets/image_preview.dart +++ b/lib/widgets/image_preview.dart @@ -1,3 +1,4 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; class ImagePreview extends StatelessWidget { @@ -52,11 +53,11 @@ class ImagePreview extends StatelessWidget { child: Center( child: Hero( tag: heroTag, - child: Image.network( - imageUrl, - headers: headers, + child: CachedNetworkImage( + imageUrl: imageUrl, + httpHeaders: headers, fit: BoxFit.contain, - errorBuilder: (_, _, _) => const Icon( + errorWidget: (_, _, _) => const Icon( Icons.broken_image_outlined, size: 64, color: Colors.white54, diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 38dd0bc..3ccd551 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,10 +6,14 @@ #include "generated_plugin_registrant.h" +#include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 7e7bd77..fbedf4a 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux flutter_secure_storage_linux url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 23da481..e7b0845 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,14 +5,18 @@ import FlutterMacOS import Foundation +import file_selector_macos import flutter_secure_storage_darwin import package_info_plus +import sqflite_darwin import url_launcher_macos import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 2fc53af..d84c063 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -121,6 +121,30 @@ packages: url: "https://pub.dev" source: hosted version: "8.12.5" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" characters: dependency: transitive description: @@ -185,6 +209,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -249,6 +281,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" fixnum: dependency: transitive description: @@ -262,6 +326,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" flutter_launcher_icons: dependency: "direct dev" description: @@ -286,6 +358,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.7" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" flutter_secure_storage: dependency: "direct main" description: @@ -433,6 +513,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.8.0" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "66810af8e99b2657ee98e5c6f02064f69bb63f7a70e343937f70946c5f8c6622" + url: "https://pub.dev" + source: hosted + version: "0.8.13+16" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" intl: dependency: "direct main" description: @@ -577,6 +721,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.3.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" package_config: dependency: transitive description: @@ -774,6 +926,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" + url: "https://pub.dev" + source: hosted + version: "2.4.2+3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" stack_trace: dependency: transitive description: @@ -806,6 +998,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" term_glyph: dependency: transitive description: @@ -910,6 +1110,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" vector_graphics: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5099124..e4df84d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,8 @@ dependencies: wakelock_plus: ^1.5.1 path_provider: ^2.1.5 intl: ^0.20.2 + cached_network_image: ^3.4.1 + image_picker: ^1.1.2 i18n: git: https://github.com/chenasraf/i18n diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 2048c45..602b168 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,10 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index e17c858..b68f91d 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows flutter_secure_storage_windows url_launcher_windows )