Add admin management tabs and TV/mobile fixes

This commit is contained in:
kihong.kim
2026-01-24 21:44:13 +09:00
parent 807df3d678
commit 29881aa442
10 changed files with 522 additions and 105 deletions

View File

@@ -1,8 +1,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<application <application
android:label="google_tv_dashboard" android:label="google_tv_dashboard"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -23,6 +28,7 @@
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -22,6 +22,7 @@ Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Hide status bar for TV immersive experience // Hide status bar for TV immersive experience
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
GoogleFonts.config.allowRuntimeFetching = false;
await initializeDateFormatting(); await initializeDateFormatting();
@@ -120,9 +121,8 @@ class MyApp extends StatelessWidget {
), ),
), ),
), ),
initialRoute: '/', home: const AdaptiveHome(),
routes: { routes: {
'/': (context) => const TvDashboardScreen(),
'/mobile': (context) => const MobileHomeScreen(), '/mobile': (context) => const MobileHomeScreen(),
'/admin': (context) => const AdminScreen(), '/admin': (context) => const AdminScreen(),
}, },
@@ -130,3 +130,25 @@ class MyApp extends StatelessWidget {
); );
} }
} }
class AdaptiveHome extends StatelessWidget {
const AdaptiveHome({super.key});
@override
Widget build(BuildContext context) {
const forceTv = String.fromEnvironment('FORCE_TV', defaultValue: 'false');
if (forceTv.toLowerCase() == 'true') {
return const TvDashboardScreen();
}
final size = MediaQuery.of(context).size;
final shortestSide = size.shortestSide;
final isMobile = shortestSide < 600;
if (isMobile) {
return const MobileHomeScreen();
}
return const TvDashboardScreen();
}
}

View File

