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

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@ backend/npm-debug.log
backend/.env
backend/.env.*
backend/.npmrc
backend/uploads
# Flutter
flutter_app/.dart_tool

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 });

View File

@@ -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,
};
}
}

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'),
),
],
),
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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