Initial commit

This commit is contained in:
kihong.kim
2026-01-24 19:41:19 +09:00
commit 807df3d678
90 changed files with 6411 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
class ApiConfig {
static const String baseUrl = String.fromEnvironment(
"API_BASE_URL",
defaultValue: "http://localhost:4000",
);
static const bool useMockData = bool.fromEnvironment(
"USE_MOCK_DATA",
defaultValue: false,
);
static const String family = "/api/family";
static const String todos = "/api/todos";
static const String schedules = "/api/schedules";
static const String announcements = "/api/announcements";
static const String weather = "/api/weather";
static const String bibleToday = "/api/bible/today";
static const String bibleVerses = "/api/bible/verses";
static const String photos = "/api/photos";
}

132
flutter_app/lib/main.dart Normal file
View File

@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:provider/provider.dart';
import 'config/api_config.dart';
import 'screens/admin/admin_screen.dart';
import 'screens/mobile/mobile_home_screen.dart';
import 'screens/tv/tv_dashboard_screen.dart';
import 'services/announcement_service.dart';
import 'services/api_client.dart';
import 'services/bible_service.dart';
import 'services/bible_verse_service.dart';
import 'services/family_service.dart';
import 'services/photo_service.dart';
import 'services/schedule_service.dart';
import 'services/todo_service.dart';
import 'services/weather_service.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Hide status bar for TV immersive experience
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
await initializeDateFormatting();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
// Shared ApiClient instance
final apiClient = ApiClient();
return MultiProvider(
providers: [
Provider<ApiClient>.value(value: apiClient),
Provider<WeatherService>(create: (_) => WeatherService(apiClient)),
Provider<BibleService>(create: (_) => BibleService(apiClient)),
Provider<BibleVerseService>(
create: (_) => BibleVerseService(apiClient),
),
Provider<TodoService>(create: (_) => TodoService(apiClient)),
Provider<ScheduleService>(create: (_) => ScheduleService(apiClient)),
Provider<AnnouncementService>(
create: (_) => AnnouncementService(apiClient),
),
Provider<PhotoService>(create: (_) => PhotoService(apiClient)),
Provider<FamilyService>(create: (_) => FamilyService(apiClient)),
],
child: MaterialApp(
title: 'Bini Google TV Dashboard',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: const Color(
0xFF0F172A,
), // Deep Midnight Navy
colorScheme: const ColorScheme.dark(
primary: Color(0xFFFFD700), // Cinema Gold
onPrimary: Colors.black,
secondary: Color(0xFF4FC3F7), // Sky Blue
onSecondary: Colors.black,
surface: Color(0xFF1E293B), // Slate 800
onSurface: Colors.white,
background: Color(0xFF0F172A),
onBackground: Colors.white,
error: Color(0xFFFF6E40), // Deep Orange
),
cardTheme: CardThemeData(
color: const Color(0xFF1E293B),
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
textTheme: TextTheme(
displayLarge: GoogleFonts.outfit(
fontSize: 64,
fontWeight: FontWeight.bold,
color: Colors.white,
), // Header time
displayMedium: GoogleFonts.outfit(
fontSize: 40,
fontWeight: FontWeight.w600,
color: const Color(0xFFF1F5F9),
), // Section titles
bodyLarge: GoogleFonts.mulish(
fontSize: 24,
color: const Color(0xFFE2E8F0),
), // Main content
bodyMedium: GoogleFonts.mulish(
fontSize: 18,
color: const Color(0xFFCBD5E1),
), // Secondary content
displaySmall: GoogleFonts.outfit(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.white,
), // Clock usage
headlineSmall: GoogleFonts.outfit(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
titleLarge: GoogleFonts.outfit(
fontSize: 22,
fontWeight: FontWeight.w600,
color: Colors.white,
),
titleMedium: GoogleFonts.outfit(
fontSize: 16,
fontWeight: FontWeight.w500,
color: const Color(0xFFF1F5F9),
),
),
),
initialRoute: '/',
routes: {
'/': (context) => const TvDashboardScreen(),
'/mobile': (context) => const MobileHomeScreen(),
'/admin': (context) => const AdminScreen(),
},
),
);
}
}

View File

@@ -0,0 +1,34 @@
class Announcement {
final String id;
final String title;
final String content;
final int priority;
final bool active;
const Announcement({
required this.id,
required this.title,
required this.content,
required this.priority,
required this.active,
});
factory Announcement.fromJson(Map<String, dynamic> json) {
return Announcement(
id: json["_id"] as String? ?? "",
title: json["title"] as String? ?? "",
content: json["content"] as String? ?? "",
priority: (json["priority"] as num?)?.toInt() ?? 0,
active: json["active"] as bool? ?? true,
);
}
Map<String, dynamic> toJson() {
return {
"title": title,
"content": content,
"priority": priority,
"active": active,
};
}
}

View File

@@ -0,0 +1,34 @@
class BibleVerse {
final String id;
final String text;
final String reference;
final String? date;
final bool active;
const BibleVerse({
required this.id,
required this.text,
required this.reference,
required this.date,
required this.active,
});
factory BibleVerse.fromJson(Map<String, dynamic> json) {
return BibleVerse(
id: json["_id"] as String? ?? "",
text: json["text"] as String? ?? "",
reference: json["reference"] as String? ?? "",
date: json["date"] as String?,
active: json["active"] as bool? ?? true,
);
}
Map<String, dynamic> toJson() {
return {
"text": text,
"reference": reference,
"date": date,
"active": active,
};
}
}

View File

@@ -0,0 +1,29 @@
class FamilyMember {
final String id;
final String name;
final String emoji;
final String color;
final int order;
const FamilyMember({
required this.id,
required this.name,
required this.emoji,
required this.color,
required this.order,
});
factory FamilyMember.fromJson(Map<String, dynamic> json) {
return FamilyMember(
id: json["_id"] as String? ?? "",
name: json["name"] as String? ?? "",
emoji: json["emoji"] as String? ?? "",
color: json["color"] as String? ?? "",
order: (json["order"] as num?)?.toInt() ?? 0,
);
}
Map<String, dynamic> toJson() {
return {"name": name, "emoji": emoji, "color": color, "order": order};
}
}

View File

@@ -0,0 +1,26 @@
class Photo {
final String id;
final String url;
final String caption;
final bool active;
const Photo({
required this.id,
required this.url,
required this.caption,
required this.active,
});
factory Photo.fromJson(Map<String, dynamic> json) {
return Photo(
id: json["_id"] as String? ?? "",
url: json["url"] as String? ?? "",
caption: json["caption"] as String? ?? "",
active: json["active"] as bool? ?? true,
);
}
Map<String, dynamic> toJson() {
return {"url": url, "caption": caption, "active": active};
}
}

View File

