Initial commit
This commit is contained in:
20
flutter_app/lib/config/api_config.dart
Normal file
20
flutter_app/lib/config/api_config.dart
Normal 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
132
flutter_app/lib/main.dart
Normal 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(),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
34
flutter_app/lib/models/announcement.dart
Normal file
34
flutter_app/lib/models/announcement.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
34
flutter_app/lib/models/bible_verse.dart
Normal file
34
flutter_app/lib/models/bible_verse.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
29
flutter_app/lib/models/family_member.dart
Normal file
29
flutter_app/lib/models/family_member.dart
Normal 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};
|
||||
}
|
||||
}
|
||||
26
flutter_app/lib/models/photo.dart
Normal file
26
flutter_app/lib/models/photo.dart
Normal 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};
|
||||
}
|
||||
}
|
||||
45
flutter_app/lib/models/schedule_item.dart
Normal file
45
flutter_app/lib/models/schedule_item.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
36
flutter_app/lib/models/todo_item.dart
Normal file
36
flutter_app/lib/models/todo_item.dart
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
25
flutter_app/lib/models/weather_info.dart
Normal file
25
flutter_app/lib/models/weather_info.dart
Normal 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? ?? "",
|
||||
);
|
||||
}
|
||||
}
|
||||
450
flutter_app/lib/screens/admin/admin_screen.dart
Normal file
450
flutter_app/lib/screens/admin/admin_screen.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
431
flutter_app/lib/screens/mobile/mobile_home_screen.dart
Normal file
431
flutter_app/lib/screens/mobile/mobile_home_screen.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
133
flutter_app/lib/screens/tv/tv_dashboard_screen.dart
Normal file
133
flutter_app/lib/screens/tv/tv_dashboard_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
71
flutter_app/lib/services/announcement_service.dart
Normal file
71
flutter_app/lib/services/announcement_service.dart
Normal 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");
|
||||
}
|
||||
}
|
||||
71
flutter_app/lib/services/api_client.dart
Normal file
71
flutter_app/lib/services/api_client.dart
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
26
flutter_app/lib/services/bible_service.dart
Normal file
26
flutter_app/lib/services/bible_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
66
flutter_app/lib/services/bible_verse_service.dart
Normal file
66
flutter_app/lib/services/bible_verse_service.dart
Normal 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");
|
||||
}
|
||||
}
|
||||
71
flutter_app/lib/services/family_service.dart
Normal file
71
flutter_app/lib/services/family_service.dart
Normal 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);
|
||||
}
|
||||
149
flutter_app/lib/services/mock_data.dart
Normal file
149
flutter_app/lib/services/mock_data.dart
Normal 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;
|
||||
}
|
||||
48
flutter_app/lib/services/photo_service.dart
Normal file
48
flutter_app/lib/services/photo_service.dart
Normal 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");
|
||||
}
|
||||
}
|
||||
83
flutter_app/lib/services/schedule_service.dart
Normal file
83
flutter_app/lib/services/schedule_service.dart
Normal 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");
|
||||
}
|
||||
}
|
||||
79
flutter_app/lib/services/todo_service.dart
Normal file
79
flutter_app/lib/services/todo_service.dart
Normal 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");
|
||||
}
|
||||
}
|
||||
19
flutter_app/lib/services/weather_service.dart
Normal file
19
flutter_app/lib/services/weather_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
169
flutter_app/lib/widgets/announcement_widget.dart
Normal file
169
flutter_app/lib/widgets/announcement_widget.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
83
flutter_app/lib/widgets/bible_verse_widget.dart
Normal file
83
flutter_app/lib/widgets/bible_verse_widget.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
112
flutter_app/lib/widgets/calendar_widget.dart
Normal file
112
flutter_app/lib/widgets/calendar_widget.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
53
flutter_app/lib/widgets/digital_clock_widget.dart
Normal file
53
flutter_app/lib/widgets/digital_clock_widget.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
144
flutter_app/lib/widgets/photo_slideshow_widget.dart
Normal file
144
flutter_app/lib/widgets/photo_slideshow_widget.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
129
flutter_app/lib/widgets/schedule_list_widget.dart
Normal file
129
flutter_app/lib/widgets/schedule_list_widget.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
174
flutter_app/lib/widgets/todo_list_widget.dart
Normal file
174
flutter_app/lib/widgets/todo_list_widget.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
86
flutter_app/lib/widgets/weather_widget.dart
Normal file
86
flutter_app/lib/widgets/weather_widget.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user