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),
+ ),
+ ),
+ ),
+ ],
+ );
}
}