Add todos admin and 30s refresh

This commit is contained in:
kihong.kim
2026-01-24 23:45:22 +09:00
parent 2944b6d9b5
commit 6fb00fec5d
5 changed files with 617 additions and 276 deletions

View File

@@ -13,6 +13,8 @@ import '../../services/family_service.dart';
import '../../services/photo_service.dart'; import '../../services/photo_service.dart';
import '../../services/announcement_service.dart'; import '../../services/announcement_service.dart';
import '../../services/schedule_service.dart'; import '../../services/schedule_service.dart';
import '../../models/todo_item.dart';
import '../../services/todo_service.dart';
class AdminScreen extends StatefulWidget { class AdminScreen extends StatefulWidget {
const AdminScreen({super.key}); const AdminScreen({super.key});
@@ -25,7 +27,7 @@ class _AdminScreenState extends State<AdminScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return DefaultTabController( return DefaultTabController(
length: 5, length: 6,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Admin Settings'), title: const Text('Admin Settings'),
@@ -37,6 +39,7 @@ class _AdminScreenState extends State<AdminScreen> {
Tab(text: 'Bible Verses'), Tab(text: 'Bible Verses'),
Tab(text: 'Announcements'), Tab(text: 'Announcements'),
Tab(text: 'Schedules'), Tab(text: 'Schedules'),
Tab(text: 'Todos'),
], ],
), ),
), ),
@@ -47,6 +50,7 @@ class _AdminScreenState extends State<AdminScreen> {
BibleVerseManagerTab(), BibleVerseManagerTab(),
AnnouncementManagerTab(), AnnouncementManagerTab(),
ScheduleManagerTab(), ScheduleManagerTab(),
TodoManagerTab(),
], ],
), ),
), ),
@@ -1239,3 +1243,226 @@ class _ScheduleManagerTabState extends State<ScheduleManagerTab> {
); );
} }
} }
class TodoManagerTab extends StatefulWidget {
const TodoManagerTab({super.key});
@override
State<TodoManagerTab> createState() => _TodoManagerTabState();
}
class _TodoManagerTabState extends State<TodoManagerTab> {
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddTodoDialog(context),
child: const Icon(Icons.check_box),
),
body: FutureBuilder<List<TodoItem>>(
future: Provider.of<TodoService>(context).fetchTodos(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final todos = snapshot.data!;
if (todos.isEmpty) {
return const Center(
child: Text(
'No todos added yet',
style: TextStyle(color: Colors.grey),
),
);
}
return ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
leading: Checkbox(
value: todo.completed,
onChanged: (val) async {
await Provider.of<TodoService>(
context,
listen: false,
).updateTodo(
TodoItem(
id: todo.id,
familyMemberId: todo.familyMemberId,
title: todo.title,
completed: val ?? false,
dueDate: todo.dueDate,
),
);
setState(() {});
},
),
title: Text(
todo.title,
style: TextStyle(
decoration:
todo.completed ? TextDecoration.lineThrough : null,
),
),
subtitle: todo.dueDate != null
? Text(
todo.dueDate!.toIso8601String().split('T')[0],
style:
const TextStyle(fontSize: 12, color: Colors.grey),
)
: null,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit, color: Colors.blue),
onPressed: () => _showEditTodoDialog(context, todo),
),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () async {
await Provider.of<TodoService>(
context,
listen: false,
).deleteTodo(todo.id);
setState(() {});
},
),
],
),
);
},
);
},
),
);
}
void _showAddTodoDialog(BuildContext context) {
_showTodoDialog(context, null);
}
void _showEditTodoDialog(BuildContext context, TodoItem todo) {
_showTodoDialog(context, todo);
}
void _showTodoDialog(BuildContext context, TodoItem? todo) {
final isEditing = todo != null;
final titleController = TextEditingController(text: todo?.title ?? '');
final dateController = TextEditingController(
text: todo?.dueDate?.toIso8601String().split('T')[0] ?? '',
);
String? selectedFamilyMemberId = todo?.familyMemberId;
bool isCompleted = todo?.completed ?? false;
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: Text(isEditing ? 'Edit Todo' : 'Add Todo'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: titleController,
decoration: const InputDecoration(labelText: 'Title'),
),
TextField(
controller: dateController,
decoration: const InputDecoration(
labelText: 'Due Date (YYYY-MM-DD)',
hintText: '2024-01-01',
),
),
FutureBuilder<List<FamilyMember>>(
future: Provider.of<FamilyService>(context, listen: false)
.fetchFamilyMembers(),
builder: (context, snapshot) {
if (!snapshot.hasData) return const SizedBox();
final members = snapshot.data!;
return DropdownButtonFormField<String>(
value: selectedFamilyMemberId,
decoration: const InputDecoration(
labelText: 'Family Member',
),
items: members.map((member) {
return DropdownMenuItem(
value: member.id,
child: Text(member.name),
);
}).toList(),
onChanged: (value) {
setDialogState(() {
selectedFamilyMemberId = value;
});
},
);
},
),
if (isEditing)
SwitchListTile(
title: const Text('Completed'),
value: isCompleted,
onChanged: (value) {
setDialogState(() {
isCompleted = value;
});
},
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
if (titleController.text.isNotEmpty &&
selectedFamilyMemberId != null) {
final date = dateController.text.isNotEmpty
? DateTime.tryParse(dateController.text)
: null;
final newItem = TodoItem(
id: todo?.id ?? '',
familyMemberId: selectedFamilyMemberId!,
title: titleController.text,
completed: isCompleted,
dueDate: date,
);
if (isEditing) {
await Provider.of<TodoService>(
context,
listen: false,
).updateTodo(newItem);
} else {
await Provider.of<TodoService>(
context,
listen: false,
).createTodo(newItem);
}
if (mounted) {
Navigator.pop(context);
setState(() {});
}
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Title and Family Member are required')),
);
}
},
child: Text(isEditing ? 'Save' : 'Add'),
),
],
),
),
);
}
}

