Add file uploads for photos and family icons
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@ backend/npm-debug.log
|
||||
backend/.env
|
||||
backend/.env.*
|
||||
backend/.npmrc
|
||||
backend/uploads
|
||||
|
||||
# Flutter
|
||||
flutter_app/.dart_tool
|
||||
|
||||
@@ -3,7 +3,8 @@ const mongoose = require("mongoose");
|
||||
const familyMemberSchema = new mongoose.Schema(
|
||||
{
|
||||
name: { type: String, required: true },
|
||||
emoji: { type: String, required: true },
|
||||
iconUrl: { type: String, default: "" },
|
||||
emoji: { type: String, default: "" },
|
||||
color: { type: String, required: true },
|
||||
order: { type: Number, default: 0 },
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.19.2",
|
||||
"mongoose": "^8.9.0"
|
||||
"mongoose": "^8.9.0",
|
||||
"multer": "^1.4.5-lts.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
const express = require("express");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const multer = require("multer");
|
||||
const FamilyMember = require("../models/FamilyMember");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const uploadsDir = path.join(__dirname, "..", "uploads", "family");
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, uploadsDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const timestamp = Date.now();
|
||||
const safeName = file.originalname.replace(/\s+/g, "-");
|
||||
cb(null, `${timestamp}-${safeName}`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({ storage });
|
||||
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
const members = await FamilyMember.find().sort({ order: 1, createdAt: 1 });
|
||||
@@ -21,6 +40,19 @@ router.post("/", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/upload-icon", upload.single("file"), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: "File is required" });
|
||||
}
|
||||
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
||||
const url = `${baseUrl}/uploads/family/${req.file.filename}`;
|
||||
res.status(201).json({ url });
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: "Failed to upload icon" });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/:id", async (req, res) => {
|
||||
try {
|
||||
const member = await FamilyMember.findById(req.params.id);
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
const express = require("express");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const multer = require("multer");
|
||||
const Photo = require("../models/Photo");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const uploadsDir = path.join(__dirname, "..", "uploads");
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, uploadsDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const timestamp = Date.now();
|
||||
const safeName = file.originalname.replace(/\s+/g, "-");
|
||||
cb(null, `${timestamp}-${safeName}`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({ storage });
|
||||
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
const filter = {};
|
||||
@@ -25,6 +44,24 @@ router.post("/", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/upload", upload.single("file"), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: "File is required" });
|
||||
}
|
||||
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
||||
const url = `${baseUrl}/uploads/${req.file.filename}`;
|
||||
const photo = await Photo.create({
|
||||
url,
|
||||
caption: req.body.caption || "",
|
||||
active: req.body.active !== "false",
|
||||
});
|
||||
res.status(201).json(photo);
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: "Failed to upload photo" });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/:id", async (req, res) => {
|
||||
try {
|
||||
const photo = await Photo.findByIdAndDelete(req.params.id);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require("express");
|
||||
const cors = require("cors");
|
||||
const dotenv = require("dotenv");
|
||||
const path = require("path");
|
||||
|
||||
const connectDb = require("./config/db");
|
||||
const familyRoutes = require("./routes/family");
|
||||
@@ -18,6 +19,7 @@ const port = process.env.PORT || 4000;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: "2mb" }));
|
||||
app.use("/uploads", express.static(path.join(__dirname, "uploads")));
|
||||
|
||||
app.get("/health", (req, res) => {
|
||||
res.json({ ok: true });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class FamilyMember {
|
||||
final String id;
|
||||
final String name;
|
||||
final String iconUrl;
|
||||
final String emoji;
|
||||
final String color;
|
||||
final int order;
|
||||
@@ -8,6 +9,7 @@ class FamilyMember {
|
||||
const FamilyMember({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.iconUrl,
|
||||
required this.emoji,
|
||||
required this.color,
|
||||
required this.order,
|
||||
@@ -17,6 +19,7 @@ class FamilyMember {
|
||||
return FamilyMember(
|
||||
id: json["_id"] as String? ?? "",
|
||||
name: json["name"] as String? ?? "",
|
||||
iconUrl: json["iconUrl"] as String? ?? "",
|
||||
emoji: json["emoji"] as String? ?? "",
|
||||
color: json["color"] as String? ?? "",
|
||||
order: (json["order"] as num?)?.toInt() ?? 0,
|
||||
@@ -24,6 +27,11 @@ class FamilyMember {
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {"name": name, "emoji": emoji, "color": color, "order": order};
|
||||
return {
|
||||
"name": name,
|
||||
"iconUrl": iconUrl,
|
||||
"color": color,
|
||||
"order": order,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "dart:convert";
|
||||
import "dart:typed_data";
|
||||
import "package:http/http.dart" as http;
|
||||
import "../config/api_config.dart";
|
||||
|
||||
@@ -45,6 +46,30 @@ class ApiClient {
|
||||
return jsonDecode(response.body) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> postMultipart(
|
||||
String path, {
|
||||
required String fieldName,
|
||||
required Uint8List bytes,
|
||||
required String filename,
|
||||
Map<String, String>? fields,
|
||||
}) async {
|
||||
final request = http.MultipartRequest("POST", _uri(path));
|
||||
if (fields != null) {
|
||||
request.fields.addAll(fields);
|
||||
}
|
||||
request.files.add(
|
||||
http.MultipartFile.fromBytes(
|
||||
fieldName,
|
||||
bytes,
|
||||
filename: filename,
|
||||
),
|
||||
);
|
||||
final streamed = await _client.send(request);
|
||||
final response = await http.Response.fromStream(streamed);
|
||||
_ensureSuccess(response);
|
||||
return jsonDecode(response.body) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> put(
|
||||
String path,
|
||||
Map<String, dynamic> body,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import "dart:typed_data";
|
||||
|
||||
import "../config/api_config.dart";
|
||||
import "../models/family_member.dart";
|
||||
import "api_client.dart";
|
||||
@@ -23,6 +25,7 @@ class FamilyService {
|
||||
final created = FamilyMember(
|
||||
id: "family-${DateTime.now().millisecondsSinceEpoch}",
|
||||
name: member.name,
|
||||
iconUrl: member.iconUrl,
|
||||
emoji: member.emoji,
|
||||
color: member.color,
|
||||
order: member.order,
|
||||
@@ -51,6 +54,22 @@ class FamilyService {
|
||||
return FamilyMember.fromJson(data);
|
||||
}
|
||||
|
||||
Future<String> uploadFamilyIcon({
|
||||
required Uint8List bytes,
|
||||
required String filename,
|
||||
}) async {
|
||||
if (ApiConfig.useMockData) {
|
||||
return "mock://$filename";
|
||||
}
|
||||
final data = await _client.postMultipart(
|
||||
"${ApiConfig.family}/upload-icon",
|
||||
fieldName: "file",
|
||||
bytes: bytes,
|
||||
filename: filename,
|
||||
);
|
||||
return data["url"] as String? ?? "";
|
||||
}
|
||||
|
||||
Future<void> deleteFamilyMember(String id) async {
|
||||
if (ApiConfig.useMockData) {
|
||||
MockDataStore.familyMembers.removeWhere((item) => item.id == id);
|
||||
|
||||
@@ -11,28 +11,32 @@ class MockDataStore {
|
||||
const FamilyMember(
|
||||
id: "family-1",
|
||||
name: "Dad",
|
||||
emoji: ":)",
|
||||
iconUrl: "",
|
||||
emoji: "",
|
||||
color: "#0F766E",
|
||||
order: 1,
|
||||
),
|
||||
const FamilyMember(
|
||||
id: "family-2",
|
||||
name: "Mom",
|
||||
emoji: "<3",
|
||||
iconUrl: "",
|
||||
emoji: "",
|
||||
color: "#C2410C",
|
||||
order: 2,
|
||||
),
|
||||
const FamilyMember(
|
||||
id: "family-3",
|
||||
name: "Son",
|
||||
emoji: ":D",
|
||||
iconUrl: "",
|
||||
emoji: "",
|
||||
color: "#1D4ED8",
|
||||
order: 3,
|
||||
),
|
||||
const FamilyMember(
|
||||
id: "family-4",
|
||||
name: "Daughter",
|
||||
emoji: ":-)",
|
||||
iconUrl: "",
|
||||
emoji: "",
|
||||
color: "#7C3AED",
|
||||
order: 4,
|
||||
),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import "dart:typed_data";
|
||||
|
||||
import "../config/api_config.dart";
|
||||
import "../models/photo.dart";
|
||||
import "api_client.dart";
|
||||
@@ -38,6 +40,35 @@ class PhotoService {
|
||||
return Photo.fromJson(data);
|
||||
}
|
||||
|
||||
Future<Photo> uploadPhotoBytes({
|
||||
required Uint8List bytes,
|
||||
required String filename,
|
||||
String caption = "",
|
||||
bool active = true,
|
||||
}) async {
|
||||
if (ApiConfig.useMockData) {
|
||||
final created = Photo(
|
||||
id: "photo-${DateTime.now().millisecondsSinceEpoch}",
|
||||
url: "mock://$filename",
|
||||
caption: caption,
|
||||
active: active,
|
||||
);
|
||||
MockDataStore.photos.add(created);
|
||||
return created;
|
||||
}
|
||||
final data = await _client.postMultipart(
|
||||
"${ApiConfig.photos}/upload",
|
||||
fieldName: "file",
|
||||
bytes: bytes,
|
||||
filename: filename,
|
||||
fields: {
|
||||
"caption": caption,
|
||||
"active": active.toString(),
|
||||
},
|
||||
);
|
||||
return Photo.fromJson(data);
|
||||
}
|
||||
|
||||
Future<void> deletePhoto(String id) async {
|
||||
if (ApiConfig.useMockData) {
|
||||
MockDataStore.photos.removeWhere((item) => item.id == id);
|
||||
|
||||
@@ -101,6 +101,7 @@ class TodoListWidget extends StatelessWidget {
|
||||
orElse: () => const FamilyMember(
|
||||
id: '',
|
||||
name: 'Unknown',
|
||||
iconUrl: '',
|
||||
emoji: '👤',
|
||||
color: '#888888',
|
||||
order: 0,
|
||||
@@ -110,9 +111,16 @@ class TodoListWidget extends StatelessWidget {
|
||||
// Parse color
|
||||
Color memberColor;
|
||||
try {
|
||||
memberColor = Color(
|
||||
int.parse(member.color.replaceAll('#', '0xFF')),
|
||||
);
|
||||
String cleanHex =
|
||||
member.color.replaceAll('#', '').replaceAll('0x', '');
|
||||
if (cleanHex.length == 6) {
|
||||
cleanHex = 'FF$cleanHex';
|
||||
}
|
||||
if (cleanHex.length == 8) {
|
||||
memberColor = Color(int.parse('0x$cleanHex'));
|
||||
} else {
|
||||
memberColor = Colors.grey;
|
||||
}
|
||||
} catch (_) {
|
||||
memberColor = Colors.grey;
|
||||
}
|
||||
@@ -121,10 +129,24 @@ class TodoListWidget extends StatelessWidget {
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: memberColor.withOpacity(0.2),
|
||||
child: Text(
|
||||
member.emoji,
|
||||
style: const TextStyle(fontSize: 20),
|
||||
),
|
||||
backgroundImage: member.iconUrl.isNotEmpty
|
||||
? NetworkImage(member.iconUrl)
|
||||
: null,
|
||||
child: member.iconUrl.isEmpty
|
||||
? (member.name.isNotEmpty
|
||||
? Text(
|
||||
member.name[0].toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: memberColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 20,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.person,
|
||||
color: memberColor,
|
||||
))
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
todo.title,
|
||||
|
||||
@@ -49,6 +49,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.5+1"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -89,16 +97,37 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
file_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.3.7"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_plugin_android_lifecycle
|
||||
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.33"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -400,6 +429,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -14,6 +14,7 @@ dependencies:
|
||||
intl: ^0.19.0
|
||||
provider: ^6.1.2
|
||||
google_fonts: ^6.1.0
|
||||
file_picker: ^8.0.5
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user