@@ -0,0 +1,45 @@
class ScheduleItem {
final String id;
final String title;
final String description;
final DateTime startDate;
final DateTime endDate;
final String familyMemberId;
final bool isAllDay;
const ScheduleItem({
required this.id,
required this.title,
required this.description,
required this.startDate,
required this.endDate,
required this.familyMemberId,
required this.isAllDay,
});
factory ScheduleItem.fromJson(Map<String, dynamic> json) {
return ScheduleItem(
id: json["_id"] as String? ?? "",
title: json["title"] as String? ?? "",
description: json["description"] as String? ?? "",
startDate:
DateTime.tryParse(json["startDate"] as String? ?? "") ??
DateTime.now(),
endDate:
DateTime.tryParse(json["endDate"] as String? ?? "") ?? DateTime.now(),
familyMemberId: json["familyMemberId"] as String? ?? "",
isAllDay: json["isAllDay"] as bool? ?? false,
);
}
Map<String, dynamic> toJson() {
return {
"title": title,
"description": description,
"startDate": startDate.toIso8601String(),
"endDate": endDate.toIso8601String(),
"familyMemberId": familyMemberId,
"isAllDay": isAllDay,
};
}
}

View File

@@ -0,0 +1,36 @@
class TodoItem {
final String id;
final String familyMemberId;
final String title;
final bool completed;
final DateTime? dueDate;
const TodoItem({
required this.id,
required this.familyMemberId,
required this.title,
required this.completed,
required this.dueDate,
});
factory TodoItem.fromJson(Map<String, dynamic> json) {
return TodoItem(
id: json["_id"] as String? ?? "",
familyMemberId: json["familyMemberId"] as String? ?? "",
title: json["title"] as String? ?? "",
completed: json["completed"] as bool? ?? false,
dueDate: json["dueDate"] != null
? DateTime.tryParse(json["dueDate"] as String)
: null,
);
}
Map<String, dynamic> toJson() {
return {
"familyMemberId": familyMemberId,
"title": title,
"completed": completed,
"dueDate": dueDate?.toIso8601String(),
};
}
}

View File

@@ -0,0 +1,25 @@
class WeatherInfo {
final String description;
final double temperature;
final String icon;
final String city;
const WeatherInfo({
required this.description,
required this.temperature,
required this.icon,
required this.city,
});
factory WeatherInfo.fromJson(Map<String, dynamic> json) {
final weather = (json["weather"] as List<dynamic>? ?? []).isNotEmpty
? json["weather"][0] as Map<String, dynamic>
: {};
return WeatherInfo(
description: weather["description"] as String? ?? "",
temperature: (json["main"]?["temp"] as num?)?.toDouble() ?? 0,
icon: weather["icon"] as String? ?? "",
city: json["name"] as String? ?? "",
);
}
}

View File

@@ -0,0 +1,450 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/bible_verse.dart';
import '../../models/family_member.dart';
import '../../models/photo.dart';
import '../../services/bible_verse_service.dart';
import '../../services/family_service.dart';
import '../../services/photo_service.dart';
class AdminScreen extends StatefulWidget {
const AdminScreen({super.key});
@override
State<AdminScreen> createState() => _AdminScreenState();
}
class _AdminScreenState extends State<AdminScreen> {
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
title: const Text('Admin Settings'),
bottom: const TabBar(
tabs: [
Tab(text: 'Family Members'),
Tab(text: 'Photos'),
Tab(text: 'Bible Verses'),
],
),
),
body: const TabBarView(
children: [
FamilyManagerTab(),
PhotoManagerTab(),
BibleVerseManagerTab(),
],
),
),
);
}
}
class FamilyManagerTab extends StatefulWidget {
const FamilyManagerTab({super.key});
@override
State<FamilyManagerTab> createState() => _FamilyManagerTabState();
}
class _FamilyManagerTabState extends State<FamilyManagerTab> {
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddMemberDialog(context),
child: const Icon(Icons.add),
),
body: FutureBuilder<List<FamilyMember>>(
future: Provider.of<FamilyService>(context).fetchFamilyMembers(),
builder: (context, snapshot) {
if (!snapshot.hasData)
return const Center(child: CircularProgressIndicator());
final members = snapshot.data!;
return ListView.builder(
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;
}
return ListTile(
leading: CircleAvatar(
backgroundColor: memberColor,
child: Text(
member.emoji,
style: const TextStyle(fontSize: 20),
),
),
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(() {});
},
),
);
},
);
},
),
);
}
void _showAddMemberDialog(BuildContext context) {
final nameController = TextEditingController();
final emojiController = TextEditingController(text: '👤');
final colorController = TextEditingController(text: '0xFFFFD700');
final orderController = TextEditingController(text: '1');
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)',
),
),
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,
),
);
if (mounted) {
Navigator.pop(context);
setState(() {});
}
}
},
child: const Text('Add'),
),
],
),
);
}
}
class PhotoManagerTab extends StatefulWidget {
const PhotoManagerTab({super.key});
@override
State<PhotoManagerTab> createState() => _PhotoManagerTabState();
}
class _PhotoManagerTabState extends State<PhotoManagerTab> {
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddPhotoDialog(context),
child: const Icon(Icons.add_a_photo),
),
body: FutureBuilder<List<Photo>>(
future: Provider.of<PhotoService>(context).fetchPhotos(),
builder: (context, snapshot) {
if (!snapshot.hasData)
return const Center(child: CircularProgressIndicator());
final photos = snapshot.data!;
return GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: photos.length,
itemBuilder: (context, index) {
final photo = photos[index];
return GridTile(
footer: GridTileBar(
backgroundColor: Colors.black54,
title: Text(photo.caption),
trailing: IconButton(
icon: const Icon(Icons.delete, color: Colors.white),
onPressed: () async {
await Provider.of<PhotoService>(
context,
listen: false,
).deletePhoto(photo.id);
setState(() {});
},
),
),
child: Image.network(
photo.url,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
const Center(child: Icon(Icons.broken_image)),
),
);
},
);
},
),
);
}
void _showAddPhotoDialog(BuildContext context) {
final urlController = TextEditingController();
final captionController = TextEditingController();
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'),
),
TextField(
controller: captionController,
decoration: const InputDecoration(labelText: 'Caption'),
),
],
),
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'),
),
],
),
);
}
}
class BibleVerseManagerTab extends StatefulWidget {
const BibleVerseManagerTab({super.key});
@override
State<BibleVerseManagerTab> createState() => _BibleVerseManagerTabState();
}
class _BibleVerseManagerTabState extends State<BibleVerseManagerTab> {
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddVerseDialog(context),
child: const Icon(Icons.menu_book),
),
body: FutureBuilder<List<BibleVerse>>(
future: Provider.of<BibleVerseService>(context).fetchVerses(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final verses = snapshot.data!;
if (verses.isEmpty) {
return const Center(
child: Text(
'No verses added yet',
style: TextStyle(color: Colors.grey),
),
);
}
return ListView.builder(
itemCount: verses.length,
itemBuilder: (context, index) {
final verse = verses[index];
return ListTile(
title: Text(verse.reference),
subtitle: Text(
verse.text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (verse.date != null && verse.date!.isNotEmpty)
Text(
verse.date!,
style:
const TextStyle(fontSize: 12, color: Colors.grey),
),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () async {
await Provider.of<BibleVerseService>(
context,
listen: false,
).deleteVerse(verse.id);
setState(() {});
},
),
],
),
);
},
);
},
),
);
}
void _showAddVerseDialog(BuildContext context) {
final textController = TextEditingController();
final referenceController = TextEditingController();
final dateController = TextEditingController();
bool isActive = true;
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: const Text('Add Bible Verse'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: referenceController,
decoration: const InputDecoration(
labelText: 'Reference (e.g., Psalms 23:1)',
),
),
const SizedBox(height: 8),
TextField(
controller: textController,
decoration: const InputDecoration(
labelText: 'Verse Text (Korean)',
),
maxLines: 3,
),
const SizedBox(height: 8),
TextField(
controller: dateController,
decoration: const InputDecoration(
labelText: 'Date (YYYY-MM-DD) - Optional',
hintText: '2024-01-01',
),
),
const SizedBox(height: 8),
SwitchListTile(
title: const Text('Active'),
value: isActive,
onChanged: (value) {
setDialogState(() {
isActive = value;
});
},
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
if (textController.text.isNotEmpty &&
referenceController.text.isNotEmpty) {
await Provider.of<BibleVerseService>(
context,
listen: false,
).createVerse(
BibleVerse(
id: '',
text: textController.text,
reference: referenceController.text,
date: dateController.text.isEmpty
? null
: dateController.text,
active: isActive,
),
);
if (mounted) {
Navigator.pop(context);
setState(() {});
}
}
},
child: const Text('Add'),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,431 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../../models/todo_item.dart';
import '../../models/schedule_item.dart';
import '../../models/announcement.dart';
import '../../models/family_member.dart';
import '../../services/todo_service.dart';
import '../../services/schedule_service.dart';
import '../../services/announcement_service.dart';
import '../../services/family_service.dart';
class MobileHomeScreen extends StatefulWidget {
const MobileHomeScreen({super.key});
@override
State<MobileHomeScreen> createState() => _MobileHomeScreenState();
}
class _MobileHomeScreenState extends State<MobileHomeScreen> {
int _currentIndex = 0;
final List<Widget> _screens = [
const MobileTodoScreen(),
const MobileScheduleScreen(),
const MobileAnnouncementScreen(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Bini Family Manager'),
actions: [
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
Navigator.pushNamed(context, '/admin');
},
),
],
),
body: _screens[_currentIndex],
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (index) {
setState(() {
_currentIndex = index;
});
},
items: const [
BottomNavigationBarItem(icon: Icon(Icons.check_box), label: 'Todos'),
BottomNavigationBarItem(
icon: Icon(Icons.calendar_today),
label: 'Schedule',
),
BottomNavigationBarItem(icon: Icon(Icons.campaign), label: 'Notices'),
],
),
);
}
}
class MobileTodoScreen extends StatefulWidget {
const MobileTodoScreen({super.key});
@override
State<MobileTodoScreen> createState() => _MobileTodoScreenState();
}
class _MobileTodoScreenState extends State<MobileTodoScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddTodoDialog(context),
child: const Icon(Icons.add),
),
body: FutureBuilder<List<TodoItem>>(
future: Provider.of<TodoService>(
context,
).fetchTodos(), // Fetch all or today? Let's fetch all for manager
builder: (context, snapshot) {
if (!snapshot.hasData)
return const Center(child: CircularProgressIndicator());
final todos = snapshot.data!;
return ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
title: Text(todo.title),
subtitle: Text(
todo.dueDate != null
? DateFormat('MM/dd').format(todo.dueDate!)
: 'No date',
),
trailing: 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(() {});
},
),
onLongPress: () async {
await Provider.of<TodoService>(
context,
listen: false,
).deleteTodo(todo.id);
setState(() {});
},
);
},
);
},
),
);
}
void _showAddTodoDialog(BuildContext context) async {
final titleController = TextEditingController();
final familyMembers = await Provider.of<FamilyService>(
context,
listen: false,
).fetchFamilyMembers();
String? selectedMemberId =
familyMembers.isNotEmpty ? familyMembers.first.id : null;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Add Todo'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: titleController,
decoration: const InputDecoration(labelText: 'Task'),
),
DropdownButtonFormField<String>(
value: selectedMemberId,
items: familyMembers
.map(
(m) => DropdownMenuItem(value: m.id, child: Text(m.name)),
)
.toList(),
onChanged: (val) => selectedMemberId = val,
decoration: const InputDecoration(labelText: 'Assign to'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
if (selectedMemberId != null && titleController.text.isNotEmpty) {
await Provider.of<TodoService>(
context,
listen: false,
).createTodo(
TodoItem(
id: '',
familyMemberId: selectedMemberId!,
title: titleController.text,
completed: false,
dueDate: DateTime.now(),
),
);
if (mounted) {
Navigator.pop(context);
setState(() {});
}
}
},
child: const Text('Add'),
),
],
),
);
}
}
class MobileScheduleScreen extends StatefulWidget {
const MobileScheduleScreen({super.key});
@override
State<MobileScheduleScreen> createState() => _MobileScheduleScreenState();
}
class _MobileScheduleScreenState extends State<MobileScheduleScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddScheduleDialog(context),
child: const Icon(Icons.add),
),
body: FutureBuilder<List<ScheduleItem>>(
future: Provider.of<ScheduleService>(context).fetchSchedules(),
builder: (context, snapshot) {
if (!snapshot.hasData)
return const Center(child: CircularProgressIndicator());
final schedules = snapshot.data!;
return ListView.builder(
itemCount: schedules.length,
itemBuilder: (context, index) {
final item = schedules[index];
return ListTile(
title: Text(item.title),
subtitle: Text(
'${DateFormat('MM/dd HH:mm').format(item.startDate)} - ${item.description}',
),
onLongPress: () async {
await Provider.of<ScheduleService>(
context,
listen: false,
).deleteSchedule(item.id);
setState(() {});
},
);
},
);
},
),
);
}
void _showAddScheduleDialog(BuildContext context) async {
final titleController = TextEditingController();
final descController = TextEditingController();
final familyMembers = await Provider.of<FamilyService>(
context,
listen: false,
).fetchFamilyMembers();
String? selectedMemberId =
familyMembers.isNotEmpty ? familyMembers.first.id : null;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Add Schedule'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: titleController,
decoration: const InputDecoration(labelText: 'Title'),
),
TextField(
controller: descController,
decoration: const InputDecoration(labelText: 'Description'),
),
DropdownButtonFormField<String>(
value: selectedMemberId,
items: familyMembers
.map(
(m) => DropdownMenuItem(value: m.id, child: Text(m.name)),
)
.toList(),
onChanged: (val) => selectedMemberId = val,
decoration: const InputDecoration(labelText: 'For whom?'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
if (selectedMemberId != null && titleController.text.isNotEmpty) {
await Provider.of<ScheduleService>(
context,
listen: false,
).createSchedule(
ScheduleItem(
id: '',
title: titleController.text,
description: descController.text,
startDate: DateTime.now(),
endDate: DateTime.now().add(const Duration(hours: 1)),
familyMemberId: selectedMemberId!,
isAllDay: false,
),
);
if (mounted) {
Navigator.pop(context);
setState(() {});
}
}
},
child: const Text('Add'),
),
],
),
);
}
}
class MobileAnnouncementScreen extends StatefulWidget {
const MobileAnnouncementScreen({super.key});
@override
State<MobileAnnouncementScreen> createState() =>
_MobileAnnouncementScreenState();
}
class _MobileAnnouncementScreenState extends State<MobileAnnouncementScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddAnnouncementDialog(context),
child: const Icon(Icons.add),
),
body: FutureBuilder<List<Announcement>>(
future: Provider.of<AnnouncementService>(context).fetchAnnouncements(),
builder: (context, snapshot) {
if (!snapshot.hasData)
return const Center(child: CircularProgressIndicator());
final items = snapshot.data!;
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return ListTile(
title: Text(item.title),
subtitle: Text(item.content),
trailing: Switch(
value: item.active,
onChanged: (val) async {
await Provider.of<AnnouncementService>(
context,
listen: false,
).updateAnnouncement(
Announcement(
id: item.id,
title: item.title,
content: item.content,
priority: item.priority,
active: val,
),
);
setState(() {});
},
),
onLongPress: () async {
await Provider.of<AnnouncementService>(
context,
listen: false,
).deleteAnnouncement(item.id);
setState(() {});
},
);
},
);
},
),
);
}
void _showAddAnnouncementDialog(BuildContext context) {
final titleController = TextEditingController();
final contentController = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Add Announcement'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: titleController,
decoration: const InputDecoration(labelText: 'Title'),
),
TextField(
controller: contentController,
decoration: const InputDecoration(labelText: 'Content'),
maxLines: 3,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
if (titleController.text.isNotEmpty) {
await Provider.of<AnnouncementService>(
context,
listen: false,
).createAnnouncement(
Announcement(
id: '',
title: titleController.text,
content: contentController.text,
priority: 1,
active: true,
),
);
if (mounted) {
Navigator.pop(context);
setState(() {});
}
}
},
child: const Text('Add'),
),
],
),
);
}
}

View File

@@ -0,0 +1,133 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../widgets/digital_clock_widget.dart';
import '../../widgets/weather_widget.dart';
import '../../widgets/calendar_widget.dart';
import '../../widgets/schedule_list_widget.dart';
import '../../widgets/announcement_widget.dart';
import '../../widgets/photo_slideshow_widget.dart';
import '../../widgets/todo_list_widget.dart';
import '../../widgets/bible_verse_widget.dart';
class TvDashboardScreen extends StatefulWidget {
const TvDashboardScreen({super.key});
@override
State<TvDashboardScreen> createState() => _TvDashboardScreenState();
}
class _TvDashboardScreenState extends State<TvDashboardScreen> {
// Timer for periodic refresh (every 5 minutes for data, 1 second for clock)
Timer? _dataRefreshTimer;
@override
void initState() {
super.initState();
// Initial data fetch could be triggered here or within widgets
_startDataRefresh();
}
void _startDataRefresh() {
_dataRefreshTimer = Timer.periodic(const Duration(minutes: 5), (timer) {
// Trigger refreshes if needed, or let widgets handle their own polling
// For simplicity, we assume widgets or providers handle their data
setState(() {}); // Rebuild to refresh UI state if needed
});
}
@override
void dispose() {
_dataRefreshTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
// 1920x1080 reference
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(32.0), // Outer margin safe zone
child: Column(
children: [
// Header: Time and Weather
SizedBox(
height: 100,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [const DigitalClockWidget(), const WeatherWidget()],
),
),
const SizedBox(height: 24),
// Main Content Grid
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Left Column: Calendar, Schedule, Announcement
Expanded(
flex: 3,
child: Column(
children: [
const Expanded(flex: 4, child: CalendarWidget()),
const SizedBox(height: 16),
const Expanded(flex: 4, child: ScheduleListWidget()),
const SizedBox(height: 16),
const Expanded(flex: 2, child: AnnouncementWidget()),
],
),
),
const SizedBox(width: 24),
// Center Column: Photo Slideshow
Expanded(
flex: 4,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.5),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
clipBehavior: Clip.antiAlias,
child: const PhotoSlideshowWidget(),
),
),
const SizedBox(width: 24),
// Right Column: Todos, Bible Verse
Expanded(
flex: 3,
child: Column(
children: [
const Expanded(flex: 6, child: TodoListWidget()),
const SizedBox(height: 16),
const Expanded(flex: 3, child: BibleVerseWidget()),
],
),
),
],
),
),
// Hidden trigger for admin/mobile view (e.g. long press corner)
GestureDetector(
onLongPress: () {
Navigator.of(context).pushNamed('/admin');
},
child: Container(
width: 50,
height: 50,
color: Colors.transparent,
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,71 @@
import "../config/api_config.dart";
import "../models/announcement.dart";
import "api_client.dart";
import "mock_data.dart";
class AnnouncementService {
final ApiClient _client;
AnnouncementService(this._client);
Future<List<Announcement>> fetchAnnouncements({
bool activeOnly = false,
}) async {
if (ApiConfig.useMockData) {
final items = List<Announcement>.from(MockDataStore.announcements);
if (activeOnly) {
return items.where((item) => item.active).toList();
}
return items;
}
final query = activeOnly ? {"active": "true"} : null;
final data = await _client.getList(ApiConfig.announcements, query: query);
return data
.map((item) => Announcement.fromJson(item as Map<String, dynamic>))
.toList();
}
Future<Announcement> createAnnouncement(Announcement announcement) async {
if (ApiConfig.useMockData) {
final created = Announcement(
id: "announcement-${DateTime.now().millisecondsSinceEpoch}",
title: announcement.title,
content: announcement.content,
priority: announcement.priority,
active: announcement.active,
);
MockDataStore.announcements.add(created);
return created;
}
final data = await _client.post(
ApiConfig.announcements,
announcement.toJson(),
);
return Announcement.fromJson(data);
}
Future<Announcement> updateAnnouncement(Announcement announcement) async {
if (ApiConfig.useMockData) {
final index = MockDataStore.announcements.indexWhere(
(item) => item.id == announcement.id,
);
if (index != -1) {
MockDataStore.announcements[index] = announcement;
}
return announcement;
}
final data = await _client.put(
"${ApiConfig.announcements}/${announcement.id}",
announcement.toJson(),
);
return Announcement.fromJson(data);
}
Future<void> deleteAnnouncement(String id) async {
if (ApiConfig.useMockData) {
MockDataStore.announcements.removeWhere((item) => item.id == id);
return;
}
await _client.delete("${ApiConfig.announcements}/$id");
}
}

View File

@@ -0,0 +1,71 @@
import "dart:convert";
import "package:http/http.dart" as http;
import "../config/api_config.dart";
class ApiClient {
final http.Client _client;
ApiClient({http.Client? client}) : _client = client ?? http.Client();
Uri _uri(String path, [Map<String, dynamic>? query]) {
return Uri.parse(ApiConfig.baseUrl).replace(
path: path,
queryParameters: query?.map((key, value) => MapEntry(key, "$value")),
);
}
Future<List<dynamic>> getList(
String path, {
Map<String, dynamic>? query,
}) async {
final response = await _client.get(_uri(path, query));
_ensureSuccess(response);
return jsonDecode(response.body) as List<dynamic>;
}
Future<Map<String, dynamic>> getMap(
String path, {
Map<String, dynamic>? query,
}) async {
final response = await _client.get(_uri(path, query));
_ensureSuccess(response);
return jsonDecode(response.body) as Map<String, dynamic>;
}
Future<Map<String, dynamic>> post(
String path,
Map<String, dynamic> body,
) async {
final response = await _client.post(
_uri(path),
headers: {"Content-Type": "application/json"},
body: jsonEncode(body),
);
_ensureSuccess(response);
return jsonDecode(response.body) as Map<String, dynamic>;
}
Future<Map<String, dynamic>> put(
String path,
Map<String, dynamic> body,
) async {
final response = await _client.put(
_uri(path),
headers: {"Content-Type": "application/json"},
body: jsonEncode(body),
);
_ensureSuccess(response);
return jsonDecode(response.body) as Map<String, dynamic>;
}
Future<void> delete(String path) async {
final response = await _client.delete(_uri(path));
_ensureSuccess(response);
}
void _ensureSuccess(http.Response response) {
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception("Request failed: ${response.statusCode}");
}
}
}

View File

@@ -0,0 +1,26 @@
import "../config/api_config.dart";
import "../models/bible_verse.dart";
import "api_client.dart";
import "mock_data.dart";
class BibleService {
final ApiClient _client;
BibleService(this._client);
Future<BibleVerse> fetchTodayVerse({String? date}) async {
if (ApiConfig.useMockData) {
final verses = MockDataStore.bibleVerses;
if (verses.isEmpty) {
return MockDataStore.bible;
}
verses.shuffle();
return verses.first;
}
final data = await _client.getMap(
ApiConfig.bibleToday,
query: date == null ? null : {"date": date},
);
return BibleVerse.fromJson(data);
}
}

View File

@@ -0,0 +1,66 @@
import "../config/api_config.dart";
import "../models/bible_verse.dart";
import "api_client.dart";
import "mock_data.dart";
class BibleVerseService {
final ApiClient _client;
BibleVerseService(this._client);
Future<List<BibleVerse>> fetchVerses({bool activeOnly = false}) async {
if (ApiConfig.useMockData) {
final items = List<BibleVerse>.from(MockDataStore.bibleVerses);
if (activeOnly) {
return items.where((item) => item.active).toList();
}
return items;
}
final query = activeOnly ? {"active": "true"} : null;
final data = await _client.getList(ApiConfig.bibleVerses, query: query);
return data
.map((item) => BibleVerse.fromJson(item as Map<String, dynamic>))
.toList();
}
Future<BibleVerse> createVerse(BibleVerse verse) async {
if (ApiConfig.useMockData) {
final created = BibleVerse(
id: "bible-${DateTime.now().millisecondsSinceEpoch}",
text: verse.text,
reference: verse.reference,
date: verse.date,
active: verse.active,
);
MockDataStore.bibleVerses.add(created);
return created;
}
final data = await _client.post(ApiConfig.bibleVerses, verse.toJson());
return BibleVerse.fromJson(data);
}
Future<BibleVerse> updateVerse(BibleVerse verse) async {
if (ApiConfig.useMockData) {
final index = MockDataStore.bibleVerses.indexWhere(
(item) => item.id == verse.id,
);
if (index != -1) {
MockDataStore.bibleVerses[index] = verse;
}
return verse;
}
final data = await _client.put(
"${ApiConfig.bibleVerses}/${verse.id}",
verse.toJson(),
);
return BibleVerse.fromJson(data);
}
Future<void> deleteVerse(String id) async {
if (ApiConfig.useMockData) {
MockDataStore.bibleVerses.removeWhere((item) => item.id == id);
return;
}
await _client.delete("${ApiConfig.bibleVerses}/$id");
}
}

View File

@@ -0,0 +1,71 @@
import "../config/api_config.dart";
import "../models/family_member.dart";
import "api_client.dart";
import "mock_data.dart";
class FamilyService {
final ApiClient _client;
FamilyService(this._client);
Future<List<FamilyMember>> fetchFamilyMembers() async {
if (ApiConfig.useMockData) {
return List<FamilyMember>.from(MockDataStore.familyMembers);
}
final data = await _client.getList(ApiConfig.family);
return data
.map((item) => FamilyMember.fromJson(item as Map<String, dynamic>))
.toList();
}
Future<FamilyMember> createFamilyMember(FamilyMember member) async {
if (ApiConfig.useMockData) {
final created = FamilyMember(
id: "family-${DateTime.now().millisecondsSinceEpoch}",
name: member.name,
emoji: member.emoji,
color: member.color,
order: member.order,
);
MockDataStore.familyMembers.add(created);
return created;
}
final data = await _client.post(ApiConfig.family, member.toJson());
return FamilyMember.fromJson(data);
}
Future<FamilyMember> updateFamilyMember(FamilyMember member) async {
if (ApiConfig.useMockData) {
final index = MockDataStore.familyMembers.indexWhere(
(item) => item.id == member.id,
);
if (index != -1) {
MockDataStore.familyMembers[index] = member;
}
return member;
}
final data = await _client.put(
"${ApiConfig.family}/${member.id}",
member.toJson(),
);
return FamilyMember.fromJson(data);
}
Future<void> deleteFamilyMember(String id) async {
if (ApiConfig.useMockData) {
MockDataStore.familyMembers.removeWhere((item) => item.id == id);
return;
}
await _client.delete("${ApiConfig.family}/$id");
}
Future<List<FamilyMember>> fetchMembers() => fetchFamilyMembers();
Future<FamilyMember> createMember(FamilyMember member) =>
createFamilyMember(member);
Future<FamilyMember> updateMember(FamilyMember member) =>
updateFamilyMember(member);
Future<void> deleteMember(String id) => deleteFamilyMember(id);
}

View File

@@ -0,0 +1,149 @@
import "../models/announcement.dart";
import "../models/bible_verse.dart";
import "../models/family_member.dart";
import "../models/photo.dart";
import "../models/schedule_item.dart";
import "../models/todo_item.dart";
import "../models/weather_info.dart";
class MockDataStore {
static final List<FamilyMember> familyMembers = [
const FamilyMember(
id: "family-1",
name: "Dad",
emoji: ":)",
color: "#0F766E",
order: 1,
),
const FamilyMember(
id: "family-2",
name: "Mom",
emoji: "<3",
color: "#C2410C",
order: 2,
),
const FamilyMember(
id: "family-3",
name: "Son",
emoji: ":D",
color: "#1D4ED8",
order: 3,
),
const FamilyMember(
id: "family-4",
name: "Daughter",
emoji: ":-)",
color: "#7C3AED",
order: 4,
),
];
static final List<TodoItem> todos = [
TodoItem(
id: "todo-1",
familyMemberId: "family-1",
title: "Grocery run",
completed: false,
dueDate: DateTime.now(),
),
TodoItem(
id: "todo-2",
familyMemberId: "family-2",
title: "Team meeting",
completed: false,
dueDate: DateTime.now(),
),
TodoItem(
id: "todo-3",
familyMemberId: "family-3",
title: "Math homework",
completed: false,
dueDate: DateTime.now(),
),
TodoItem(
id: "todo-4",
familyMemberId: "family-4",
title: "Piano lesson",
completed: false,
dueDate: DateTime.now().add(const Duration(days: 1)),
),
];
static final List<ScheduleItem> schedules = [
ScheduleItem(
id: "schedule-1",
title: "Family dinner",
description: "Everyone at home",
startDate: DateTime.now(),
endDate: DateTime.now().add(const Duration(hours: 2)),
familyMemberId: "family-1",
isAllDay: false,
),
ScheduleItem(
id: "schedule-2",
title: "Soccer practice",
description: "School field",
startDate: DateTime.now().add(const Duration(hours: 3)),
endDate: DateTime.now().add(const Duration(hours: 4)),
familyMemberId: "family-3",
isAllDay: false,
),
];
static final List<Announcement> announcements = [
const Announcement(
id: "announcement-1",
title: "Weekend trip",
content: "Pack light and be ready by 8 AM",
priority: 2,
active: true,
),
const Announcement(
id: "announcement-2",
title: "Trash day",
content: "Take out bins tonight",
priority: 1,
active: true,
),
];
static final List<Photo> photos = [
const Photo(
id: "photo-1",
url: "https://picsum.photos/1200/800?random=21",
caption: "Summer vacation",
active: true,
),
const Photo(
id: "photo-2",
url: "https://picsum.photos/1200/800?random=22",
caption: "Family hike",
active: true,
),
const Photo(
id: "photo-3",
url: "https://picsum.photos/1200/800?random=23",
caption: "Birthday party",
active: true,
),
];
static WeatherInfo weather = const WeatherInfo(
description: "clear sky",
temperature: 12,
icon: "01d",
city: "Seoul",
);
static final List<BibleVerse> bibleVerses = [
const BibleVerse(
id: "bible-1",
text: "여호와를 경외하는 것이 지식의 근본이니라.",
reference: "잠언 1:7",
date: null,
active: true,
),
];
static BibleVerse bible = bibleVerses.first;
}

View File

@@ -0,0 +1,48 @@
import "../config/api_config.dart";
import "../models/photo.dart";
import "api_client.dart";
import "mock_data.dart";
class PhotoService {
final ApiClient _client;
PhotoService(this._client);
Future<List<Photo>> fetchPhotos({bool activeOnly = false}) async {
if (ApiConfig.useMockData) {
final items = List<Photo>.from(MockDataStore.photos);
if (activeOnly) {
return items.where((item) => item.active).toList();
}
return items;
}
final query = activeOnly ? {"active": "true"} : null;
final data = await _client.getList(ApiConfig.photos, query: query);
return data
.map((item) => Photo.fromJson(item as Map<String, dynamic>))
.toList();
}
Future<Photo> createPhoto(Photo photo) async {
if (ApiConfig.useMockData) {
final created = Photo(
id: "photo-${DateTime.now().millisecondsSinceEpoch}",
url: photo.url,
caption: photo.caption,
active: photo.active,
);
MockDataStore.photos.add(created);
return created;
}
final data = await _client.post(ApiConfig.photos, photo.toJson());
return Photo.fromJson(data);
}
Future<void> deletePhoto(String id) async {
if (ApiConfig.useMockData) {
MockDataStore.photos.removeWhere((item) => item.id == id);
return;
}
await _client.delete("${ApiConfig.photos}/$id");
}
}

View File

@@ -0,0 +1,83 @@
import "../config/api_config.dart";
import "../models/schedule_item.dart";
import "api_client.dart";
import "mock_data.dart";
class ScheduleService {
final ApiClient _client;
ScheduleService(this._client);
Future<List<ScheduleItem>> fetchSchedules() async {
if (ApiConfig.useMockData) {
return List<ScheduleItem>.from(MockDataStore.schedules);
}
final data = await _client.getList(ApiConfig.schedules);
return data
.map((item) => ScheduleItem.fromJson(item as Map<String, dynamic>))
.toList();
}
Future<List<ScheduleItem>> fetchWeeklySchedules() async {
if (ApiConfig.useMockData) {
return List<ScheduleItem>.from(MockDataStore.schedules);
}
final data = await _client.getList("${ApiConfig.schedules}/week");
return data
.map((item) => ScheduleItem.fromJson(item as Map<String, dynamic>))
.toList();
}
Future<List<ScheduleItem>> fetchMonthlySchedules() async {
if (ApiConfig.useMockData) {
return List<ScheduleItem>.from(MockDataStore.schedules);
}
final data = await _client.getList("${ApiConfig.schedules}/month");
return data
.map((item) => ScheduleItem.fromJson(item as Map<String, dynamic>))
.toList();
}
Future<ScheduleItem> createSchedule(ScheduleItem schedule) async {
if (ApiConfig.useMockData) {
final created = ScheduleItem(
id: "schedule-${DateTime.now().millisecondsSinceEpoch}",
title: schedule.title,
description: schedule.description,
startDate: schedule.startDate,
endDate: schedule.endDate,
familyMemberId: schedule.familyMemberId,
isAllDay: schedule.isAllDay,
);
MockDataStore.schedules.add(created);
return created;
}
final data = await _client.post(ApiConfig.schedules, schedule.toJson());
return ScheduleItem.fromJson(data);
}
Future<ScheduleItem> updateSchedule(ScheduleItem schedule) async {
if (ApiConfig.useMockData) {
final index = MockDataStore.schedules.indexWhere(
(item) => item.id == schedule.id,
);
if (index != -1) {
MockDataStore.schedules[index] = schedule;
}
return schedule;
}
final data = await _client.put(
"${ApiConfig.schedules}/${schedule.id}",
schedule.toJson(),
);
return ScheduleItem.fromJson(data);
}
Future<void> deleteSchedule(String id) async {
if (ApiConfig.useMockData) {
MockDataStore.schedules.removeWhere((item) => item.id == id);
return;
}
await _client.delete("${ApiConfig.schedules}/$id");
}
}

View File

@@ -0,0 +1,79 @@
import "../config/api_config.dart";
import "../models/todo_item.dart";
import "api_client.dart";
import "mock_data.dart";
class TodoService {
final ApiClient _client;
TodoService(this._client);
Future<List<TodoItem>> fetchTodos() async {
if (ApiConfig.useMockData) {
return List<TodoItem>.from(MockDataStore.todos);
}
final data = await _client.getList(ApiConfig.todos);
return data
.map((item) => TodoItem.fromJson(item as Map<String, dynamic>))
.toList();
}
Future<List<TodoItem>> fetchTodayTodos() async {
if (ApiConfig.useMockData) {
final today = DateTime.now();
return MockDataStore.todos.where((todo) => todo.dueDate != null).where((
todo,
) {
final date = todo.dueDate!;
return date.year == today.year &&
date.month == today.month &&
date.day == today.day;
}).toList();
}
final data = await _client.getList("${ApiConfig.todos}/today");
return data
.map((item) => TodoItem.fromJson(item as Map<String, dynamic>))
.toList();
}
Future<TodoItem> createTodo(TodoItem todo) async {
if (ApiConfig.useMockData) {
final created = TodoItem(
id: "todo-${DateTime.now().millisecondsSinceEpoch}",
familyMemberId: todo.familyMemberId,
title: todo.title,
completed: todo.completed,
dueDate: todo.dueDate,
);
MockDataStore.todos.add(created);
return created;
}
final data = await _client.post(ApiConfig.todos, todo.toJson());
return TodoItem.fromJson(data);
}
Future<TodoItem> updateTodo(TodoItem todo) async {
if (ApiConfig.useMockData) {
final index = MockDataStore.todos.indexWhere(
(item) => item.id == todo.id,
);
if (index != -1) {
MockDataStore.todos[index] = todo;
}
return todo;
}
final data = await _client.put(
"${ApiConfig.todos}/${todo.id}",
todo.toJson(),
);
return TodoItem.fromJson(data);
}
Future<void> deleteTodo(String id) async {
if (ApiConfig.useMockData) {
MockDataStore.todos.removeWhere((item) => item.id == id);
return;
}
await _client.delete("${ApiConfig.todos}/$id");
}
}

View File

@@ -0,0 +1,19 @@
import "../config/api_config.dart";
import "../models/weather_info.dart";
import "api_client.dart";
import "mock_data.dart";
class WeatherService {
final ApiClient _client;
WeatherService(this._client);
Future<WeatherInfo> fetchWeather({String? city}) async {
if (ApiConfig.useMockData) {
return MockDataStore.weather;
}
final query = city != null ? {"q": city} : null;
final data = await _client.getMap(ApiConfig.weather, query: query);
return WeatherInfo.fromJson(data);
}
}

View File

@@ -0,0 +1,169 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/announcement.dart';
import '../services/announcement_service.dart';
class AnnouncementWidget extends StatefulWidget {
const AnnouncementWidget({super.key});
@override
State<AnnouncementWidget> createState() => _AnnouncementWidgetState();
}
class _AnnouncementWidgetState extends State<AnnouncementWidget> {
final PageController _pageController = PageController();
Timer? _timer;
int _currentPage = 0;
List<Announcement> _announcements = [];
@override
void initState() {
super.initState();
_fetchAnnouncements();
}
void _fetchAnnouncements() async {
try {
final data = await Provider.of<AnnouncementService>(
context,
listen: false,
).fetchAnnouncements(activeOnly: true);
if (mounted) {
setState(() {
_announcements = data;
});
_startAutoScroll();
}
} catch (e) {
// Handle error
}
}
void _startAutoScroll() {
_timer?.cancel();
if (_announcements.length > 1) {
_timer = Timer.periodic(const Duration(seconds: 10), (timer) {
if (_pageController.hasClients) {
_currentPage++;
if (_currentPage >= _announcements.length) {
_currentPage = 0;
}
_pageController.animateToPage(
_currentPage,
duration: const Duration(milliseconds: 800),
curve: Curves.easeInOut,
);
}
});
}
}
@override
void dispose() {
_timer?.cancel();
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_announcements.isEmpty) {
// Show default placeholder if no announcements
return Container(
decoration: BoxDecoration(
color: Theme.of(context).cardTheme.color,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white10),
),
padding: const EdgeInsets.all(16),
child: const Center(
child: Text(
'Welcome Home! Have a great day.',
style: TextStyle(color: Colors.white70, fontSize: 18),
),
),
);
}
return Container(
decoration: BoxDecoration(
color: Theme.of(
context,
).cardTheme.color, // Slightly lighter/distinct background
borderRadius: BorderRadius.circular(16),
),
child: Stack(
children: [
PageView.builder(
controller: _pageController,
itemCount: _announcements.length,
itemBuilder: (context, index) {
final item = _announcements[index];
return Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.campaign,
color: Theme.of(context).colorScheme.secondary,
size: 32,
),
const SizedBox(width: 12),
Text(
item.title,
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(
color: Theme.of(context).colorScheme.secondary,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
Text(
item.content,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: Colors.white),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
),
);
},
),
// Page Indicator
if (_announcements.length > 1)
Positioned(
bottom: 16,
right: 16,
child: Row(
children: List.generate(_announcements.length, (index) {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
margin: const EdgeInsets.symmetric(horizontal: 4),
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _currentPage == index
? Theme.of(context).colorScheme.secondary
: Colors.white24,
),
);
}),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/bible_verse.dart';
import '../services/bible_service.dart';
class BibleVerseWidget extends StatelessWidget {
const BibleVerseWidget({super.key});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Theme.of(context).cardTheme.color!.withOpacity(0.8),
Theme.of(context).cardTheme.color!,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white10),
),
padding: const EdgeInsets.all(24),
child: FutureBuilder<BibleVerse>(
future: Provider.of<BibleService>(
context,
listen: false,
).fetchTodayVerse(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return const Center(
child: Text(
'Verse Unavailable',
style: TextStyle(color: Colors.white54),
),
);
}
if (!snapshot.hasData) {
return const SizedBox.shrink();
}
final verse = snapshot.data!;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.format_quote,
color: Color(0xFFBB86FC),
size: 32,
),
const SizedBox(height: 12),
Text(
verse.text,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Colors.white,
height: 1.5,
fontStyle: FontStyle.italic,
),
),
const SizedBox(height: 8),
Text(
verse.reference,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
);
},
),
);
}
}

