diff --git a/.gitignore b/.gitignore index 3b95ea2..bb25bef 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ backend/npm-debug.log backend/.env backend/.env.* backend/.npmrc +backend/uploads # Flutter flutter_app/.dart_tool diff --git a/backend/models/FamilyMember.js b/backend/models/FamilyMember.js index 74f80b4..5cca7a4 100644 --- a/backend/models/FamilyMember.js +++ b/backend/models/FamilyMember.js @@ -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 }, }, diff --git a/backend/package.json b/backend/package.json index 84ebe76..3be5776 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" } } diff --git a/backend/routes/family.js b/backend/routes/family.js index 4c0d6bf..0532492 100644 --- a/backend/routes/family.js +++ b/backend/routes/family.js @@ -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); diff --git a/backend/routes/photos.js b/backend/routes/photos.js index 1b29356..0fd059f 100644 --- a/backend/routes/photos.js +++ b/backend/routes/photos.js @@ -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); diff --git a/backend/server.js b/backend/server.js index a047a60..6e5d81b 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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 }); diff --git a/flutter_app/lib/models/family_member.dart b/flutter_app/lib/models/family_member.dart index ff86d9a..3ed7db8 100644 --- a/flutter_app/lib/models/family_member.dart +++ b/flutter_app/lib/models/family_member.dart @@ -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 toJson() { - return {"name": name, "emoji": emoji, "color": color, "order": order}; + return { + "name": name, + "iconUrl": iconUrl, + "color": color, + "order": order, + }; } } diff --git a/flutter_app/lib/screens/admin/admin_screen.dart b/flutter_app/lib/screens/admin/admin_screen.dart index 9a4c5a0..2903e5d 100644 --- a/flutter_app/lib/screens/admin/admin_screen.dart +++ b/flutter_app/lib/screens/admin/admin_screen.dart @@ -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 { + // Rainbow palette colors + final List _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 { 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( - 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( + context, + listen: false, + ).deleteFamilyMember(member.id); + setState(() {}); + }, + ), + ], ), ); }, @@ -115,72 +158,366 @@ class _FamilyManagerTabState extends State { 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( - 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( + 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( + 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( + 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( + 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 { 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(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( - context, - listen: false, - ).createPhoto( - Photo( - id: '', - url: urlController.text, - caption: captionController.text, - active: true, - ), - ); - if (mounted) { - Navigator.pop(context); - setState(() {}); - } - } - }, - child: const Text('Add'), - ), - ], ), ); } diff --git a/flutter_app/lib/services/api_client.dart b/flutter_app/lib/services/api_client.dart index 727e46c..5d5ffe3 100644 --- a/flutter_app/lib/services/api_client.dart +++ b/flutter_app/lib/services/api_client.dart @@ -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; } + Future> postMultipart( + String path, { + required String fieldName, + required Uint8List bytes, + required String filename, + Map? 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; + } + Future> put( String path, Map body, diff --git a/flutter_app/lib/services/family_service.dart b/flutter_app/lib/services/family_service.dart index 97e8b3c..b944131 100644 --- a/flutter_app/lib/services/family_service.dart +++ b/flutter_app/lib/services/family_service.dart @@ -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 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 deleteFamilyMember(String id) async { if (ApiConfig.useMockData) { MockDataStore.familyMembers.removeWhere((item) => item.id == id); diff --git a/flutter_app/lib/services/mock_data.dart b/flutter_app/lib/services/mock_data.dart index a041039..946ca1b 100644 --- a/flutter_app/lib/services/mock_data.dart +++ b/flutter_app/lib/services/mock_data.dart @@ -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, ), diff --git a/flutter_app/lib/services/photo_service.dart b/flutter_app/lib/services/photo_service.dart index 5a918e8..540abbd 100644 --- a/flutter_app/lib/services/photo_service.dart +++ b/flutter_app/lib/services/photo_service.dart @@ -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 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 deletePhoto(String id) async { if (ApiConfig.useMockData) { MockDataStore.photos.removeWhere((item) => item.id == id); diff --git a/flutter_app/lib/widgets/todo_list_widget.dart b/flutter_app/lib/widgets/todo_list_widget.dart index 029ca92..f238ff8 100644 --- a/flutter_app/lib/widgets/todo_list_widget.dart +++ b/flutter_app/lib/widgets/todo_list_widget.dart @@ -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, diff --git a/flutter_app/pubspec.lock b/flutter_app/pubspec.lock index f14f79f..fc3cc14 100644 --- a/flutter_app/pubspec.lock +++ b/flutter_app/pubspec.lock @@ -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: diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml index 497266f..9ff9ba6 100644 --- a/flutter_app/pubspec.yaml +++ b/flutter_app/pubspec.yaml @@ -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: