From d8802690c0a3ab80346f84ea1df4b3774ba6e4ee Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Thu, 14 May 2026 15:12:36 +0300 Subject: [PATCH] feat: take photos directly from the photo board --- ios/Runner/Info.plist | 2 + lib/messages.i18n.dart | 24 +++ lib/messages.i18n.yaml | 4 + lib/messages_de.i18n.dart | 24 +++ lib/messages_de.i18n.yaml | 4 + lib/messages_es.i18n.dart | 24 +++ lib/messages_es.i18n.yaml | 4 + lib/messages_fr.i18n.dart | 24 +++ lib/messages_fr.i18n.yaml | 4 + lib/messages_he.i18n.dart | 24 +++ lib/messages_he.i18n.yaml | 4 + lib/views/photos/photo_board_view.dart | 6 +- lib/widgets/photo_add_button.dart | 282 ++++++++++++++++++++++--- 13 files changed, 394 insertions(+), 36 deletions(-) diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 6797d72..64ad795 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -84,6 +84,8 @@ ITSAppUsesNonExemptEncryption + NSCameraUsageDescription + Pantry needs access to the camera so you can take photos and upload them to your photo board. AppGroupId group.dev.casraf.pantry CFBundleURLTypes diff --git a/lib/messages.i18n.dart b/lib/messages.i18n.dart index 30874be..4bb3381 100644 --- a/lib/messages.i18n.dart +++ b/lib/messages.i18n.dart @@ -1103,9 +1103,30 @@ class PhotoBoardMessages { /// "$count" /// ``` String photoCount(int count) => """$count"""; + AddMenuPhotoBoardMessages get addMenu => AddMenuPhotoBoardMessages(this); SortPhotoBoardMessages get sort => SortPhotoBoardMessages(this); } +class AddMenuPhotoBoardMessages { + final PhotoBoardMessages _parent; + const AddMenuPhotoBoardMessages(this._parent); + + /// ```dart + /// "Upload photos" + /// ``` + String get upload => """Upload photos"""; + + /// ```dart + /// "Take photo" + /// ``` + String get camera => """Take photo"""; + + /// ```dart + /// "New folder" + /// ``` + String get newFolder => """New folder"""; +} + class SortPhotoBoardMessages { final PhotoBoardMessages _parent; const SortPhotoBoardMessages(this._parent); @@ -1625,6 +1646,9 @@ Please complete login in your browser.""", """photoBoard.folderName""": """Folder name""", """photoBoard.renameFolder""": """Rename folder""", """photoBoard.caption""": """Caption""", + """photoBoard.addMenu.upload""": """Upload photos""", + """photoBoard.addMenu.camera""": """Take photo""", + """photoBoard.addMenu.newFolder""": """New folder""", """photoBoard.sort.foldersFirst""": """Folders first""", """photoBoard.sort.newestFirst""": """Newest first""", """photoBoard.sort.oldestFirst""": """Oldest first""", diff --git a/lib/messages.i18n.yaml b/lib/messages.i18n.yaml index 354222e..503e5bf 100644 --- a/lib/messages.i18n.yaml +++ b/lib/messages.i18n.yaml @@ -209,6 +209,10 @@ photoBoard: renameFolder: Rename folder caption: Caption photoCount(int count): "$count" + addMenu: + upload: Upload photos + camera: Take photo + newFolder: New folder sort: foldersFirst: Folders first newestFirst: Newest first diff --git a/lib/messages_de.i18n.dart b/lib/messages_de.i18n.dart index 57bd40d..c7a4a87 100644 --- a/lib/messages_de.i18n.dart +++ b/lib/messages_de.i18n.dart @@ -1114,9 +1114,30 @@ class PhotoBoardMessagesDe extends PhotoBoardMessages { /// "$count" /// ``` String photoCount(int count) => """$count"""; + AddMenuPhotoBoardMessagesDe get addMenu => AddMenuPhotoBoardMessagesDe(this); SortPhotoBoardMessagesDe get sort => SortPhotoBoardMessagesDe(this); } +class AddMenuPhotoBoardMessagesDe extends AddMenuPhotoBoardMessages { + final PhotoBoardMessagesDe _parent; + const AddMenuPhotoBoardMessagesDe(this._parent) : super(_parent); + + /// ```dart + /// "Fotos hochladen" + /// ``` + String get upload => """Fotos hochladen"""; + + /// ```dart + /// "Foto aufnehmen" + /// ``` + String get camera => """Foto aufnehmen"""; + + /// ```dart + /// "Neuer Ordner" + /// ``` + String get newFolder => """Neuer Ordner"""; +} + class SortPhotoBoardMessagesDe extends SortPhotoBoardMessages { final PhotoBoardMessagesDe _parent; const SortPhotoBoardMessagesDe(this._parent) : super(_parent); @@ -1649,6 +1670,9 @@ Bitte melde dich in deinem Browser an.""", """photoBoard.folderName""": """Ordnername""", """photoBoard.renameFolder""": """Ordner umbenennen""", """photoBoard.caption""": """Beschriftung""", + """photoBoard.addMenu.upload""": """Fotos hochladen""", + """photoBoard.addMenu.camera""": """Foto aufnehmen""", + """photoBoard.addMenu.newFolder""": """Neuer Ordner""", """photoBoard.sort.foldersFirst""": """Ordner zuerst""", """photoBoard.sort.newestFirst""": """Neueste zuerst""", """photoBoard.sort.oldestFirst""": """Älteste zuerst""", diff --git a/lib/messages_de.i18n.yaml b/lib/messages_de.i18n.yaml index dc7999a..efc9301 100644 --- a/lib/messages_de.i18n.yaml +++ b/lib/messages_de.i18n.yaml @@ -209,6 +209,10 @@ photoBoard: renameFolder: Ordner umbenennen caption: Beschriftung photoCount(int count): "$count" + addMenu: + upload: Fotos hochladen + camera: Foto aufnehmen + newFolder: Neuer Ordner sort: foldersFirst: Ordner zuerst newestFirst: Neueste zuerst diff --git a/lib/messages_es.i18n.dart b/lib/messages_es.i18n.dart index 127f668..d172fb1 100644 --- a/lib/messages_es.i18n.dart +++ b/lib/messages_es.i18n.dart @@ -1110,9 +1110,30 @@ class PhotoBoardMessagesEs extends PhotoBoardMessages { /// "$count" /// ``` String photoCount(int count) => """$count"""; + AddMenuPhotoBoardMessagesEs get addMenu => AddMenuPhotoBoardMessagesEs(this); SortPhotoBoardMessagesEs get sort => SortPhotoBoardMessagesEs(this); } +class AddMenuPhotoBoardMessagesEs extends AddMenuPhotoBoardMessages { + final PhotoBoardMessagesEs _parent; + const AddMenuPhotoBoardMessagesEs(this._parent) : super(_parent); + + /// ```dart + /// "Subir fotos" + /// ``` + String get upload => """Subir fotos"""; + + /// ```dart + /// "Tomar foto" + /// ``` + String get camera => """Tomar foto"""; + + /// ```dart + /// "Nueva carpeta" + /// ``` + String get newFolder => """Nueva carpeta"""; +} + class SortPhotoBoardMessagesEs extends SortPhotoBoardMessages { final PhotoBoardMessagesEs _parent; const SortPhotoBoardMessagesEs(this._parent) : super(_parent); @@ -1639,6 +1660,9 @@ Por favor, completa el inicio de sesión en tu navegador.""", """photoBoard.folderName""": """Nombre de la carpeta""", """photoBoard.renameFolder""": """Renombrar carpeta""", """photoBoard.caption""": """Descripción""", + """photoBoard.addMenu.upload""": """Subir fotos""", + """photoBoard.addMenu.camera""": """Tomar foto""", + """photoBoard.addMenu.newFolder""": """Nueva carpeta""", """photoBoard.sort.foldersFirst""": """Carpetas primero""", """photoBoard.sort.newestFirst""": """Más recientes""", """photoBoard.sort.oldestFirst""": """Más antiguos""", diff --git a/lib/messages_es.i18n.yaml b/lib/messages_es.i18n.yaml index 96325e2..8ba1de9 100644 --- a/lib/messages_es.i18n.yaml +++ b/lib/messages_es.i18n.yaml @@ -209,6 +209,10 @@ photoBoard: renameFolder: Renombrar carpeta caption: "Descripción" photoCount(int count): "$count" + addMenu: + upload: Subir fotos + camera: Tomar foto + newFolder: Nueva carpeta sort: foldersFirst: Carpetas primero newestFirst: "Más recientes" diff --git a/lib/messages_fr.i18n.dart b/lib/messages_fr.i18n.dart index 79df072..a5bb62c 100644 --- a/lib/messages_fr.i18n.dart +++ b/lib/messages_fr.i18n.dart @@ -1113,9 +1113,30 @@ class PhotoBoardMessagesFr extends PhotoBoardMessages { /// "$count" /// ``` String photoCount(int count) => """$count"""; + AddMenuPhotoBoardMessagesFr get addMenu => AddMenuPhotoBoardMessagesFr(this); SortPhotoBoardMessagesFr get sort => SortPhotoBoardMessagesFr(this); } +class AddMenuPhotoBoardMessagesFr extends AddMenuPhotoBoardMessages { + final PhotoBoardMessagesFr _parent; + const AddMenuPhotoBoardMessagesFr(this._parent) : super(_parent); + + /// ```dart + /// "Téléverser des photos" + /// ``` + String get upload => """Téléverser des photos"""; + + /// ```dart + /// "Prendre une photo" + /// ``` + String get camera => """Prendre une photo"""; + + /// ```dart + /// "Nouveau dossier" + /// ``` + String get newFolder => """Nouveau dossier"""; +} + class SortPhotoBoardMessagesFr extends SortPhotoBoardMessages { final PhotoBoardMessagesFr _parent; const SortPhotoBoardMessagesFr(this._parent) : super(_parent); @@ -1647,6 +1668,9 @@ Veuillez terminer la connexion dans votre navigateur.""", """photoBoard.folderName""": """Nom du dossier""", """photoBoard.renameFolder""": """Renommer le dossier""", """photoBoard.caption""": """Légende""", + """photoBoard.addMenu.upload""": """Téléverser des photos""", + """photoBoard.addMenu.camera""": """Prendre une photo""", + """photoBoard.addMenu.newFolder""": """Nouveau dossier""", """photoBoard.sort.foldersFirst""": """Dossiers en premier""", """photoBoard.sort.newestFirst""": """Plus récents""", """photoBoard.sort.oldestFirst""": """Plus anciens""", diff --git a/lib/messages_fr.i18n.yaml b/lib/messages_fr.i18n.yaml index e602c62..63cba48 100644 --- a/lib/messages_fr.i18n.yaml +++ b/lib/messages_fr.i18n.yaml @@ -209,6 +209,10 @@ photoBoard: renameFolder: Renommer le dossier caption: "Légende" photoCount(int count): "$count" + addMenu: + upload: Téléverser des photos + camera: Prendre une photo + newFolder: Nouveau dossier sort: foldersFirst: Dossiers en premier newestFirst: "Plus récents" diff --git a/lib/messages_he.i18n.dart b/lib/messages_he.i18n.dart index dedf5a8..5e62b35 100644 --- a/lib/messages_he.i18n.dart +++ b/lib/messages_he.i18n.dart @@ -1105,9 +1105,30 @@ class PhotoBoardMessagesHe extends PhotoBoardMessages { /// "$count" /// ``` String photoCount(int count) => """$count"""; + AddMenuPhotoBoardMessagesHe get addMenu => AddMenuPhotoBoardMessagesHe(this); SortPhotoBoardMessagesHe get sort => SortPhotoBoardMessagesHe(this); } +class AddMenuPhotoBoardMessagesHe extends AddMenuPhotoBoardMessages { + final PhotoBoardMessagesHe _parent; + const AddMenuPhotoBoardMessagesHe(this._parent) : super(_parent); + + /// ```dart + /// "העלאת תמונות" + /// ``` + String get upload => """העלאת תמונות"""; + + /// ```dart + /// "צילום תמונה" + /// ``` + String get camera => """צילום תמונה"""; + + /// ```dart + /// "תיקייה חדשה" + /// ``` + String get newFolder => """תיקייה חדשה"""; +} + class SortPhotoBoardMessagesHe extends SortPhotoBoardMessages { final PhotoBoardMessagesHe _parent; const SortPhotoBoardMessagesHe(this._parent) : super(_parent); @@ -1622,6 +1643,9 @@ Map get messagesHeMap => { """photoBoard.folderName""": """שם התיקייה""", """photoBoard.renameFolder""": """שנה שם תיקייה""", """photoBoard.caption""": """כיתוב""", + """photoBoard.addMenu.upload""": """העלאת תמונות""", + """photoBoard.addMenu.camera""": """צילום תמונה""", + """photoBoard.addMenu.newFolder""": """תיקייה חדשה""", """photoBoard.sort.foldersFirst""": """תיקיות קודם""", """photoBoard.sort.newestFirst""": """החדש ביותר""", """photoBoard.sort.oldestFirst""": """הישן ביותר""", diff --git a/lib/messages_he.i18n.yaml b/lib/messages_he.i18n.yaml index f704a0d..4344442 100644 --- a/lib/messages_he.i18n.yaml +++ b/lib/messages_he.i18n.yaml @@ -209,6 +209,10 @@ photoBoard: renameFolder: שנה שם תיקייה caption: כיתוב photoCount(int count): "$count" + addMenu: + upload: העלאת תמונות + camera: צילום תמונה + newFolder: תיקייה חדשה sort: foldersFirst: תיקיות קודם newestFirst: החדש ביותר diff --git a/lib/views/photos/photo_board_view.dart b/lib/views/photos/photo_board_view.dart index 5057ab9..74e86f5 100644 --- a/lib/views/photos/photo_board_view.dart +++ b/lib/views/photos/photo_board_view.dart @@ -96,11 +96,7 @@ class _PhotoBoardBody extends StatelessWidget { ), ], ), - PositionedDirectional( - end: 16, - bottom: 16, - child: PhotoAddButton(controller: controller), - ), + Positioned.fill(child: PhotoAddButton(controller: controller)), ], ), ); diff --git a/lib/widgets/photo_add_button.dart b/lib/widgets/photo_add_button.dart index 206f210..693139d 100644 --- a/lib/widgets/photo_add_button.dart +++ b/lib/widgets/photo_add_button.dart @@ -1,47 +1,80 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:pantry/i18n.dart'; import 'package:pantry/views/photos/photo_board_controller.dart'; -class PhotoAddButton extends StatelessWidget { +class PhotoAddButton extends StatefulWidget { final PhotoBoardController controller; const PhotoAddButton({super.key, 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), - ), - ], + State createState() => _PhotoAddButtonState(); +} + +class _PhotoAddButtonState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _animController; + bool _open = false; + + @override + void initState() { + super.initState(); + _animController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 380), ); } - Future _pickPhotos(BuildContext context) async { - final picker = ImagePicker(); - final files = await picker.pickMultiImage(); - if (files.isNotEmpty) { - controller.uploadPhotos(files); + @override + void dispose() { + _animController.dispose(); + super.dispose(); + } + + void _toggle() { + setState(() => _open = !_open); + if (_open) { + _animController.forward(); + } else { + _animController.reverse(); } } - void _createFolder(BuildContext context) { + void _close() { + if (!_open) return; + setState(() => _open = false); + _animController.reverse(); + } + + Future _pickPhotos() async { + _close(); + final picker = ImagePicker(); + final files = await picker.pickMultiImage(); + if (files.isNotEmpty) { + widget.controller.uploadPhotos(files); + } + } + + Future _takePhoto() async { + _close(); + final picker = ImagePicker(); + final file = await picker.pickImage(source: ImageSource.camera); + if (file != null) { + widget.controller.uploadPhotos([file]); + } + } + + Future _createFolderDialog() async { + _close(); + if (!mounted) return; final textController = TextEditingController(); - showDialog( + final name = await showDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (dialogCtx) => AlertDialog( title: Text(m.photoBoard.newFolder), content: TextField( controller: textController, @@ -51,24 +84,211 @@ class PhotoAddButton extends StatelessWidget { labelText: m.photoBoard.folderName, border: const OutlineInputBorder(), ), + onSubmitted: (value) { + final v = value.trim(); + if (v.isNotEmpty) Navigator.pop(dialogCtx, v); + }, ), actions: [ TextButton( - onPressed: () => Navigator.pop(ctx), + onPressed: () => Navigator.pop(dialogCtx), child: Text(m.common.cancel), ), FilledButton( onPressed: () { - final name = textController.text.trim(); - if (name.isNotEmpty) { - controller.createFolder(name); - Navigator.pop(ctx); - } + final v = textController.text.trim(); + if (v.isNotEmpty) Navigator.pop(dialogCtx, v); }, child: Text(m.common.save), ), ], ), ); + if (name != null && name.isNotEmpty) { + widget.controller.createFolder(name); + } + } + + @override + Widget build(BuildContext context) { + final actions = <_FabAction>[ + _FabAction( + icon: Icons.add_photo_alternate, + label: m.photoBoard.addMenu.upload, + onTap: _pickPhotos, + ), + _FabAction( + icon: Icons.camera_alt, + label: m.photoBoard.addMenu.camera, + onTap: _takePhoto, + ), + _FabAction( + icon: Icons.create_new_folder, + label: m.photoBoard.addMenu.newFolder, + onTap: _createFolderDialog, + ), + ]; + + return PopScope( + canPop: !_open, + onPopInvokedWithResult: (didPop, _) { + if (!didPop && _open) _close(); + }, + child: Stack( + children: [ + Positioned.fill( + child: IgnorePointer( + ignoring: !_open, + child: AnimatedOpacity( + opacity: _open ? 1 : 0, + duration: const Duration(milliseconds: 180), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _close, + child: const ColoredBox(color: Color(0x66000000)), + ), + ), + ), + ), + PositionedDirectional( + end: 16, + bottom: 16, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (var i = 0; i < actions.length; i++) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _AnimatedAction( + action: actions[i], + controller: _animController, + index: actions.length - 1 - i, + total: actions.length, + ), + ), + _buildMainFab(), + ], + ), + ), + ], + ), + ); + } + + Widget _buildMainFab() { + return FloatingActionButton( + heroTag: 'photo_add_menu_main', + onPressed: _toggle, + child: AnimatedBuilder( + animation: _animController, + builder: (ctx, _) { + return Transform.rotate( + angle: _animController.value * (math.pi * 3 / 4), + child: const Icon(Icons.add), + ); + }, + ), + ); + } +} + +class _FabAction { + final IconData icon; + final String label; + final VoidCallback onTap; + + const _FabAction({ + required this.icon, + required this.label, + required this.onTap, + }); +} + +class _AnimatedAction extends StatelessWidget { + final _FabAction action; + final AnimationController controller; + final int index; + final int total; + + const _AnimatedAction({ + required this.action, + required this.controller, + required this.index, + required this.total, + }); + + @override + Widget build(BuildContext context) { + final start = (index / total) * 0.4; + final end = math.min(1.0, start + 0.7); + final anim = CurvedAnimation( + parent: controller, + curve: Interval(start, end, curve: Curves.easeOutCubic), + reverseCurve: Interval(start, end, curve: Curves.easeInCubic), + ); + return AnimatedBuilder( + animation: anim, + builder: (ctx, child) { + final v = anim.value.clamp(0.0, 1.0); + if (v == 0) return const SizedBox.shrink(); + return Opacity( + opacity: v, + child: Transform.translate( + offset: Offset(0, (1 - v) * 16), + child: child, + ), + ); + }, + child: _ActionRow(action: action), + ); + } +} + +class _ActionRow extends StatelessWidget { + final _FabAction action; + const _ActionRow({required this.action}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Material( + color: scheme.surfaceContainerHighest, + elevation: 2, + shape: const StadiumBorder(), + child: InkWell( + customBorder: const StadiumBorder(), + onTap: action.onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + child: Text( + action.label, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ), + ), + const SizedBox(width: 12), + Material( + color: scheme.secondaryContainer, + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: action.onTap, + child: SizedBox( + width: 48, + height: 48, + child: Icon(action.icon, color: scheme.onSecondaryContainer), + ), + ), + ), + ], + ); } }