View File

@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class CalendarWidget extends StatelessWidget {
const CalendarWidget({super.key});
@override
Widget build(BuildContext context) {
final now = DateTime.now();
final firstDayOfMonth = DateTime(now.year, now.month, 1);
final lastDayOfMonth = DateTime(now.year, now.month + 1, 0);
final daysInMonth = lastDayOfMonth.day;
final startingWeekday = firstDayOfMonth.weekday; // Mon=1, Sun=7
// Simple calendar logic
// We need to pad the beginning with empty slots
// If week starts on Sunday, adjust accordingly. Let's assume Mon start for now or use locale.
// Let's assume standard Sun-Sat or Mon-Sun. Let's go with Sun-Sat for standard calendar view often seen in KR/US.
// DateTime.weekday: Mon=1, Sun=7.
// If we want Sun start: Sun=0, Mon=1...
// Let's adjust so Sunday is first.
int offset =
startingWeekday %
7; // If startingWeekday is 7 (Sun), offset is 0. If 1 (Mon), offset is 1.
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).cardTheme.color,
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
DateFormat('MMMM yyyy').format(now),
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const Icon(Icons.calendar_today, color: Colors.white54),
],
),
const SizedBox(height: 16),
// Days Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: ['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day) {
return Expanded(
child: Center(
child: Text(
day,
style: const TextStyle(
color: Colors.white54,
fontWeight: FontWeight.bold,
),
),
),
);
}).toList(),
),
const SizedBox(height: 8),
// Days Grid
Expanded(
child: GridView.builder(
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 7,
childAspectRatio: 1.0,
),
itemCount: 42, // 6 rows max to be safe
itemBuilder: (context, index) {
final dayNumber = index - offset + 1;
if (dayNumber < 1 || dayNumber > daysInMonth) {
return const SizedBox.shrink();
}
final isToday = dayNumber == now.day;
return Container(
margin: const EdgeInsets.all(4),
decoration: isToday
? BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
)
: null,
child: Center(
child: Text(
'$dayNumber',
style: TextStyle(
color: isToday ? Colors.black : Colors.white,
fontWeight: isToday
? FontWeight.bold
: FontWeight.normal,
),
),
),
);
},
),
),
],
),
);
}
}

