From 0053f53cd7937445eca53b5d2c070b9b5a3eb82f Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Thu, 9 Apr 2026 18:10:51 +0300 Subject: [PATCH] feat: photos/notes multiselect --- Makefile | 138 ++------------- lib/messages.i18n.dart | 12 ++ lib/messages.i18n.yaml | 2 + lib/views/notes/notes_controller.dart | 45 +++++ lib/views/notes/notes_wall_view.dart | 83 ++++++++- lib/views/photos/photo_board_controller.dart | 64 +++++++ lib/views/photos/photo_board_view.dart | 177 ++++++++++++++++++- pubspec.yaml | 5 +- 8 files changed, 394 insertions(+), 132 deletions(-) diff --git a/Makefile b/Makefile index 6a4c923..fa25ea3 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/lib/messages.i18n.dart b/lib/messages.i18n.dart index c48dcd6..5d6ba16 100644 --- a/lib/messages.i18n.dart +++ b/lib/messages.i18n.dart @@ -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" /// ``` diff --git a/lib/messages.i18n.yaml b/lib/messages.i18n.yaml index 8f4338e..3b6ea84 100644 --- a/lib/messages.i18n.yaml +++ b/lib/messages.i18n.yaml @@ -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 diff --git a/lib/views/notes/notes_controller.dart b/lib/views/notes/notes_controller.dart index 2a6307c..c872a94 100644 --- a/lib/views/notes/notes_controller.dart +++ b/lib/views/notes/notes_controller.dart @@ -23,6 +23,51 @@ class NotesController extends ChangeNotifier { String? _error; String? get error => _error; + // -- Selection -- + + bool _selectMode = false; + bool get selectMode => _selectMode; + + final Set _selected = {}; + Set 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 deleteSelected() async { + final ids = Set.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; diff --git a/lib/views/notes/notes_wall_view.dart b/lib/views/notes/notes_wall_view.dart index f173808..d836758 100644 --- a/lib/views/notes/notes_wall_view.dart +++ b/lib/views/notes/notes_wall_view.dart @@ -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( onWillAcceptWithDetails: (details) => details.data != note.id, onAcceptWithDetails: (_) {}, diff --git a/lib/views/photos/photo_board_controller.dart b/lib/views/photos/photo_board_controller.dart index 262c4e6..b357038 100644 --- a/lib/views/photos/photo_board_controller.dart +++ b/lib/views/photos/photo_board_controller.dart @@ -67,6 +67,70 @@ class PhotoBoardController extends ChangeNotifier { final List _uploads = []; List get uploads => _uploads; + // -- Selection -- + + bool _selectMode = false; + bool get selectMode => _selectMode; + + final Set _selected = {}; + Set 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 deleteSelected() async { + final ids = Set.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 moveSelectedToFolder(int? folderId) async { + final ids = Set.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 get visiblePhotos { if (_currentFolderId != null) { diff --git a/lib/views/photos/photo_board_view.dart b/lib/views/photos/photo_board_view.dart index b1fefbf..4203b07 100644 --- a/lib/views/photos/photo_board_view.dart +++ b/lib/views/photos/photo_board_view.dart @@ -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( onWillAcceptWithDetails: (details) => details.data != photo.id, - onAcceptWithDetails: (_) { - // Drop finalized — endDrag is called via onDragEnd - }, + onAcceptWithDetails: (_) {}, onMove: (details) { controller.hoverReorder(photo.id); }, diff --git a/pubspec.yaml b/pubspec.yaml index e4df84d..e724158 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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.