@@ -3,9 +3,13 @@ import 'package:provider/provider.dart';
import '../../models/bible_verse.dart'; import '../../models/bible_verse.dart';
import '../../models/family_member.dart'; import '../../models/family_member.dart';
import '../../models/photo.dart'; import '../../models/photo.dart';
import '../../models/announcement.dart';
import '../../models/schedule_item.dart';
import '../../services/bible_verse_service.dart'; import '../../services/bible_verse_service.dart';
import '../../services/family_service.dart'; import '../../services/family_service.dart';
import '../../services/photo_service.dart'; import '../../services/photo_service.dart';
import '../../services/announcement_service.dart';
import '../../services/schedule_service.dart';
class AdminScreen extends StatefulWidget { class AdminScreen extends StatefulWidget {
const AdminScreen({super.key}); const AdminScreen({super.key});
@@ -18,15 +22,18 @@ class _AdminScreenState extends State<AdminScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return DefaultTabController( return DefaultTabController(
length: 3, length: 5,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Admin Settings'), title: const Text('Admin Settings'),
bottom: const TabBar( bottom: const TabBar(
isScrollable: true,
tabs: [ tabs: [
Tab(text: 'Family Members'), Tab(text: 'Family Members'),
Tab(text: 'Photos'), Tab(text: 'Photos'),
Tab(text: 'Bible Verses'), Tab(text: 'Bible Verses'),
Tab(text: 'Announcements'),
Tab(text: 'Schedules'),
], ],
), ),
), ),
@@ -35,6 +42,8 @@ class _AdminScreenState extends State<AdminScreen> {
FamilyManagerTab(), FamilyManagerTab(),
PhotoManagerTab(), PhotoManagerTab(),
BibleVerseManagerTab(), BibleVerseManagerTab(),
AnnouncementManagerTab(),
ScheduleManagerTab(),
], ],
), ),
), ),
@@ -448,3 +457,344 @@ class _BibleVerseManagerTabState extends State<BibleVerseManagerTab> {
); );
} }
} }
class AnnouncementManagerTab extends StatefulWidget {
const AnnouncementManagerTab({super.key});
@override
State<AnnouncementManagerTab> createState() => _AnnouncementManagerTabState();
}
class _AnnouncementManagerTabState extends State<AnnouncementManagerTab> {
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddAnnouncementDialog(context),
child: const Icon(Icons.campaign),
),
body: FutureBuilder<List<Announcement>>(
future: Provider.of<AnnouncementService>(context).fetchAnnouncements(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final announcements = snapshot.data!;
if (announcements.isEmpty) {
return const Center(
child: Text(
'No announcements added yet',
style: TextStyle(color: Colors.grey),
),
);
}
return ListView.builder(
itemCount: announcements.length,
itemBuilder: (context, index) {
final announcement = announcements[index];
return ListTile(
title: Text(announcement.title),
subtitle: Text(
announcement.content,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (announcement.priority > 0)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.orangeAccent,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'P${announcement.priority}',
style: const TextStyle(
color: Colors.white, fontSize: 10),
),
),
const SizedBox(width: 8),
Icon(
announcement.active ? Icons.check_circle : Icons.cancel,
color: announcement.active ? Colors.green : Colors.grey,
size: 16,
),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () async {
await Provider.of<AnnouncementService>(
context,
listen: false,
).deleteAnnouncement(announcement.id);
setState(() {});
},
),
],
),
);
},
);
},
),
);
}
void _showAddAnnouncementDialog(BuildContext context) {
final titleController = TextEditingController();
final contentController = TextEditingController();
final priorityController = TextEditingController(text: '0');
bool isActive = true;
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: const Text('Add Announcement'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: titleController,
decoration: const InputDecoration(labelText: 'Title'),
),
TextField(
controller: contentController,
decoration: const InputDecoration(labelText: 'Content'),
maxLines: 3,
),
TextField(
controller: priorityController,
decoration:
const InputDecoration(labelText: 'Priority (0-10)'),
keyboardType: TextInputType.number,
),
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 (titleController.text.isNotEmpty) {
await Provider.of<AnnouncementService>(
context,
listen: false,
).createAnnouncement(
Announcement(
id: '',
title: titleController.text,
content: contentController.text,
priority: int.tryParse(priorityController.text) ?? 0,
active: isActive,
),
);
if (mounted) {
Navigator.pop(context);
setState(() {});
}
}
},
child: const Text('Add'),
),
],
),
),
);
}
}
class ScheduleManagerTab extends StatefulWidget {
const ScheduleManagerTab({super.key});
@override
State<ScheduleManagerTab> createState() => _ScheduleManagerTabState();
}
class _ScheduleManagerTabState extends State<ScheduleManagerTab> {
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddScheduleDialog(context),
child: const Icon(Icons.calendar_today),
),
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!;
if (schedules.isEmpty) {
return const Center(
child: Text(
'No schedules added yet',
style: TextStyle(color: Colors.grey),
),
);
}
return ListView.builder(
itemCount: schedules.length,
itemBuilder: (context, index) {
final schedule = schedules[index];
return ListTile(
title: Text(schedule.title),
subtitle: Text(
'${schedule.description}\n${schedule.startDate.toIso8601String().split('T')[0]} ~ ${schedule.endDate.toIso8601String().split('T')[0]}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
isThreeLine: true,
trailing: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () async {
await Provider.of<ScheduleService>(
context,
listen: false,
).deleteSchedule(schedule.id);
setState(() {});
},
),
);
},
);
},
),
);
}
void _showAddScheduleDialog(BuildContext context) {
final titleController = TextEditingController();
final descriptionController = TextEditingController();
final startController = TextEditingController(
text: DateTime.now().toIso8601String().split('T')[0],
);
final endController = TextEditingController(
text: DateTime.now().toIso8601String().split('T')[0],
);
bool isAllDay = true;
String? selectedFamilyMemberId;
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: const Text('Add Schedule'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: titleController,
decoration: const InputDecoration(labelText: 'Title'),
),
TextField(
controller: descriptionController,
decoration: const InputDecoration(labelText: 'Description'),
),
TextField(
controller: startController,
decoration: const InputDecoration(
labelText: 'Start Date (YYYY-MM-DD)',
),
),
TextField(
controller: endController,
decoration: const InputDecoration(
labelText: 'End Date (YYYY-MM-DD)',
),
),
SwitchListTile(
title: const Text('All Day'),
value: isAllDay,
onChanged: (value) {
setDialogState(() {
isAllDay = value;
});
},
),
FutureBuilder<List<FamilyMember>>(
future: Provider.of<FamilyService>(context, listen: false)
.fetchFamilyMembers(),
builder: (context, snapshot) {
if (!snapshot.hasData) return const SizedBox();
final members = snapshot.data!;
return DropdownButtonFormField<String>(
value: selectedFamilyMemberId,
decoration: const InputDecoration(
labelText: 'Family Member',
),
items: members.map((member) {
return DropdownMenuItem(
value: member.id,
child: Text(member.name),
);
}).toList(),
onChanged: (value) {
setDialogState(() {
selectedFamilyMemberId = value;
});
},
);
},
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
if (titleController.text.isNotEmpty) {
final startDate =
DateTime.tryParse(startController.text) ?? DateTime.now();
final endDate =
DateTime.tryParse(endController.text) ?? DateTime.now();
await Provider.of<ScheduleService>(
context,
listen: false,
).createSchedule(
ScheduleItem(
id: '',
title: titleController.text,
description: descriptionController.text,
startDate: startDate,
endDate: endDate,
familyMemberId: selectedFamilyMemberId ?? '',
isAllDay: isAllDay,
),
);
if (mounted) {
Navigator.pop(context);
setState(() {});
}
}
},
child: const Text('Add'),
),
],
),
),
);
}
}

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -47,7 +48,14 @@ class _TvDashboardScreenState extends State<TvDashboardScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
// 1920x1080 reference // 1920x1080 reference
return Scaffold( return Scaffold(
body: SafeArea( body: Center(
child: AspectRatio(
aspectRatio: 16 / 9,
child: FittedBox(
fit: BoxFit.contain,
child: SizedBox(
width: 1920,
height: 1080,
child: Padding( child: Padding(
padding: const EdgeInsets.all(32.0), // Outer margin safe zone padding: const EdgeInsets.all(32.0), // Outer margin safe zone
child: Column( child: Column(
@@ -57,7 +65,10 @@ class _TvDashboardScreenState extends State<TvDashboardScreen> {
height: 100, height: 100,
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [const DigitalClockWidget(), const WeatherWidget()], children: [
const DigitalClockWidget(),
const WeatherWidget()
],
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
@@ -71,11 +82,14 @@ class _TvDashboardScreenState extends State<TvDashboardScreen> {
flex: 3, flex: 3,
child: Column( child: Column(
children: [ children: [
const Expanded(flex: 4, child: CalendarWidget()), const Expanded(
flex: 4, child: CalendarWidget()),
const SizedBox(height: 16), const SizedBox(height: 16),
const Expanded(flex: 4, child: ScheduleListWidget()), const Expanded(
flex: 4, child: ScheduleListWidget()),
const SizedBox(height: 16), const SizedBox(height: 16),
const Expanded(flex: 2, child: AnnouncementWidget()), const Expanded(
flex: 2, child: AnnouncementWidget()),
], ],
), ),
), ),
@@ -104,9 +118,11 @@ class _TvDashboardScreenState extends State<TvDashboardScreen> {
flex: 3, flex: 3,
child: Column( child: Column(
children: [ children: [
const Expanded(flex: 6, child: TodoListWidget()), const Expanded(
flex: 6, child: TodoListWidget()),
const SizedBox(height: 16), const SizedBox(height: 16),
const Expanded(flex: 3, child: BibleVerseWidget()), const Expanded(
flex: 3, child: BibleVerseWidget()),
], ],
), ),
), ),
@@ -128,6 +144,9 @@ class _TvDashboardScreenState extends State<TvDashboardScreen> {
), ),
), ),
), ),
),
),
),
); );
} }
} }