View File

@@ -0,0 +1,53 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class DigitalClockWidget extends StatefulWidget {
const DigitalClockWidget({super.key});
@override
State<DigitalClockWidget> createState() => _DigitalClockWidgetState();
}
class _DigitalClockWidgetState extends State<DigitalClockWidget> {
DateTime _now = DateTime.now();
Timer? _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
setState(() {
_now = DateTime.now();
});
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Format: 2026.01.24 (Sat) 15:43:36
final dateStr = DateFormat('yyyy.MM.dd (E)', 'ko_KR').format(_now);
final timeStr = DateFormat('HH:mm:ss').format(_now);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'$dateStr $timeStr',
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
],
);
}
}

View File

@@ -0,0 +1,144 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/photo.dart';
import '../services/photo_service.dart';
class PhotoSlideshowWidget extends StatefulWidget {
const PhotoSlideshowWidget({super.key});
@override
State<PhotoSlideshowWidget> createState() => _PhotoSlideshowWidgetState();
}
class _PhotoSlideshowWidgetState extends State<PhotoSlideshowWidget> {
List<Photo> _photos = [];
int _currentIndex = 0;
Timer? _timer;
@override
void initState() {
super.initState();
_fetchPhotos();
}
void _fetchPhotos() async {
try {
final photos = await Provider.of<PhotoService>(
context,
listen: false,
).fetchPhotos(activeOnly: true);
if (mounted) {
setState(() {
_photos = photos;
});
_startSlideshow();
}
} catch (e) {
// Handle error
}
}
void _startSlideshow() {
_timer?.cancel();
if (_photos.length > 1) {
_timer = Timer.periodic(const Duration(seconds: 30), (timer) {
if (mounted) {
setState(() {
_currentIndex = (_currentIndex + 1) % _photos.length;
});
}
});
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_photos.isEmpty) {
return Container(
color: Colors.black,
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.photo_library, size: 64, color: Colors.white24),
SizedBox(height: 16),
Text(
'No Photos Available',
style: TextStyle(color: Colors.white54, fontSize: 24),
),
],
),
),
);
}
final currentPhoto = _photos[_currentIndex];
return Stack(
fit: StackFit.expand,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 1000),
child: Image.network(
currentPhoto.url,
key: ValueKey<String>(currentPhoto.id),
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
errorBuilder: (context, error, stackTrace) {
return Container(
color: Colors.grey[900],
child: const Center(
child: Icon(
Icons.broken_image,
color: Colors.white54,
size: 48,
),
),
);
},
),
),
// Gradient overlay for caption
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black87],
),
),
padding: const EdgeInsets.all(24.0),
child: Text(
currentPhoto.caption,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w500,
shadows: [
Shadow(
color: Colors.black45,
blurRadius: 4,
offset: Offset(1, 1),
),
],
),
textAlign: TextAlign.center,
),
),
),
],
);
}
}

