feat: take photos directly from the photo board

This commit is contained in:
2026-05-14 15:12:36 +03:00
parent 60b16aad30
commit d8802690c0
13 changed files with 394 additions and 36 deletions

View File

@@ -84,6 +84,8 @@
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSCameraUsageDescription</key>
<string>Pantry needs access to the camera so you can take photos and upload them to your photo board.</string>
<key>AppGroupId</key>
<string>group.dev.casraf.pantry</string>
<key>CFBundleURLTypes</key>

View File

@@ -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""",

View File

@@ -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

View File

@@ -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""",

View File

@@ -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

View File

@@ -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""",

View File

@@ -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"

View File

@@ -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""",

View File

@@ -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"

View File

@@ -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<String, String> 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""": """הישן ביותר""",

View File

@@ -209,6 +209,10 @@ photoBoard:
renameFolder: שנה שם תיקייה
caption: כיתוב
photoCount(int count): "$count"
addMenu:
upload: העלאת תמונות
camera: צילום תמונה
newFolder: תיקייה חדשה
sort:
foldersFirst: תיקיות קודם
newestFirst: החדש ביותר

View File

@@ -96,11 +96,7 @@ class _PhotoBoardBody extends StatelessWidget {
),
],
),
PositionedDirectional(
end: 16,
bottom: 16,
child: PhotoAddButton(controller: controller),
),
Positioned.fill(child: PhotoAddButton(controller: controller)),
],
),
);

View File

@@ -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<PhotoAddButton> createState() => _PhotoAddButtonState();
}
class _PhotoAddButtonState extends State<PhotoAddButton>
with SingleTickerProviderStateMixin {
late final AnimationController _animController;
bool _open = false;
@override
void initState() {
super.initState();
_animController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 380),
);
}
Future<void> _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<void> _pickPhotos() async {
_close();
final picker = ImagePicker();
final files = await picker.pickMultiImage();
if (files.isNotEmpty) {
widget.controller.uploadPhotos(files);
}
}
Future<void> _takePhoto() async {
_close();
final picker = ImagePicker();
final file = await picker.pickImage(source: ImageSource.camera);
if (file != null) {
widget.controller.uploadPhotos([file]);
}
}
Future<void> _createFolderDialog() async {
_close();
if (!mounted) return;
final textController = TextEditingController();
showDialog(
final name = await showDialog<String>(
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),
),
),
),
],
);
}
}