Add file uploads for photos and family icons

This commit is contained in:
kihong.kim
2026-01-24 22:31:38 +09:00
parent 29881aa442
commit 9e6a265a7a
15 changed files with 761 additions and 133 deletions

View File

@@ -1,3 +1,7 @@
import 'dart:typed_data';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/bible_verse.dart';
@@ -59,6 +63,36 @@ class FamilyManagerTab extends StatefulWidget {
}
class _FamilyManagerTabState extends State<FamilyManagerTab> {
// Rainbow palette colors
final List<Color> _palette = const [
Color(0xFFFF0000), // Red
Color(0xFFFF7F00), // Orange
Color(0xFFFFFF00), // Yellow
Color(0xFF00FF00), // Green
Color(0xFF0000FF), // Blue
Color(0xFF4B0082), // Indigo
Color(0xFF9400D3), // Violet
];
String _colorToHex(Color color) {
return '#${color.value.toRadixString(16).padLeft(8, '0').substring(2).toUpperCase()}';
}
Color _hexToColor(String hex) {
try {
String cleanHex = hex.replaceAll('#', '').replaceAll('0x', '');
if (cleanHex.length == 6) {
cleanHex = 'FF$cleanHex';
}
if (cleanHex.length == 8) {
return Color(int.parse(cleanHex, radix: 16));
}
return Colors.grey;
} catch (_) {
return Colors.grey;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -76,34 +110,43 @@ class _FamilyManagerTabState extends State<FamilyManagerTab> {
itemCount: members.length,
itemBuilder: (context, index) {
final member = members[index];
Color memberColor;
try {
memberColor = Color(
int.parse(member.color.replaceAll('#', '0xFF')),
);
} catch (_) {
memberColor = Colors.grey;
}
Color memberColor = _hexToColor(member.color);
return ListTile(
leading: CircleAvatar(
backgroundColor: memberColor,
child: Text(
member.emoji,
style: const TextStyle(fontSize: 20),
),
backgroundImage: member.iconUrl.isNotEmpty
? NetworkImage(member.iconUrl)
: null,
child: member.iconUrl.isEmpty
? Text(
member.name.isNotEmpty
? member.name[0].toUpperCase()
: '?',
style: const TextStyle(fontSize: 20),
)
: null,
),
title: Text(member.name),
subtitle: Text('Order: ${member.order}'),
trailing: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () async {
await Provider.of<FamilyService>(
context,
listen: false,
).deleteFamilyMember(member.id);
setState(() {});
},
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit, color: Colors.blue),
onPressed: () => _showEditMemberDialog(context, member),
),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () async {
await Provider.of<FamilyService>(
context,
listen: false,
).deleteFamilyMember(member.id);
setState(() {});
},
),
],
),
);
},
@@ -115,72 +158,366 @@ class _FamilyManagerTabState extends State<FamilyManagerTab> {
void _showAddMemberDialog(BuildContext context) {
final nameController = TextEditingController();
final emojiController = TextEditingController(text: '👤');
final colorController = TextEditingController(text: '0xFFFFD700');
final orderController = TextEditingController(text: '1');
Color selectedColor = _palette[0];
Uint8List? selectedIconBytes;
String? selectedIconName;
bool isUploading = false;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Add Family Member'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Name'),
),
TextField(
controller: emojiController,
decoration: const InputDecoration(labelText: 'Emoji'),
),
TextField(
controller: colorController,
decoration: const InputDecoration(
labelText: 'Color (Hex 0xAARRGGBB)',
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: const Text('Add Family Member'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Name'),
),
),
TextField(
controller: orderController,
decoration: const InputDecoration(labelText: 'Order'),
keyboardType: TextInputType.number,
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
if (nameController.text.isNotEmpty) {
await Provider.of<FamilyService>(
context,
listen: false,
).createFamilyMember(
FamilyMember(
id: '',
name: nameController.text,
emoji: emojiController.text,
color: colorController.text.replaceFirst(
'0xFF',
'#',
), // Simple conversion
order: int.tryParse(orderController.text) ?? 1,
const SizedBox(height: 16),
const Text('Icon', style: TextStyle(color: Colors.grey)),
const SizedBox(height: 8),
Row(
children: [
CircleAvatar(
backgroundColor: selectedColor,
backgroundImage: selectedIconBytes != null
? MemoryImage(selectedIconBytes!)
: null,
child: selectedIconBytes == null
? const Icon(Icons.person, color: Colors.white)
: null,
),
const SizedBox(width: 16),
ElevatedButton.icon(
onPressed: () async {
final result = await FilePicker.platform.pickFiles(
withData: true,
type: FileType.image,
);
if (result != null && result.files.isNotEmpty) {
setDialogState(() {
selectedIconBytes = result.files.first.bytes;
selectedIconName = result.files.first.name;
});
}
},
icon: const Icon(Icons.image),
label: const Text('Pick Image'),
),
],
),
if (selectedIconName != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
selectedIconName!,
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
),
);
if (mounted) {
Navigator.pop(context);
setState(() {});
}
}
},
child: const Text('Add'),
const SizedBox(height: 16),
const Text('Color', style: TextStyle(color: Colors.grey)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _palette.map((color) {
final isSelected = color.value == selectedColor.value;
return GestureDetector(
onTap: () {
setDialogState(() {
selectedColor = color;
});
},
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: isSelected
? Border.all(color: Colors.white, width: 3)
: null,
boxShadow: [
if (isSelected)
const BoxShadow(
color: Colors.black26,
blurRadius: 4,
spreadRadius: 2,
)
],
),
child: isSelected
? const Icon(Icons.check, color: Colors.white)
: null,
),
);
}).toList(),
),
const SizedBox(height: 16),
TextField(
controller: orderController,
decoration: const InputDecoration(labelText: 'Order'),
keyboardType: TextInputType.number,
),
if (isUploading)
const Padding(
padding: EdgeInsets.only(top: 16),
child: Center(child: CircularProgressIndicator()),
),
],
),
),
],
actions: [
TextButton(
onPressed: isUploading ? null : () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: isUploading
? null
: () async {
if (nameController.text.isNotEmpty) {
setDialogState(() => isUploading = true);
String iconUrl = '';
if (selectedIconBytes != null &&
selectedIconName != null) {
try {
iconUrl = await Provider.of<FamilyService>(
context,
listen: false,
).uploadFamilyIcon(
bytes: selectedIconBytes!,
filename: selectedIconName!,
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to upload icon: $e')),
);
setDialogState(() => isUploading = false);
return;
}
}
await Provider.of<FamilyService>(
context,
listen: false,
).createFamilyMember(
FamilyMember(
id: '',
name: nameController.text,
emoji: '', // Legacy field
iconUrl: iconUrl,
color: _colorToHex(selectedColor),
order: int.tryParse(orderController.text) ?? 1,
),
);
if (mounted) {
Navigator.pop(context);
setState(() {});
}
}
},
child: const Text('Add'),
),
],
),
),
);
}
void _showEditMemberDialog(BuildContext context, FamilyMember member) {
final nameController = TextEditingController(text: member.name);
final orderController =
TextEditingController(text: member.order.toString());
Color selectedColor = _hexToColor(member.color);
if (!_palette.any((c) => c.value == selectedColor.value)) {
// If current color is not in palette, try to match closest or add it,
// but for now let's just default to first palette color if invalid,
// or keep it if we want to support custom legacy colors.
// The requirement says "replace hex color input with rainbow palette selection".
// Let's assume we map to the palette or keep custom if it exists.
// But for better UX let's just keep it as is visually, but if they pick a new one it changes.
}
Uint8List? selectedIconBytes;
String? selectedIconName;
bool isUploading = false;
ImageProvider? previewImage;
if (member.iconUrl.isNotEmpty) {
previewImage = NetworkImage(member.iconUrl);
}
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: const Text('Edit Family Member'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Name'),
),
const SizedBox(height: 16),
const Text('Icon', style: TextStyle(color: Colors.grey)),
const SizedBox(height: 8),
Row(
children: [
CircleAvatar(
backgroundColor: selectedColor,
backgroundImage: selectedIconBytes != null
? MemoryImage(selectedIconBytes!)
: previewImage,
child: (selectedIconBytes == null && previewImage == null)
? Text(member.name.isNotEmpty ? member.name[0] : '?')
: null,
),
const SizedBox(width: 16),
ElevatedButton.icon(
onPressed: () async {
final result = await FilePicker.platform.pickFiles(
withData: true,
type: FileType.image,
);
if (result != null && result.files.isNotEmpty) {
setDialogState(() {
selectedIconBytes = result.files.first.bytes;
selectedIconName = result.files.first.name;
previewImage = selectedIconBytes == null
? previewImage
: MemoryImage(selectedIconBytes!);
});
}
},
icon: const Icon(Icons.image),
label: const Text('Change Icon'),
),
],
),
const SizedBox(height: 16),
const Text('Color', style: TextStyle(color: Colors.grey)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _palette.map((color) {
final isSelected = color.value == selectedColor.value;
return GestureDetector(
onTap: () {
setDialogState(() {
selectedColor = color;
});
},
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: isSelected
? Border.all(color: Colors.white, width: 3)
: null,
boxShadow: [
if (isSelected)
const BoxShadow(
color: Colors.black26,
blurRadius: 4,
spreadRadius: 2,
)
],
),
child: isSelected
? const Icon(Icons.check, color: Colors.white)
: null,
),
);
}).toList(),
),
const SizedBox(height: 16),
TextField(
controller: orderController,
decoration: const InputDecoration(labelText: 'Order'),
keyboardType: TextInputType.number,
),
if (isUploading)
const Padding(
padding: EdgeInsets.only(top: 16),
child: Center(child: CircularProgressIndicator()),
),
],
),
),
actions: [
TextButton(
onPressed: isUploading ? null : () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: isUploading
? null
: () async {
if (nameController.text.isNotEmpty) {
setDialogState(() => isUploading = true);
String iconUrl = member.iconUrl;
if (selectedIconBytes != null &&
selectedIconName != null) {
try {
iconUrl = await Provider.of<FamilyService>(
context,
listen: false,
).uploadFamilyIcon(
bytes: selectedIconBytes!,
filename: selectedIconName!,
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to upload icon: $e')),
);
setDialogState(() => isUploading = false);
return;
}
}
await Provider.of<FamilyService>(
context,
listen: false,
).updateFamilyMember(
FamilyMember(
id: member.id,
name: nameController.text,
emoji: member
.emoji, // Keep existing emoji or clear it? Let's keep it to be safe
iconUrl: iconUrl,
color: _colorToHex(selectedColor),
order: int.tryParse(orderController.text) ?? 1,
),
);
if (mounted) {
Navigator.pop(context);
setState(() {});
}
}
},
child: const Text('Save'),
),
],
),
),
);
}
@@ -249,52 +586,122 @@ class _PhotoManagerTabState extends State<PhotoManagerTab> {
void _showAddPhotoDialog(BuildContext context) {
final urlController = TextEditingController();
final captionController = TextEditingController();
Uint8List? selectedFileBytes;
String? selectedFileName;
bool isUploading = false;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Add Photo URL'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: urlController,
decoration: const InputDecoration(labelText: 'Image URL'),
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: const Text('Add Photo'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton.icon(
onPressed: () async {
final result = await FilePicker.platform.pickFiles(
withData: true,
type: FileType.image,
);
if (result != null && result.files.isNotEmpty) {
setDialogState(() {
selectedFileBytes = result.files.first.bytes;
selectedFileName = result.files.first.name;
// Clear URL if file is selected to avoid confusion
urlController.clear();
});
}
},
icon: const Icon(Icons.upload_file),
label: const Text('Pick Local Image'),
),
if (selectedFileName != null)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'Selected: $selectedFileName',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Text('- OR -', style: TextStyle(color: Colors.grey)),
),
TextField(
controller: urlController,
decoration: const InputDecoration(labelText: 'Image URL'),
enabled: selectedFileBytes == null,
),
TextField(
controller: captionController,
decoration: const InputDecoration(labelText: 'Caption'),
),
if (isUploading)
const Padding(
padding: EdgeInsets.only(top: 16),
child: CircularProgressIndicator(),
),
],
),
actions: [
TextButton(
onPressed: isUploading ? null : () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextField(
controller: captionController,
decoration: const InputDecoration(labelText: 'Caption'),
TextButton(
onPressed: isUploading
? null
: () async {
if (selectedFileBytes != null ||
urlController.text.isNotEmpty) {
setDialogState(() {
isUploading = true;
});
try {
final photoService =
Provider.of<PhotoService>(context, listen: false);
if (selectedFileBytes != null) {
await photoService.uploadPhotoBytes(
bytes: selectedFileBytes!,
filename: selectedFileName!,
caption: captionController.text,
);
} else {
await photoService.createPhoto(
Photo(
id: '',
url: urlController.text,
caption: captionController.text,
active: true,
),
);
}
if (mounted) {
Navigator.pop(context);
setState(() {});
}
} catch (e) {
setDialogState(() {
isUploading = false;
});
// Optionally show error
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
}
}
},
child: const Text('Add'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
if (urlController.text.isNotEmpty) {
await Provider.of<PhotoService>(
context,
listen: false,
).createPhoto(
Photo(
id: '',
url: urlController.text,
caption: captionController.text,
active: true,
),
);
if (mounted) {
Navigator.pop(context);
setState(() {});
}
}
},
child: const Text('Add'),
),
],
),
);
}