View File

@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../models/schedule_item.dart';
import '../services/schedule_service.dart';
class ScheduleListWidget extends StatelessWidget {
const ScheduleListWidget({super.key});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).cardTheme.color,
borderRadius: BorderRadius.circular(16),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Weekly Schedule',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Expanded(
child: FutureBuilder<List<ScheduleItem>>(
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) {
return const Center(
child: Text(
'No schedules this week',
style: TextStyle(color: Colors.white54),
),
);
}
final schedules = snapshot.data!;
// Sort by date
schedules.sort((a, b) => a.startDate.compareTo(b.startDate));
return ListView.separated(
itemCount: schedules.length,
separatorBuilder: (context, index) =>
const Divider(color: Colors.white10),
itemBuilder: (context, index) {
final item = schedules[index];
final dateStr = DateFormat(
'E, MMM d',
).format(item.startDate);
final timeStr = item.isAllDay
? 'All Day'
: DateFormat('HH:mm').format(item.startDate);
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.white10,
borderRadius: BorderRadius.circular(8),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(
DateFormat('d').format(item.startDate),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
Text(
DateFormat('E').format(item.startDate),
style: const TextStyle(
color: Colors.white70,
fontSize: 12,
),
),
],
),
),
title: Text(
item.title,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
subtitle: Text(
timeStr,
style: const TextStyle(color: Colors.white54),
),
);
},
);
},
),
),
],
),
);
}
}

