feat: photos/notes multiselect

This commit is contained in:
2026-04-09 18:10:51 +03:00
parent 755861aa91
commit 0053f53cd7
8 changed files with 394 additions and 132 deletions

138
Makefile
View File

@@ -4,10 +4,6 @@ VERSION := $(shell grep '^version:' pubspec.yaml | sed 's/version: *//;s/+.*//')
# Get staged Dart files
STAGED_DART_FILES := $(shell git diff --cached --name-only --diff-filter=ACM | grep '\.dart$$' 2>/dev/null)
# Check for staged files in subprojects
STAGED_FUNCTIONS_FILES := $(shell git diff --cached --name-only --diff-filter=ACM | grep '^functions/' 2>/dev/null)
STAGED_WEBSITE_FILES := $(shell git diff --cached --name-only --diff-filter=ACM | grep '^website/' 2>/dev/null)
# Default target
.PHONY: help
help:
@@ -17,21 +13,7 @@ help:
@echo " get Install dependencies"
@echo " clean Clean build artifacts"
@echo " install-precommit Install git pre-commit hook"
@echo " sync-env Sync .env to iOS Secrets.xcconfig"
@echo " pods Update CocoaPods repo and install pods"
@echo " rules-deploy Deploy Firestore and Storage security rules"
@echo ""
@echo " Website:"
@echo " website-install Install website dependencies"
@echo " website-run Run website dev server"
@echo " website-build Build website"
@echo ""
@echo " Functions:"
@echo " functions-install Install functions dependencies"
@echo " functions-build Build functions"
@echo " functions-serve Run functions with emulators"
@echo " functions-deploy Deploy functions to Firebase"
@echo " functions-logs View functions logs"
@echo ""
@echo " i18n:"
@echo " i18n Build i18n generated Dart code"
@@ -63,6 +45,7 @@ help:
@echo " android-install Build APK and install on connected device"
@echo " android-build-apk Build Android APK"
@echo " android-build-aab Build Android App Bundle"
@echo " android-push Build APK and push to device via adb"
@echo " ios-build Build iOS (no codesign)"
@echo " web-build Build web app"
@echo " build-all Build all platforms"
@@ -78,10 +61,6 @@ help:
@echo " android-deploy Build AAB and upload to Google Play (TRACK=internal|beta|production, STATUS=draft|completed)"
@echo " android-promote Promote release between tracks (FROM=internal, TO=production, STATUS=draft|completed)"
@echo " ios-deploy Build IPA and upload (DEST=testflight|appstore, default: testflight)"
@echo " webapp-deploy Build and deploy Flutter web app"
@echo " hosting-deploy Build and deploy marketing website"
@echo " website-deploy Deploy website + functions"
@echo " deploy-all Deploy all platforms (SKIP_WEBAPP=1 SKIP_IOS=1 SKIP_ANDROID=1 SKIP_WEBSITE=1 to skip)"
# Setup
.PHONY: get
@@ -149,21 +128,11 @@ precommit: format analyze
echo "Re-staging formatted files..."; \
git add $(STAGED_DART_FILES); \
fi
@if [ -n "$(STAGED_FUNCTIONS_FILES)" ]; then \
echo "Running lint-staged for functions..."; \
cd functions && pnpm lint-staged; \
fi
@if [ -n "$(STAGED_WEBSITE_FILES)" ]; then \
echo "Running lint-staged for website..."; \
cd website && pnpm lint-staged; \
fi
# Full project commands
.PHONY: format-all
format-all:
dart format .
cd functions && pnpm prettier --write .
cd website && pnpm prettier --write .
.PHONY: analyze-all
analyze-all:
@@ -197,6 +166,11 @@ android-build-apk:
android-install: android-build-apk
flutter install
.PHONY: android-push
android-push: android-build-apk
adb push build/app/outputs/flutter-apk/app-release.apk /sdcard/Download/pantry-$(VERSION).apk
@echo "-> /sdcard/Download/pantry-$(VERSION).apk"
.PHONY: android-build-aab
android-build-aab:
flutter build appbundle --release --obfuscate --split-debug-info=build/debug-info-aab --dart-define-from-file=.env
@@ -216,20 +190,20 @@ build-all: android-build-apk android-build-aab web-build
.PHONY: android-release-apk
android-release-apk: android-build-apk
mkdir -p build/release
cp build/app/outputs/flutter-apk/app-release.apk build/release/retroachieved-$(VERSION).apk
@echo "-> build/release/retroachieved-$(VERSION).apk"
cp build/app/outputs/flutter-apk/app-release.apk build/release/pantry-$(VERSION).apk
@echo "-> build/release/pantry-$(VERSION).apk"
.PHONY: android-release-aab
android-release-aab: android-build-aab
mkdir -p build/release
cp build/app/outputs/bundle/release/app-release.aab build/release/retroachieved-$(VERSION).aab
@echo "-> build/release/retroachieved-$(VERSION).aab"
cp build/app/outputs/bundle/release/app-release.aab build/release/pantry-$(VERSION).aab
@echo "-> build/release/pantry-$(VERSION).aab"
.PHONY: ios-release
ios-release: ios-build
mkdir -p build/release
cp build/ios/ipa/*.ipa build/release/retroachieved-$(VERSION).ipa
@echo "-> build/release/retroachieved-$(VERSION).ipa"
cp build/ios/ipa/*.ipa build/release/pantry-$(VERSION).ipa
@echo "-> build/release/pantry-$(VERSION).ipa"
.PHONY: android-upload
android-upload:
@@ -265,25 +239,12 @@ ios-deploy: ios-build ios-upload
.PHONY: web-release
web-release: web-build
mkdir -p build/release
cd build/web && zip -r ../release/retroachieved-$(VERSION)-web.zip .
@echo "-> build/release/retroachieved-$(VERSION)-web.zip"
cd build/web && zip -r ../release/pantry-$(VERSION)-web.zip .
@echo "-> build/release/pantry-$(VERSION)-web.zip"
.PHONY: release-all
release-all: build-clean android-release-apk android-release-aab web-release
# Environment sync
.PHONY: sync-env
sync-env:
@echo "Syncing .env to iOS Secrets.xcconfig..."
@if [ -f .env ]; then \
echo "// Auto-generated from .env - DO NOT EDIT" > ios/Flutter/Secrets.xcconfig; \
grep -E "^[A-Za-z_][A-Za-z0-9_]*=" .env >> ios/Flutter/Secrets.xcconfig; \
echo "Secrets.xcconfig updated."; \
else \
echo "Error: .env file not found"; \
exit 1; \
fi
# CocoaPods
.PHONY: pods
pods:
@@ -303,36 +264,6 @@ install-precommit:
echo "Pre-commit hook already exists, skipping."; \
fi
# Firebase
.PHONY: rules-deploy
rules-deploy:
firebase deploy --only firestore:rules,storage
# Functions
.PHONY: functions-install
functions-install:
cd functions && pnpm install
.PHONY: functions-build
functions-build:
cd functions && pnpm build
.PHONY: functions-serve
functions-serve:
cd functions && pnpm serve
.PHONY: functions-deploy
functions-deploy:
ifdef FN
firebase deploy --only functions:$(FN)
else
firebase deploy --only functions
endif
.PHONY: functions-logs
functions-logs:
firebase functions:log
# API
.PHONY: fetch-openapi
fetch-openapi:
@@ -364,44 +295,3 @@ splash:
mkdir -p assets/icon
rsvg-convert -h 512 --page-width 1024 --page-height 1024 --top 256 --left 256 assets/logo_icon.svg > assets/icon/splash.png
dart run flutter_native_splash:create
# Website
.PHONY: website-install
website-install:
cd website && pnpm install
.PHONY: website-run
website-run:
cd website && pnpm dev
.PHONY: website-build
website-build:
cd website && pnpm build
# Hosting
.PHONY: hosting-deploy
hosting-deploy: website-build
firebase deploy --only hosting:website
.PHONY: webapp-deploy
webapp-deploy: web-build
firebase deploy --only hosting:webapp
.PHONY: website-deploy
website-deploy:
firebase deploy --only hosting:website,functions
.PHONY: deploy-all
deploy-all:
ifndef SKIP_WEBAPP
$(MAKE) webapp-deploy
endif
ifndef SKIP_IOS
$(MAKE) ios-deploy
endif
ifndef SKIP_ANDROID
$(MAKE) android-deploy STATUS=completed
endif
ifndef SKIP_WEBSITE
$(MAKE) website-deploy
endif

View File

@@ -384,6 +384,12 @@ class NotesWallMessages {
/// ```
String get deleteConfirm => """Delete this note?""";
/// ```dart
/// "Delete ${_plural(count, one: 'this note', many: '$count notes')}?"
/// ```
String deleteSelectedConfirm(int count) =>
"""Delete ${_plural(count, one: 'this note', many: '$count notes')}?""";
/// ```dart
/// "New note"
/// ```
@@ -470,6 +476,12 @@ class PhotoBoardMessages {
/// ```
String get deleteConfirm => """Delete this photo?""";
/// ```dart
/// "Delete ${_plural(count, one: 'this photo', many: '$count photos')}?"
/// ```
String deleteSelectedConfirm(int count) =>
"""Delete ${_plural(count, one: 'this photo', many: '$count photos')}?""";
/// ```dart
/// "Delete folder"
/// ```

View File

@@ -64,6 +64,7 @@ notesWall:
saveFailed: Failed to save note.
deleteFailed: Failed to delete note.
deleteConfirm: Delete this note?
deleteSelectedConfirm(int count): "Delete ${_plural(count, one: 'this note', many: '$count notes')}?"
newNote: New note
editNote: Edit note
title: Title
@@ -82,6 +83,7 @@ photoBoard:
uploadFailed: Failed to upload photo.
deleteFailed: Failed to delete photo.
deleteConfirm: Delete this photo?
deleteSelectedConfirm(int count): "Delete ${_plural(count, one: 'this photo', many: '$count photos')}?"
deleteFolder: Delete folder
deleteFolderConfirm: Delete this folder?
deleteFolderKeepPhotos: Move photos to root

View File

@@ -23,6 +23,51 @@ class NotesController extends ChangeNotifier {
String? _error;
String? get error => _error;
// -- Selection --
bool _selectMode = false;
bool get selectMode => _selectMode;
final Set<int> _selected = {};
Set<int> get selected => _selected;
void toggleSelectMode() {
_selectMode = !_selectMode;
if (!_selectMode) _selected.clear();
notifyListeners();
}
void toggleSelection(int noteId) {
if (_selected.contains(noteId)) {
_selected.remove(noteId);
} else {
_selected.add(noteId);
}
notifyListeners();
}
void clearSelection() {
_selected.clear();
_selectMode = false;
notifyListeners();
}
Future<void> deleteSelected() async {
final ids = Set<int>.from(_selected);
for (final id in ids) {
try {
await _service.deleteNote(houseId, id);
_notes.removeWhere((n) => n.id == id);
} catch (e) {
debugPrint('[NotesController] Failed to delete note $id: $e');
}
}
_selected.clear();
_selectMode = false;
_service.cacheNotes(houseId, _notes);
notifyListeners();
}
// -- Drag reorder state --
int? _draggingId;

View File

@@ -76,7 +76,16 @@ class _NotesWallBody extends StatelessWidget {
child: Row(
children: [
const Spacer(),
_SortButton(controller: controller),
if (controller.selectMode)
_NoteSelectionActions(controller: controller)
else ...[
IconButton(
icon: const Icon(Icons.checklist),
tooltip: '',
onPressed: controller.toggleSelectMode,
),
_SortButton(controller: controller),
],
],
),
),
@@ -107,6 +116,57 @@ class _NotesWallBody extends StatelessWidget {
}
}
class _NoteSelectionActions extends StatelessWidget {
final NotesController controller;
const _NoteSelectionActions({required this.controller});
@override
Widget build(BuildContext context) {
final count = controller.selected.length;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('$count', style: Theme.of(context).textTheme.titleSmall),
IconButton(
icon: const Icon(Icons.delete_outlined),
tooltip: '',
onPressed: count > 0 ? () => _confirmDelete(context) : null,
),
IconButton(
icon: const Icon(Icons.close),
tooltip: '',
onPressed: controller.clearSelection,
),
],
);
}
void _confirmDelete(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text(
m.notesWall.deleteSelectedConfirm(controller.selected.length),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(m.common.cancel),
),
FilledButton(
onPressed: () {
Navigator.pop(ctx);
controller.deleteSelected();
},
child: Text(m.common.delete),
),
],
),
);
}
}
class _SortButton extends StatelessWidget {
final NotesController controller;
@@ -221,6 +281,27 @@ class _NoteTile extends StatelessWidget {
_parseColor(note.color) ?? theme.colorScheme.surfaceContainerHighest;
final textColor = _contrastColor(bgColor);
if (controller.selectMode) {
final isSelected = controller.selected.contains(note.id);
return GestureDetector(
onTap: () => controller.toggleSelection(note.id),
child: Stack(
children: [
_buildCard(theme, bgColor, textColor),
Positioned(
top: 4,
left: 4,
child: Icon(
isSelected ? Icons.check_circle : Icons.circle_outlined,
color: isSelected ? theme.colorScheme.primary : textColor,
size: 24,
),
),
],
),
);
}
return DragTarget<int>(
onWillAcceptWithDetails: (details) => details.data != note.id,
onAcceptWithDetails: (_) {},

View File

@@ -67,6 +67,70 @@ class PhotoBoardController extends ChangeNotifier {
final List<UploadTask> _uploads = [];
List<UploadTask> get uploads => _uploads;
// -- Selection --
bool _selectMode = false;
bool get selectMode => _selectMode;
final Set<int> _selected = {};
Set<int> get selected => _selected;
void toggleSelectMode() {
_selectMode = !_selectMode;
if (!_selectMode) _selected.clear();
notifyListeners();
}
void toggleSelection(int photoId) {
if (_selected.contains(photoId)) {
_selected.remove(photoId);
} else {
_selected.add(photoId);
}
notifyListeners();
}
void clearSelection() {
_selected.clear();
_selectMode = false;
notifyListeners();
}
Future<void> deleteSelected() async {
final ids = Set<int>.from(_selected);
for (final id in ids) {
try {
await _service.deletePhoto(houseId, id);
_photos.removeWhere((p) => p.id == id);
} catch (e) {
debugPrint('[PhotoBoardController] Failed to delete photo $id: $e');
}
}
_selected.clear();
_selectMode = false;
_service.cachePhotos(houseId, _photos);
notifyListeners();
}
Future<void> moveSelectedToFolder(int? folderId) async {
final ids = Set<int>.from(_selected);
for (final id in ids) {
try {
await _service.updatePhoto(
houseId,
id,
folderId: folderId,
moveToRoot: folderId == null,
);
} catch (e) {
debugPrint('[PhotoBoardController] Failed to move photo $id: $e');
}
}
_selected.clear();
_selectMode = false;
await _reloadPhotos();
}
/// Items visible in the current view (folders at root + photos in current folder).
List<Photo> get visiblePhotos {
if (_currentFolderId != null) {

View File

@@ -120,7 +120,16 @@ class _TopBar extends StatelessWidget {
),
] else
const Spacer(),
_SortButton(controller: controller),
if (controller.selectMode)
_SelectionActions(controller: controller)
else ...[
IconButton(
icon: const Icon(Icons.checklist),
tooltip: '',
onPressed: controller.toggleSelectMode,
),
_SortButton(controller: controller),
],
],
),
);
@@ -192,6 +201,104 @@ class _SortButton extends StatelessWidget {
}
}
class _SelectionActions extends StatelessWidget {
final PhotoBoardController controller;
const _SelectionActions({required this.controller});
@override
Widget build(BuildContext context) {
final count = controller.selected.length;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('$count', style: Theme.of(context).textTheme.titleSmall),
IconButton(
icon: const Icon(Icons.drive_file_move_outlined),
tooltip: '',
onPressed: count > 0 ? () => _showMoveDialog(context) : null,
),
IconButton(
icon: const Icon(Icons.delete_outlined),
tooltip: '',
onPressed: count > 0 ? () => _confirmDelete(context) : null,
),
IconButton(
icon: const Icon(Icons.close),
tooltip: '',
onPressed: controller.clearSelection,
),
],
);
}
void _confirmDelete(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text(
m.photoBoard.deleteSelectedConfirm(controller.selected.length),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(m.common.cancel),
),
FilledButton(
onPressed: () {
Navigator.pop(ctx);
controller.deleteSelected();
},
child: Text(m.common.delete),
),
],
),
);
}
void _showMoveDialog(BuildContext context) {
final folders = controller.folders;
showDialog(
context: context,
builder: (ctx) => SimpleDialog(
title: Text(m.photoBoard.folderName),
children: [
// Move to root option
if (controller.currentFolderId != null)
SimpleDialogOption(
onPressed: () {
Navigator.pop(ctx);
controller.moveSelectedToFolder(null);
},
child: const Row(
children: [
Icon(Icons.home_outlined, size: 20),
SizedBox(width: 12),
Text('Root'),
],
),
),
...folders.map(
(f) => SimpleDialogOption(
onPressed: () {
Navigator.pop(ctx);
controller.moveSelectedToFolder(f.id);
},
child: Row(
children: [
const Icon(Icons.folder, size: 20),
const SizedBox(width: 12),
Text(f.name),
],
),
),
),
],
),
);
}
}
class _PhotoGrid extends StatelessWidget {
final PhotoBoardController controller;
@@ -601,11 +708,73 @@ class _PhotoTile extends StatelessWidget {
final uri = PhotoService.instance.photoPreviewUri(houseId, photo.id);
final headers = AuthService.instance.credentials?.basicAuthHeaders ?? {};
if (controller.selectMode) {
final isSelected = controller.selected.contains(photo.id);
return GestureDetector(
onTap: () => controller.toggleSelection(photo.id),
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),
),
),
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,
),
),
),
Positioned(
top: 4,
left: 4,
child: Icon(
isSelected ? Icons.check_circle : Icons.circle_outlined,
color: isSelected ? theme.colorScheme.primary : Colors.white,
size: 24,
shadows: const [Shadow(blurRadius: 4)],
),
),
],
),
),
);
}
return DragTarget<int>(
onWillAcceptWithDetails: (details) => details.data != photo.id,
onAcceptWithDetails: (_) {
// Drop finalized — endDrag is called via onDragEnd
},
onAcceptWithDetails: (_) {},
onMove: (details) {
controller.hoverReorder(photo.id);
},

View File

@@ -2,7 +2,7 @@ name: pantry
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
publish_to: "none" # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
version: 0.1.0+1
environment:
sdk: ^3.11.1
@@ -66,7 +66,6 @@ dev_dependencies:
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.