mirror of
https://github.com/chenasraf/pantry-flutter.git
synced 2026-05-17 17:28:03 +00:00
322 lines
8.5 KiB
Dart
322 lines
8.5 KiB
Dart
import 'dart:io' show Platform;
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
|
|
import 'package:pantry/i18n.dart';
|
|
import 'package:pantry/utils/platform_info.dart';
|
|
import 'package:pantry/views/photos/photo_board_controller.dart';
|
|
|
|
class PhotoAddButton extends StatefulWidget {
|
|
final PhotoBoardController controller;
|
|
|
|
const PhotoAddButton({super.key, required this.controller});
|
|
|
|
@override
|
|
State<PhotoAddButton> createState() => _PhotoAddButtonState();
|
|
}
|
|
|
|
class _PhotoAddButtonState extends State<PhotoAddButton>
|
|
with SingleTickerProviderStateMixin {
|
|
late final AnimationController _animController;
|
|
bool _open = false;
|
|
bool _cameraSupported = !Platform.isMacOS;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_animController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 380),
|
|
);
|
|
if (_cameraSupported) {
|
|
isiOSAppOnMac().then((onMac) {
|
|
if (!mounted || !onMac) return;
|
|
setState(() => _cameraSupported = false);
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_animController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _toggle() {
|
|
setState(() => _open = !_open);
|
|
if (_open) {
|
|
_animController.forward();
|
|
} else {
|
|
_animController.reverse();
|
|
}
|
|
}
|
|
|
|
void _close() {
|
|
if (!_open) return;
|
|
setState(() => _open = false);
|
|
_animController.reverse();
|
|
}
|
|
|
|
Future<void> _pickPhotos() async {
|
|
_close();
|
|
final useFilePicker = Platform.isMacOS || await isiOSAppOnMac();
|
|
if (useFilePicker) {
|
|
final result = await FilePicker.pickFiles(
|
|
allowMultiple: true,
|
|
type: FileType.image,
|
|
);
|
|
if (result == null) return;
|
|
final files = [
|
|
for (final f in result.files)
|
|
if (f.path != null) XFile(f.path!, name: f.name),
|
|
];
|
|
if (files.isNotEmpty) {
|
|
widget.controller.uploadPhotos(files);
|
|
}
|
|
return;
|
|
}
|
|
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();
|
|
final name = await showDialog<String>(
|
|
context: context,
|
|
builder: (dialogCtx) => AlertDialog(
|
|
title: Text(m.photoBoard.newFolder),
|
|
content: TextField(
|
|
controller: textController,
|
|
autofocus: true,
|
|
textCapitalization: TextCapitalization.sentences,
|
|
decoration: InputDecoration(
|
|
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(dialogCtx),
|
|
child: Text(m.common.cancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
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,
|
|
),
|
|
if (_cameraSupported)
|
|
_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),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|