feat: photo board

This commit is contained in:
2026-04-09 16:23:43 +03:00
parent 138f9a58c4
commit 43cc0a3fcf
19 changed files with 2180 additions and 46 deletions

View File

@@ -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 AZ"
/// ```
String get captionAZ => """Caption AZ""";
/// ```dart
/// "Caption ZA"
/// ```
String get captionZA => """Caption ZA""";
/// ```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 AZ""",
"""checklists.sort.nameZA""": """Name ZA""",
"""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 AZ""",
"""photoBoard.sort.captionZA""": """Caption ZA""",
"""photoBoard.sort.custom""": """Custom""",
"""recurrence.title""": """Recurrence""",
"""recurrence.presets""": """Presets""",
"""recurrence.daily""": """Daily""",

View File

@@ -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

83
lib/models/photo.dart Normal file
View File

@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> toJson() => {
'id': id,
'houseId': houseId,
'name': name,
'sortOrder': sortOrder,
'createdAt': createdAt,
'updatedAt': updatedAt,
};
}

View File

@@ -94,6 +94,53 @@ class ApiClient {
}
}
/// Upload raw bytes (e.g. image) via POST with a given content type.
Future<T> uploadBytes<D, T>(
String path, {
required List<int> bytes,
required String contentType,
Map<String, String>? 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<D, T>(response, fromJson);
}
/// Upload a file via multipart form POST.
Future<T> uploadMultipart<D, T>(
String path, {
required List<int> bytes,
required String fileName,
required String mimeType,
String fieldName = 'file',
Map<String, String>? 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<D, T>(response, fromJson);
}
Uri buildUri(String path, [Map<String, String>? query]) => _uri(path, query);
Map<String, String> get authHeaders => _credentials.basicAuthHeaders;

View File

@@ -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<void> 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);
}
}

View File

