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/.env.*
|
backend/.env.*
|
||||||
backend/.npmrc
|
backend/.npmrc
|
||||||
|
backend/uploads
|
||||||
|
|
||||||
# Flutter
|
# Flutter
|
||||||
flutter_app/.dart_tool
|
flutter_app/.dart_tool
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ const mongoose = require("mongoose");
|
|||||||
const familyMemberSchema = new mongoose.Schema(
|
const familyMemberSchema = new mongoose.Schema(
|
||||||
{
|
{
|
||||||
name: { type: String, required: true },
|
name: { type: String, required: true },
|
||||||
emoji: { type: String, required: true },
|
iconUrl: { type: String, default: "" },
|
||||||
|
emoji: { type: String, default: "" },
|
||||||
color: { type: String, required: true },
|
color: { type: String, required: true },
|
||||||
order: { type: Number, default: 0 },
|
order: { type: Number, default: 0 },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^4.19.2",
|
"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 express = require("express");
|
||||||
|
const path = require("path");
|
||||||
|
const fs = require("fs");
|
||||||
|
const multer = require("multer");
|
||||||
const FamilyMember = require("../models/FamilyMember");
|
const FamilyMember = require("../models/FamilyMember");
|
||||||
|
|
||||||
const router = express.Router();
|
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) => {
|
router.get("/", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const members = await FamilyMember.find().sort({ order: 1, createdAt: 1 });
|
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) => {
|
router.get("/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const member = await FamilyMember.findById(req.params.id);
|
const member = await FamilyMember.findById(req.params.id);
|
||||||
|
|||||||
@@ -1,8 +1,27 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
|
const path = require("path");
|
||||||
|
const fs = require("fs");
|
||||||
|
const multer = require("multer");
|
||||||
const Photo = require("../models/Photo");
|
const Photo = require("../models/Photo");
|
||||||
|
|
||||||
const router = express.Router();
|
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) => {
|
router.get("/", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const filter = {};
|
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) => {
|
router.delete("/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const photo = await Photo.findByIdAndDelete(req.params.id);
|
const photo = await Photo.findByIdAndDelete(req.params.id);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const cors = require("cors");
|
const cors = require("cors");
|
||||||
const dotenv = require("dotenv");
|
const dotenv = require("dotenv");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
const connectDb = require("./config/db");
|
const connectDb = require("./config/db");
|
||||||
const familyRoutes = require("./routes/family");
|
const familyRoutes = require("./routes/family");
|
||||||
@@ -18,6 +19,7 @@ const port = process.env.PORT || 4000;
|
|||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json({ limit: "2mb" }));
|
app.use(express.json({ limit: "2mb" }));
|
||||||
|
app.use("/uploads", express.static(path.join(__dirname, "uploads")));
|
||||||
|
|
||||||
app.get("/health", (req, res) => {
|
app.get("/health", (req, res) => {
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
class FamilyMember {
|
class FamilyMember {
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
|
final String iconUrl;
|
||||||
final String emoji;
|
final String emoji;
|
||||||
final String color;
|
final String color;
|
||||||
final int order;
|
final int order;
|
||||||
@@ -8,6 +9,7 @@ class FamilyMember {
|
|||||||
const FamilyMember({
|
const FamilyMember({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
|
required this.iconUrl,
|
||||||
required this.emoji,
|
required this.emoji,
|
||||||
required this.color,
|
required this.color,
|
||||||
required this.order,
|
required this.order,
|
||||||
@@ -17,6 +19,7 @@ class FamilyMember {
|
|||||||
return FamilyMember(
|
return FamilyMember(
|
||||||
id: json["_id"] as String? ?? "",
|
id: json["_id"] as String? ?? "",
|
||||||
name: json["name"] as String? ?? "",
|
name: json["name"] as String? ?? "",
|
||||||
|
iconUrl: json["iconUrl"] as String? ?? "",
|
||||||
emoji: json["emoji"] as String? ?? "",
|
emoji: json["emoji"] as String? ?? "",
|
||||||
color: json["color"] as String? ?? "",
|
color: json["color"] as String? ?? "",
|
||||||
order: (json["order"] as num?)?.toInt() ?? 0,
|
order: (json["order"] as num?)?.toInt() ?? 0,
|
||||||
@@ -24,6 +27,11 @@ class FamilyMember {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
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:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../../models/bible_verse.dart';
|
import '../../models/bible_verse.dart';
|
||||||
@@ -59,6 +63,36 @@ class FamilyManagerTab extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _FamilyManagerTabState extends State<FamilyManagerTab> {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -76,34 +110,43 @@ class _FamilyManagerTabState extends State<FamilyManagerTab> {
|
|||||||
itemCount: members.length,
|
itemCount: members.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final member = members[index];
|
final member = members[index];
|
||||||
Color memberColor;
|
Color memberColor = _hexToColor(member.color);
|
||||||
try {
|
|
||||||
memberColor = Color(
|
|
||||||
int.parse(member.color.replaceAll('#', '0xFF')),
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
memberColor = Colors.grey;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
backgroundColor: memberColor,
|
backgroundColor: memberColor,
|
||||||
child: Text(
|
backgroundImage: member.iconUrl.isNotEmpty
|
||||||
member.emoji,
|
? NetworkImage(member.iconUrl)
|
||||||
style: const TextStyle(fontSize: 20),
|
: null,
|
||||||
),
|
child: member.iconUrl.isEmpty
|
||||||
|
? Text(
|
||||||
|
member.name.isNotEmpty
|
||||||
|
? member.name[0].toUpperCase()
|
||||||
|
: '?',
|
||||||
|
style: const TextStyle(fontSize: 20),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
title: Text(member.name),
|
title: Text(member.name),
|
||||||
subtitle: Text('Order: ${member.order}'),
|
subtitle: Text('Order: ${member.order}'),
|
||||||
trailing: IconButton(
|
trailing: Row(
|
||||||
icon: const Icon(Icons.delete, color: Colors.red),
|
mainAxisSize: MainAxisSize.min,
|
||||||
onPressed: () async {
|
children: [
|
||||||
await Provider.of<FamilyService>(
|
IconButton(
|
||||||
context,
|
icon: const Icon(Icons.edit, color: Colors.blue),
|
||||||
listen: false,
|
onPressed: () => _showEditMemberDialog(context, member),
|
||||||
).deleteFamilyMember(member.id);
|
),
|
||||||
setState(() {});
|
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) {
|
void _showAddMemberDialog(BuildContext context) {
|
||||||
final nameController = TextEditingController();
|
final nameController = TextEditingController();
|
||||||
final emojiController = TextEditingController(text: '👤');
|
|
||||||
final colorController = TextEditingController(text: '0xFFFFD700');
|
|
||||||
final orderController = TextEditingController(text: '1');
|
final orderController = TextEditingController(text: '1');
|
||||||
|
Color selectedColor = _palette[0];
|
||||||
|
|
||||||
|
Uint8List? selectedIconBytes;
|
||||||
|
String? selectedIconName;
|
||||||
|
bool isUploading = false;
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => StatefulBuilder(
|
||||||
title: const Text('Add Family Member'),
|
builder: (context, setDialogState) => AlertDialog(
|
||||||
content: SingleChildScrollView(
|
title: const Text('Add Family Member'),
|
||||||
child: Column(
|
content: SingleChildScrollView(
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Column(
|
||||||
children: [
|
mainAxisSize: MainAxisSize.min,
|
||||||
TextField(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
controller: nameController,
|
children: [
|
||||||
decoration: const InputDecoration(labelText: 'Name'),
|
TextField(
|
||||||
),
|
controller: nameController,
|
||||||
TextField(
|
decoration: const InputDecoration(labelText: 'Name'),
|
||||||
controller: emojiController,
|
|
||||||
decoration: const InputDecoration(labelText: 'Emoji'),
|
|
||||||
),
|
|
||||||
TextField(
|
|
||||||
controller: colorController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Color (Hex 0xAARRGGBB)',
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
const Text('Icon', style: TextStyle(color: Colors.grey)),
|
||||||
controller: orderController,
|
const SizedBox(height: 8),
|
||||||
decoration: const InputDecoration(labelText: 'Order'),
|
Row(
|
||||||
keyboardType: TextInputType.number,
|
children: [
|
||||||
),
|
CircleAvatar(
|
||||||
],
|
backgroundColor: selectedColor,
|
||||||
),
|
backgroundImage: selectedIconBytes != null
|
||||||
),
|
? MemoryImage(selectedIconBytes!)
|
||||||
actions: [
|
: null,
|
||||||
TextButton(
|
child: selectedIconBytes == null
|
||||||
onPressed: () => Navigator.pop(context),
|
? const Icon(Icons.person, color: Colors.white)
|
||||||
child: const Text('Cancel'),
|
: null,
|
||||||
),
|
),
|
||||||
TextButton(
|
const SizedBox(width: 16),
|
||||||
onPressed: () async {
|
ElevatedButton.icon(
|
||||||
if (nameController.text.isNotEmpty) {
|
onPressed: () async {
|
||||||
await Provider.of<FamilyService>(
|
final result = await FilePicker.platform.pickFiles(
|
||||||
context,
|
withData: true,
|
||||||
listen: false,
|
type: FileType.image,
|
||||||
).createFamilyMember(
|
);
|
||||||
FamilyMember(
|
if (result != null && result.files.isNotEmpty) {
|
||||||
id: '',
|
setDialogState(() {
|
||||||
name: nameController.text,
|
selectedIconBytes = result.files.first.bytes;
|
||||||
emoji: emojiController.text,
|
selectedIconName = result.files.first.name;
|
||||||
color: colorController.text.replaceFirst(
|
});
|
||||||
'0xFF',
|
}
|
||||||
'#',
|
},
|
||||||
), // Simple conversion
|
icon: const Icon(Icons.image),
|
||||||
order: int.tryParse(orderController.text) ?? 1,
|
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),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
const SizedBox(height: 16),
|
||||||
if (mounted) {
|
const Text('Color', style: TextStyle(color: Colors.grey)),
|
||||||
Navigator.pop(context);
|
const SizedBox(height: 8),
|
||||||
setState(() {});
|
Wrap(
|
||||||
}
|
spacing: 8,
|
||||||
}
|
runSpacing: 8,
|
||||||
},
|
children: _palette.map((color) {
|
||||||
child: const Text('Add'),
|
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) {
|
void _showAddPhotoDialog(BuildContext context) {
|
||||||
final urlController = TextEditingController();
|
final urlController = TextEditingController();
|
||||||
final captionController = TextEditingController();
|
final captionController = TextEditingController();
|
||||||
|
Uint8List? selectedFileBytes;
|
||||||
|
String? selectedFileName;
|
||||||
|
bool isUploading = false;
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => StatefulBuilder(
|
||||||
title: const Text('Add Photo URL'),
|
builder: (context, setDialogState) => AlertDialog(
|
||||||
content: Column(
|
title: const Text('Add Photo'),
|
||||||
mainAxisSize: MainAxisSize.min,
|
content: Column(
|
||||||
children: [
|
mainAxisSize: MainAxisSize.min,
|
||||||
TextField(
|
children: [
|
||||||
controller: urlController,
|
ElevatedButton.icon(
|
||||||
decoration: const InputDecoration(labelText: 'Image URL'),
|
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(
|
TextButton(
|
||||||
controller: captionController,
|
onPressed: isUploading
|
||||||
decoration: const InputDecoration(labelText: 'Caption'),
|
? 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:convert";
|
||||||
|
import "dart:typed_data";
|
||||||
import "package:http/http.dart" as http;
|
import "package:http/http.dart" as http;
|
||||||
import "../config/api_config.dart";
|
import "../config/api_config.dart";
|
||||||
|
|
||||||
@@ -45,6 +46,30 @@ class ApiClient {
|
|||||||
return jsonDecode(response.body) as Map<String, dynamic>;
|
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(
|
Future<Map<String, dynamic>> put(
|
||||||
String path,
|
String path,
|
||||||
Map<String, dynamic> body,
|
Map<String, dynamic> body,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import "dart:typed_data";
|
||||||
|
|
||||||
import "../config/api_config.dart";
|
import "../config/api_config.dart";
|
||||||
import "../models/family_member.dart";
|
import "../models/family_member.dart";
|
||||||
import "api_client.dart";
|
import "api_client.dart";
|
||||||
@@ -23,6 +25,7 @@ class FamilyService {
|
|||||||
final created = FamilyMember(
|
final created = FamilyMember(
|
||||||
id: "family-${DateTime.now().millisecondsSinceEpoch}",
|
id: "family-${DateTime.now().millisecondsSinceEpoch}",
|
||||||
name: member.name,
|
name: member.name,
|
||||||
|
iconUrl: member.iconUrl,
|
||||||
emoji: member.emoji,
|
emoji: member.emoji,
|
||||||
color: member.color,
|
color: member.color,
|
||||||
order: member.order,
|
order: member.order,
|
||||||
@@ -51,6 +54,22 @@ class FamilyService {
|
|||||||
return FamilyMember.fromJson(data);
|
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 {
|
Future<void> deleteFamilyMember(String id) async {
|
||||||
if (ApiConfig.useMockData) {
|
if (ApiConfig.useMockData) {
|
||||||
MockDataStore.familyMembers.removeWhere((item) => item.id == id);
|
MockDataStore.familyMembers.removeWhere((item) => item.id == id);
|
||||||
|
|||||||
@@ -11,28 +11,32 @@ class MockDataStore {
|
|||||||
const FamilyMember(
|
const FamilyMember(
|
||||||
id: "family-1",
|
id: "family-1",
|
||||||
name: "Dad",
|
name: "Dad",
|
||||||
emoji: ":)",
|
iconUrl: "",
|
||||||
|
emoji: "",
|
||||||
color: "#0F766E",
|
color: "#0F766E",
|
||||||
order: 1,
|
order: 1,
|
||||||
),
|
),
|
||||||
const FamilyMember(
|
const FamilyMember(
|
||||||
id: "family-2",
|
id: "family-2",
|
||||||
name: "Mom",
|
name: "Mom",
|
||||||
emoji: "<3",
|
iconUrl: "",
|
||||||
|
emoji: "",
|
||||||
color: "#C2410C",
|
color: "#C2410C",
|
||||||
order: 2,
|
order: 2,
|
||||||
),
|
),
|
||||||
const FamilyMember(
|
const FamilyMember(
|
||||||
id: "family-3",
|
id: "family-3",
|
||||||
name: "Son",
|
name: "Son",
|
||||||
emoji: ":D",
|
iconUrl: "",
|
||||||
|
emoji: "",
|
||||||
color: "#1D4ED8",
|
color: "#1D4ED8",
|
||||||
order: 3,
|
order: 3,
|
||||||
),
|
),
|
||||||
const FamilyMember(
|
const FamilyMember(
|
||||||
id: "family-4",
|
id: "family-4",
|
||||||
name: "Daughter",
|
name: "Daughter",
|
||||||
emoji: ":-)",
|
iconUrl: "",
|
||||||
|
emoji: "",
|
||||||
color: "#7C3AED",
|
color: "#7C3AED",
|
||||||
order: 4,
|
order: 4,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import "dart:typed_data";
|
||||||
|
|
||||||
import "../config/api_config.dart";
|
import "../config/api_config.dart";
|
||||||
import "../models/photo.dart";
|
import "../models/photo.dart";
|
||||||
import "api_client.dart";
|
import "api_client.dart";
|
||||||
@@ -38,6 +40,35 @@ class PhotoService {
|
|||||||
return Photo.fromJson(data);
|
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 {
|
Future<void> deletePhoto(String id) async {
|
||||||
if (ApiConfig.useMockData) {
|
if (ApiConfig.useMockData) {
|
||||||
MockDataStore.photos.removeWhere((item) => item.id == id);
|
MockDataStore.photos.removeWhere((item) => item.id == id);
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ class TodoListWidget extends StatelessWidget {
|
|||||||
orElse: () => const FamilyMember(
|
orElse: () => const FamilyMember(
|
||||||
id: '',
|
id: '',
|
||||||
name: 'Unknown',
|
name: 'Unknown',
|
||||||
|
iconUrl: '',
|
||||||
emoji: '👤',
|
emoji: '👤',
|
||||||
color: '#888888',
|
color: '#888888',
|
||||||
order: 0,
|
order: 0,
|
||||||
@@ -110,9 +111,16 @@ class TodoListWidget extends StatelessWidget {
|
|||||||
// Parse color
|
// Parse color
|
||||||
Color memberColor;
|
Color memberColor;
|
||||||
try {
|
try {
|
||||||
memberColor = Color(
|
String cleanHex =
|
||||||
int.parse(member.color.replaceAll('#', '0xFF')),
|
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 (_) {
|
} catch (_) {
|
||||||
memberColor = Colors.grey;
|
memberColor = Colors.grey;
|
||||||
}
|
}
|
||||||
@@ -121,10 +129,24 @@ class TodoListWidget extends StatelessWidget {
|
|||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
backgroundColor: memberColor.withOpacity(0.2),
|
backgroundColor: memberColor.withOpacity(0.2),
|
||||||
child: Text(
|
backgroundImage: member.iconUrl.isNotEmpty
|
||||||
member.emoji,
|
? NetworkImage(member.iconUrl)
|
||||||
style: const TextStyle(fontSize: 20),
|
: 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(
|
title: Text(
|
||||||
todo.title,
|
todo.title,
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
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:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -89,16 +97,37 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
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:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_web_plugins:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
glob:
|
glob:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -400,6 +429,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
|
win32:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: win32
|
||||||
|
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.15.0"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ dependencies:
|
|||||||
intl: ^0.19.0
|
intl: ^0.19.0
|
||||||
provider: ^6.1.2
|
provider: ^6.1.2
|
||||||
google_fonts: ^6.1.0
|
google_fonts: ^6.1.0
|
||||||
|
file_picker: ^8.0.5
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user