View File

@@ -13,7 +13,8 @@ class AnnouncementWidget extends StatefulWidget {
class _AnnouncementWidgetState extends State<AnnouncementWidget> { class _AnnouncementWidgetState extends State<AnnouncementWidget> {
final PageController _pageController = PageController(); final PageController _pageController = PageController();
Timer? _timer; Timer? _scrollTimer;
Timer? _refreshTimer;
int _currentPage = 0; int _currentPage = 0;
List<Announcement> _announcements = []; List<Announcement> _announcements = [];
@@ -21,6 +22,13 @@ class _AnnouncementWidgetState extends State<AnnouncementWidget> {
void initState() { void initState() {
super.initState(); super.initState();
_fetchAnnouncements(); _fetchAnnouncements();
_startAutoRefresh();
}
void _startAutoRefresh() {
_refreshTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
_fetchAnnouncements();
});
} }
void _fetchAnnouncements() async { void _fetchAnnouncements() async {
@@ -41,9 +49,9 @@ class _AnnouncementWidgetState extends State<AnnouncementWidget> {
} }
void _startAutoScroll() { void _startAutoScroll() {
_timer?.cancel(); _scrollTimer?.cancel();
if (_announcements.length > 1) { if (_announcements.length > 1) {
_timer = Timer.periodic(const Duration(seconds: 10), (timer) { _scrollTimer = Timer.periodic(const Duration(seconds: 10), (timer) {
if (_pageController.hasClients) { if (_pageController.hasClients) {
_currentPage++; _currentPage++;
if (_currentPage >= _announcements.length) { if (_currentPage >= _announcements.length) {
@@ -61,7 +69,8 @@ class _AnnouncementWidgetState extends State<AnnouncementWidget> {
@override @override
void dispose() { void dispose() {
_timer?.cancel(); _scrollTimer?.cancel();
_refreshTimer?.cancel();
_pageController.dispose(); _pageController.dispose();
super.dispose(); super.dispose();
} }

View File

@@ -3,9 +3,26 @@ import 'package:provider/provider.dart';
import '../models/bible_verse.dart'; import '../models/bible_verse.dart';
import '../services/bible_service.dart'; import '../services/bible_service.dart';
class BibleVerseWidget extends StatelessWidget { class BibleVerseWidget extends StatefulWidget {
const BibleVerseWidget({super.key}); const BibleVerseWidget({super.key});
@override
State<BibleVerseWidget> createState() => _BibleVerseWidgetState();
}
class _BibleVerseWidgetState extends State<BibleVerseWidget> {
late Future<BibleVerse> _verseFuture;
@override
void initState() {
super.initState();
// Fetch only once on init
_verseFuture = Provider.of<BibleService>(
context,
listen: false,
).fetchTodayVerse();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
@@ -24,10 +41,7 @@ class BibleVerseWidget extends StatelessWidget {
), ),
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: FutureBuilder<BibleVerse>( child: FutureBuilder<BibleVerse>(
future: Provider.of<BibleService>( future: _verseFuture,
context,
listen: false,
).fetchTodayVerse(),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());

View File

@@ -1,12 +1,66 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../models/schedule_item.dart'; import '../models/schedule_item.dart';
import '../services/schedule_service.dart'; import '../services/schedule_service.dart';
class ScheduleListWidget extends StatelessWidget { class ScheduleListWidget extends StatefulWidget {
const ScheduleListWidget({super.key}); const ScheduleListWidget({super.key});
@override
State<ScheduleListWidget> createState() => _ScheduleListWidgetState();
}
class _ScheduleListWidgetState extends State<ScheduleListWidget> {
Timer? _timer;
List<ScheduleItem> _schedules = [];
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_fetchSchedules();
_startAutoRefresh();
}
void _startAutoRefresh() {
_timer = Timer.periodic(const Duration(seconds: 30), (timer) {
_fetchSchedules();
});
}
Future<void> _fetchSchedules() async {
try {
final data = await Provider.of<ScheduleService>(
context,
listen: false,
).fetchWeeklySchedules();
if (mounted) {
setState(() {
_schedules = data;
_isLoading = false;
_error = null;
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = 'Failed to load schedules';
_isLoading = false;
});
}
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
@@ -27,26 +81,28 @@ class ScheduleListWidget extends StatelessWidget {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Expanded( Expanded(
child: FutureBuilder<List<ScheduleItem>>( child: _buildContent(),
future: Provider.of<ScheduleService>( ),
context, ],
listen: false,
).fetchWeeklySchedules(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return const Center(
child: Text(
'Failed to load schedules',
style: TextStyle(color: Colors.white54),
), ),
); );
} }
if (!snapshot.hasData || snapshot.data!.isEmpty) { Widget _buildContent() {
if (_isLoading && _schedules.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null && _schedules.isEmpty) {
return Center(
child: Text(
_error!,
style: const TextStyle(color: Colors.white54),
),
);
}
if (_schedules.isEmpty) {
return const Center( return const Center(
child: Text( child: Text(
'No schedules this week', 'No schedules this week',
@@ -55,19 +111,16 @@ class ScheduleListWidget extends StatelessWidget {
); );
} }
final schedules = snapshot.data!;
// Sort by date // Sort by date
schedules.sort((a, b) => a.startDate.compareTo(b.startDate)); final sortedSchedules = List<ScheduleItem>.from(_schedules);
sortedSchedules.sort((a, b) => a.startDate.compareTo(b.startDate));
return ListView.separated( return ListView.separated(
itemCount: schedules.length, itemCount: sortedSchedules.length,
separatorBuilder: (context, index) => separatorBuilder: (context, index) =>
const Divider(color: Colors.white10), const Divider(color: Colors.white10),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = schedules[index]; final item = sortedSchedules[index];
final dateStr = DateFormat(
'E, MMM d',
).format(item.startDate);
final timeStr = item.isAllDay final timeStr = item.isAllDay
? 'All Day' ? 'All Day'
: DateFormat('HH:mm').format(item.startDate); : DateFormat('HH:mm').format(item.startDate);
@@ -119,11 +172,5 @@ class ScheduleListWidget extends StatelessWidget {
); );
}, },
); );
},
),
),
],
),
);
} }
} }

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/todo_item.dart'; import '../models/todo_item.dart';
@@ -5,9 +6,70 @@ import '../models/family_member.dart';
import '../services/todo_service.dart'; import '../services/todo_service.dart';
import '../services/family_service.dart'; import '../services/family_service.dart';
class TodoListWidget extends StatelessWidget { class TodoListWidget extends StatefulWidget {
const TodoListWidget({super.key}); const TodoListWidget({super.key});
@override
State<TodoListWidget> createState() => _TodoListWidgetState();
}
class _TodoListWidgetState extends State<TodoListWidget> {
Timer? _timer;
List<TodoItem> _todos = [];
List<FamilyMember> _members = [];
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_fetchData();
_startAutoRefresh();
}
void _startAutoRefresh() {
_timer = Timer.periodic(const Duration(seconds: 30), (timer) {
_fetchData();
});
}
Future<void> _fetchData() async {
try {
final results = await Future.wait([
Provider.of<TodoService>(
context,
listen: false,
).fetchTodayTodos(),
Provider.of<FamilyService>(
context,
listen: false,
).fetchFamilyMembers(),
]);
if (mounted) {
setState(() {
_todos = results[0] as List<TodoItem>;
_members = results[1] as List<FamilyMember>;
_isLoading = false;
_error = null;
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = 'Failed to load todos';
_isLoading = false;
});
}
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
@@ -37,44 +99,28 @@ class TodoListWidget extends StatelessWidget {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Expanded( Expanded(
child: FutureBuilder<List<dynamic>>( child: _buildContent(),
future: Future.wait([ ),
Provider.of<TodoService>( ],
context, ),
listen: false, );
).fetchTodayTodos(), }
Provider.of<FamilyService>(
context, Widget _buildContent() {
listen: false, if (_isLoading && _todos.isEmpty) {
).fetchFamilyMembers(),
]),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (snapshot.hasError) { if (_error != null && _todos.isEmpty) {
return const Center( return Center(
child: Text( child: Text(
'Failed to load todos', _error!,
style: TextStyle(color: Colors.white54), style: const TextStyle(color: Colors.white54),
), ),
); );
} }
if (!snapshot.hasData) { if (_todos.isEmpty) {
return const Center(
child: Text(
'No todos today',
style: TextStyle(color: Colors.white54),
),
);
}
final todos = snapshot.data![0] as List<TodoItem>;
final members = snapshot.data![1] as List<FamilyMember>;
if (todos.isEmpty) {
return const Center( return const Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -91,12 +137,12 @@ class TodoListWidget extends StatelessWidget {
} }
return ListView.separated( return ListView.separated(
itemCount: todos.length, itemCount: _todos.length,
separatorBuilder: (context, index) => separatorBuilder: (context, index) =>
const Divider(color: Colors.white10), const Divider(color: Colors.white10),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final todo = todos[index]; final todo = _todos[index];
final member = members.firstWhere( final member = _members.firstWhere(
(m) => m.id == todo.familyMemberId, (m) => m.id == todo.familyMemberId,
orElse: () => const FamilyMember( orElse: () => const FamilyMember(
id: '', id: '',
@@ -168,9 +214,7 @@ class TodoListWidget extends StatelessWidget {
todo.title, todo.title,
style: TextStyle( style: TextStyle(
color: todo.completed ? Colors.white54 : Colors.white, color: todo.completed ? Colors.white54 : Colors.white,
decoration: todo.completed decoration: todo.completed ? TextDecoration.lineThrough : null,
? TextDecoration.lineThrough
: null,
decorationColor: Colors.white54, decorationColor: Colors.white54,
), ),
), ),
@@ -185,16 +229,22 @@ class TodoListWidget extends StatelessWidget {
completed: val ?? false, completed: val ?? false,
dueDate: todo.dueDate, dueDate: todo.dueDate,
); );
// Optimistic update
setState(() {
final idx = _todos.indexWhere((t) => t.id == todo.id);
if (idx != -1) {
_todos[idx] = updated;
}
});
await Provider.of<TodoService>( await Provider.of<TodoService>(
context, context,
listen: false, listen: false,
).updateTodo(updated); ).updateTodo(updated);
// Force rebuild? In a real app we'd use a reactive state.
// Here we rely on the parent or timer to refresh, or we could convert this to StatefulWidget. // Refresh to ensure sync
// For now, let's just let the next refresh cycle pick it up, or if the user interacts, maybe we should optimistic update? _fetchData();
// Given it's a TV dashboard, interaction might be rare, but if it is interactive:
(context as Element)
.markNeedsBuild(); // HACK to refresh
}, },
activeColor: Theme.of(context).colorScheme.secondary, activeColor: Theme.of(context).colorScheme.secondary,
checkColor: Colors.black, checkColor: Colors.black,
@@ -202,11 +252,5 @@ class TodoListWidget extends StatelessWidget {
); );
}, },
); );
},
),
),
],
),
);
} }
} }