@@ -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<Photo>? getCachedPhotos(int houseId) {
if (cache.get<int>(_houseIdKey) != houseId) return null;
return cache.getList(_photosKey, Photo.fromJson);
}
void cachePhotos(int houseId, List<Photo> photos) {
cache.set(_houseIdKey, houseId);
cache.setList(_photosKey, photos, (p) => p.toJson());
}
List<PhotoFolder>? getCachedFolders(int houseId) {
if (cache.get<int>(_houseIdKey) != houseId) return null;
return cache.getList(_foldersKey, PhotoFolder.fromJson);
}
void cacheFolders(int houseId, List<PhotoFolder> folders) {
cache.set(_houseIdKey, houseId);
cache.setList(_foldersKey, folders, (f) => f.toJson());
}
String get cachedSortBy => cache.get<String>(_sortByKey) ?? 'custom';
set cachedSortBy(String value) => cache.set(_sortByKey, value);
bool get cachedFoldersFirst => cache.get<bool>(_foldersFirstKey) ?? true;
set cachedFoldersFirst(bool value) => cache.set(_foldersFirstKey, value);
// -- Photos --
Future<List<Photo>> getPhotos(
int houseId, {
String sortBy = 'custom',
int limit = 200,
int offset = 0,
}) async {
return ApiClient.instance.get<List, List<Photo>>(
'/houses/$houseId/photos',
query: {'sortBy': sortBy, 'limit': '$limit', 'offset': '$offset'},
fromJson: (data) =>
data.map((e) => Photo.fromJson(e as Map<String, dynamic>)).toList(),
);
}
Future<Photo> uploadPhoto(
int houseId, {
required List<int> bytes,
required String fileName,
required String mimeType,
int? folderId,
String? caption,
}) async {
final fields = <String, String>{};
if (folderId != null) fields['folderId'] = '$folderId';
if (caption != null && caption.isNotEmpty) fields['caption'] = caption;
return ApiClient.instance.uploadMultipart<Map<String, dynamic>, Photo>(
'/houses/$houseId/photos',
bytes: bytes,
fileName: fileName,
mimeType: mimeType,
fieldName: 'image',
fields: fields.isNotEmpty ? fields : null,
fromJson: (data) => Photo.fromJson(data),
);
}
Future<Photo> updatePhoto(
int houseId,
int photoId, {
String? caption,
int? folderId,
bool moveToRoot = false,
}) async {
return ApiClient.instance.patch<Map<String, dynamic>, 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<void> deletePhoto(int houseId, int photoId) async {
await ApiClient.instance.delete('/houses/$houseId/photos/$photoId');
}
Future<void> reorderPhotos(
int houseId,
List<({int id, int sortOrder})> order,
) async {
await ApiClient.instance.post<Map<String, dynamic>, 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<List<PhotoFolder>> getFolders(
int houseId, {
String sortBy = 'custom',
}) async {
return ApiClient.instance.get<List, List<PhotoFolder>>(
'/houses/$houseId/photos/folders',
query: {'sortBy': sortBy},
fromJson: (data) => data
.map((e) => PhotoFolder.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
Future<PhotoFolder> createFolder(int houseId, {required String name}) async {
return ApiClient.instance.post<Map<String, dynamic>, PhotoFolder>(
'/houses/$houseId/photos/folders',
body: {'name': name},
fromJson: (data) => PhotoFolder.fromJson(data),
);
}
Future<PhotoFolder> updateFolder(
int houseId,
int folderId, {
String? name,
}) async {
return ApiClient.instance.patch<Map<String, dynamic>, PhotoFolder>(
'/houses/$houseId/photos/folders/$folderId',
body: {if (name != null) 'name': name},
fromJson: (data) => PhotoFolder.fromJson(data),
);
}
Future<void> 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<void> reorderFolders(
int houseId,
List<({int id, int sortOrder})> order,
) async {
await ApiClient.instance.post<Map<String, dynamic>, void>(
'/houses/$houseId/photos/folders/reorder',
body: {
'items': order
.map((e) => {'id': e.id, 'sortOrder': e.sortOrder})
.toList(),
},
fromJson: (_) {},
);
}
// -- House Prefs --
Future<Map<String, dynamic>> getHousePrefs(int houseId) async {
return ApiClient.instance.get<Map<String, dynamic>, Map<String, dynamic>>(
'/houses/$houseId/prefs',
fromJson: (data) => data,
);
}
Future<void> setHousePrefs(
int houseId, {
String? photoSort,
bool? photoFoldersFirst,
}) async {
await ApiClient.instance.put<Map<String, dynamic>, void>(
'/houses/$houseId/prefs',
body: {
if (photoSort != null) 'photoSort': photoSort,
if (photoFoldersFirst != null) 'photoFoldersFirst': photoFoldersFirst,
},
fromJson: (_) {},
);
}
}

View File

@@ -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),

View File

@@ -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,

View File

@@ -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<House> houses;
final House? currentHouse;
final Uint8List? avatarBytes;
final ValueChanged<House> 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<Object>(
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,

View File

@@ -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<Photo> _photos = [];
List<Photo> get photos => _photos;
List<PhotoFolder> _folders = [];
List<PhotoFolder> get folders => _folders;
/// Current folder we are viewing (null = root).
int? _currentFolderId;
int? get currentFolderId => _currentFolderId;
PhotoFolder? get currentFolder => _currentFolderId != null
? _folders.cast<PhotoFolder?>().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<UploadTask> _uploads = [];
List<UploadTask> get uploads => _uploads;
/// Items visible in the current view (folders at root + photos in current folder).
List<Photo> get visiblePhotos {
if (_currentFolderId != null) {
return _photos.where((p) => p.folderId == _currentFolderId).toList();
}
return _photos.where((p) => p.folderId == null).toList();
}
List<PhotoFolder> 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<Photo> 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<void> 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<Photo>;
_folders = results[1] as List<PhotoFolder>;
_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<void> refresh() async {
await load();
}
void enterFolder(int folderId) {
_currentFolderId = folderId;
notifyListeners();
}
void exitFolder() {
_currentFolderId = null;
notifyListeners();
}
Future<void> setSortBy(String sort) async {
if (sort == _sortBy) return;
_sortBy = sort;
notifyListeners();
_service.setHousePrefs(houseId, photoSort: sort);
await _reloadPhotos();
}
Future<void> setFoldersFirst(bool value) async {
if (value == _foldersFirst) return;
_foldersFirst = value;
notifyListeners();
_service.setHousePrefs(houseId, photoFoldersFirst: value);
}
Future<void> _reloadPhotos() async {
try {
final results = await Future.wait([
_service.getPhotos(houseId, sortBy: _sortBy),
_service.getFolders(houseId, sortBy: _sortBy),
]);
_photos = results[0] as List<Photo>;
_folders = results[1] as List<PhotoFolder>;
_service.cachePhotos(houseId, _photos);
_service.cacheFolders(houseId, _folders);
notifyListeners();
} catch (e) {
debugPrint('[PhotoBoardController] Failed to reload: $e');
}
}
// -- Upload --
Future<void> uploadPhotos(List<XFile> 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<void> _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<void> 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<void> 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<void> 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<void> 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<PhotoFolder> createFolder(String name) async {
final folder = await _service.createFolder(houseId, name: name);
_folders.add(folder);
_service.cacheFolders(houseId, _folders);
notifyListeners();
return folder;
}
Future<void> 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<void> 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<Photo> visible, int fromIndex, int toIndex) {
final item = visible.removeAt(fromIndex);
visible.insert(toIndex, item);
final updatedOrder = <int, int>{};
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));
}
}

View File

@@ -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<PhotoBoardView> createState() => _PhotoBoardViewState();
}
class _PhotoBoardViewState extends State<PhotoBoardView> {
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<PhotoBoardController>();
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<String>(
icon: const Icon(Icons.sort),
tooltip: '',
onSelected: (value) {
if (value == '_folders_first') {
controller.setFoldersFirst(!controller.foldersFirst);
} else {
controller.setSortBy(value);
}
},
itemBuilder: (context) => [
CheckedPopupMenuItem<String>(
value: '_folders_first',
checked: controller.foldersFirst,
child: Text(m.photoBoard.sort.foldersFirst),
),
const PopupMenuDivider(),
for (final key in _sortKeys)
PopupMenuItem<String>(
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<Photo> 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<int>(
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<String, String> headers) {
// Show up to 3 photos stacked with slight rotations
final count = previewPhotos.length;
final cards = <Widget>[];
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<int>(
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<int>(
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<String, String> 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<String, String> 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<String, String> 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<void> _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<PopupMenuEntry<String>> items;
final ValueChanged<String> onSelected;
const _TileMenuButton({required this.items, required this.onSelected});
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
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,
);
}
}

View File

@@ -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,

View File

@@ -6,10 +6,14 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
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);

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
flutter_secure_storage_linux
url_launcher_linux
)

View File

@@ -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"))
}

View File

@@ -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:

View File

@@ -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

View File

@@ -6,10 +6,13 @@
#include "generated_plugin_registrant.h"
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
flutter_secure_storage_windows
url_launcher_windows
)