View File

@@ -0,0 +1,174 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/todo_item.dart';
import '../models/family_member.dart';
import '../services/todo_service.dart';
import '../services/family_service.dart';
class TodoListWidget extends StatelessWidget {
const TodoListWidget({super.key});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).cardTheme.color,
borderRadius: BorderRadius.circular(16),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Today's Todos",
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
Icon(
Icons.check_circle_outline,
color: Theme.of(context).colorScheme.secondary,
),
],
),
const SizedBox(height: 12),
Expanded(
child: FutureBuilder<List<dynamic>>(
future: Future.wait([
Provider.of<TodoService>(
context,
listen: false,
).fetchTodayTodos(),
Provider.of<FamilyService>(
context,
listen: false,
).fetchFamilyMembers(),
]),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return const Center(
child: Text(
'Failed to load todos',
style: TextStyle(color: Colors.white54),
),
);
}
if (!snapshot.hasData) {
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(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.thumb_up, color: Colors.white24, size: 32),
SizedBox(height: 8),
Text(
'All done for today!',
style: TextStyle(color: Colors.white54),
),
],
),
);
}
return ListView.separated(
itemCount: todos.length,
separatorBuilder: (context, index) =>
const Divider(color: Colors.white10),
itemBuilder: (context, index) {
final todo = todos[index];
final member = members.firstWhere(
(m) => m.id == todo.familyMemberId,
orElse: () => const FamilyMember(
id: '',
name: 'Unknown',
emoji: '👤',
color: '#888888',
order: 0,
),
);
// Parse color
Color memberColor;
try {
memberColor = Color(
int.parse(member.color.replaceAll('#', '0xFF')),
);
} catch (_) {
memberColor = Colors.grey;
}
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
backgroundColor: memberColor.withOpacity(0.2),
child: Text(
member.emoji,
style: const TextStyle(fontSize: 20),
),
),
title: Text(
todo.title,
style: TextStyle(
color: todo.completed ? Colors.white54 : Colors.white,
decoration: todo.completed
? TextDecoration.lineThrough
: null,
decorationColor: Colors.white54,
),
),
trailing: Checkbox(
value: todo.completed,
onChanged: (val) async {
// Toggle completion
final updated = TodoItem(
id: todo.id,
familyMemberId: todo.familyMemberId,
title: todo.title,
completed: val ?? false,
dueDate: todo.dueDate,
);
await Provider.of<TodoService>(
context,
listen: false,
).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.
// For now, let's just let the next refresh cycle pick it up, or if the user interacts, maybe we should optimistic update?
// 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,
checkColor: Colors.black,
),
);
},
);
},
),
),
],
),
);
}
}

View File

@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter/foundation.dart';
import '../config/api_config.dart';
import '../models/weather_info.dart';
import '../services/weather_service.dart';
class WeatherWidget extends StatelessWidget {
const WeatherWidget({super.key});
@override
Widget build(BuildContext context) {
return FutureBuilder<WeatherInfo>(
future: Provider.of<WeatherService>(
context,
listen: false,
).fetchWeather(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
if (snapshot.hasError) {
return const Text(
'Weather Unavailable',
style: TextStyle(color: Colors.white54),
);
}
if (!snapshot.hasData) {
return const SizedBox.shrink();
}
final weather = snapshot.data!;
// Assuming OpenWeatherMap icon format
final iconUrl = (ApiConfig.useMockData || kIsWeb)
? null
: (weather.icon.isNotEmpty
? "http://openweathermap.org/img/wn/${weather.icon}@2x.png"
: null);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (iconUrl != null)
Image.network(
iconUrl,
width: 50,
height: 50,
errorBuilder: (_, __, ___) =>
const Icon(Icons.wb_sunny, color: Colors.amber),
)
else
const Icon(Icons.wb_sunny, color: Colors.amber, size: 40),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${weather.temperature.round()}°C',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
Text(
'${weather.city} · ${weather.description}',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: Colors.white70),
),
],
),
],
);
},
);
}
}