mirror of
https://github.com/chenasraf/pantry-flutter.git
synced 2026-05-17 17:28:03 +00:00
feat: photo board
This commit is contained in:
@@ -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""",
|
||||
|
||||
@@ -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
83
lib/models/photo.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
213
lib/services/photo_service.dart
Normal file
213
lib/services/photo_service.dart
Normal 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: (_) {},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
428
lib/views/photos/photo_board_controller.dart
Normal file
428
lib/views/photos/photo_board_controller.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
994
lib/views/photos/photo_board_view.dart
Normal file
994
lib/views/photos/photo_board_view.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
flutter_secure_storage_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
208
pubspec.lock
208
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
flutter_secure_storage_windows
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user