View File

@@ -20,8 +20,7 @@ class CalendarWidget extends StatelessWidget {
// If we want Sun start: Sun=0, Mon=1... // If we want Sun start: Sun=0, Mon=1...
// Let's adjust so Sunday is first. // Let's adjust so Sunday is first.
int offset = int offset = startingWeekday %
startingWeekday %
7; // If startingWeekday is 7 (Sun), offset is 0. If 1 (Mon), offset is 1. 7; // If startingWeekday is 7 (Sun), offset is 0. If 1 (Mon), offset is 1.
return Container( return Container(
@@ -67,23 +66,23 @@ class CalendarWidget extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
// Days Grid // Days Grid
Expanded( Expanded(
child: GridView.builder( child: Column(
physics: const NeverScrollableScrollPhysics(), children: List.generate(6, (row) {
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( return Expanded(
crossAxisCount: 7, child: Row(
childAspectRatio: 1.0, children: List.generate(7, (col) {
), final index = row * 7 + col;
itemCount: 42, // 6 rows max to be safe
itemBuilder: (context, index) {
final dayNumber = index - offset + 1; final dayNumber = index - offset + 1;
if (dayNumber < 1 || dayNumber > daysInMonth) { if (dayNumber < 1 || dayNumber > daysInMonth) {
return const SizedBox.shrink(); return const Expanded(child: SizedBox.shrink());
} }
final isToday = dayNumber == now.day; final isToday = dayNumber == now.day;
return Container( return Expanded(
margin: const EdgeInsets.all(4), child: Container(
margin: const EdgeInsets.all(2),
decoration: isToday decoration: isToday
? BoxDecoration( ? BoxDecoration(
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
@@ -91,6 +90,8 @@ class CalendarWidget extends StatelessWidget {
) )
: null, : null,
child: Center( child: Center(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text( child: Text(
'$dayNumber', '$dayNumber',
style: TextStyle( style: TextStyle(
@@ -98,11 +99,17 @@ class CalendarWidget extends StatelessWidget {
fontWeight: isToday fontWeight: isToday
? FontWeight.bold ? FontWeight.bold
: FontWeight.normal, : FontWeight.normal,
fontSize: 12,
),
),
), ),
), ),
), ),
); );
}, }),
),
);
}),
), ),
), ),
], ],

View File

@@ -21,3 +21,16 @@ dev_dependencies:
flutter: flutter:
uses-material-design: true uses-material-design: true
fonts:
- family: Outfit
fonts:
- asset: assets/fonts/Outfit-Medium.ttf
weight: 500
- asset: assets/fonts/Outfit-SemiBold.ttf
weight: 600
- asset: assets/fonts/Outfit-Bold.ttf
weight: 700
- family: Mulish
fonts:
- asset: assets/fonts/Mulish-Regular.ttf
weight: 400