Add file uploads for photos and family icons
This commit is contained in:
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user