Initial commit

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

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
.DS_Store
# Node
backend/node_modules
backend/npm-debug.log
backend/.env
backend/.env.*
backend/.npmrc
# Flutter
flutter_app/.dart_tool
flutter_app/build
flutter_app/.flutter-plugins
flutter_app/.flutter-plugins-dependencies
flutter_app/.packages
flutter_app/android/.gradle
flutter_app/android/local.properties
flutter_app/android/app/debug.keystore
flutter_app/ios/Flutter/Flutter.framework
flutter_app/ios/Flutter/Flutter.podspec
flutter_app/ios/Flutter/Generated.xcconfig
flutter_app/ios/Flutter/ephemeral
flutter_app/ios/Pods
flutter_app/ios/.symlinks
# IDE
.idea
.vscode

55
README.md Normal file
View File

@@ -0,0 +1,55 @@
# Bini Google TV Dashboard
Google TV용 대시보드 앱과 백엔드 API 프로젝트입니다.
## 구성
- `backend`: Node.js + Express + MongoDB API
- `flutter_app`: Flutter Google TV 앱
## 백엔드 실행 (로컬)
```bash
cd backend
cp .env.example .env
# .env에 OPENWEATHER_API_KEY 등 필요한 값 설정
npm install
npm start
```
### MongoDB
로컬 MongoDB가 필요합니다.
- 기본 연결: `mongodb://localhost:27017/google-tv-dashboard`
- 변경 시 `backend/.env``MONGODB_URI` 수정
## 백엔드 실행 (Docker)
```bash
docker compose up -d
```
## Flutter 빌드
```bash
cd flutter_app
flutter build apk --release
```
### 서버 주소 주입
Google TV에서 로컬 백엔드로 연결하려면 Mac의 IP를 사용하세요.
```bash
flutter build apk --release --dart-define=API_BASE_URL=http://<MAC_IP>:4000
```
## APK 설치
생성된 APK 경로:
`flutter_app/build/app/outputs/flutter-apk/app-release.apk`
### Google TV 설치 (USB 메모리)
1. APK를 USB 메모리에 복사
2. TV에 USB 꽂기
3. 파일 관리자 앱에서 APK 실행 → 설치
## 어드민
관리 화면에서 성경 말씀을 등록할 수 있습니다.
- 랜덤 노출
- 날짜 지정은 옵션
## 환경 변수
`backend/.env.example` 참고

14
backend/.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
node_modules
npm-debug.log
.npmrc
.env
.env.*
.git
.gitignore
Dockerfile
*.md
coverage
tests
docs
build
dist

13
backend/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY . .
EXPOSE 4000
CMD ["npm", "start"]

13
backend/config/api.js Normal file
View File

@@ -0,0 +1,13 @@
const weatherBaseUrl = "https://api.openweathermap.org/data/2.5";
const config = {
weather: {
baseUrl: weatherBaseUrl,
apiKey: process.env.OPENWEATHER_API_KEY || "",
city: process.env.WEATHER_CITY || "Seoul",
units: process.env.WEATHER_UNITS || "metric",
language: process.env.WEATHER_LANG || "en",
},
};
module.exports = config;

14
backend/config/db.js Normal file
View File

@@ -0,0 +1,14 @@
const mongoose = require("mongoose");
const connectDb = async () => {
const mongoUri = process.env.MONGODB_URI;
if (!mongoUri) {
throw new Error("MONGODB_URI is not set");
}
mongoose.set("strictQuery", true);
await mongoose.connect(mongoUri);
return mongoose.connection;
};
module.exports = connectDb;

View File

@@ -0,0 +1,13 @@
const mongoose = require("mongoose");
const announcementSchema = new mongoose.Schema(
{
title: { type: String, required: true },
content: { type: String },
priority: { type: Number, default: 0 },
active: { type: Boolean, default: true },
},
{ timestamps: true }
);
module.exports = mongoose.model("Announcement", announcementSchema);

View File

@@ -0,0 +1,13 @@
const mongoose = require("mongoose");
const bibleVerseSchema = new mongoose.Schema(
{
text: { type: String, required: true },
reference: { type: String, required: true },
date: { type: String, trim: true },
active: { type: Boolean, default: true },
},
{ timestamps: true }
);
module.exports = mongoose.model("BibleVerse", bibleVerseSchema);

View File

@@ -0,0 +1,13 @@
const mongoose = require("mongoose");
const familyMemberSchema = new mongoose.Schema(
{
name: { type: String, required: true },
emoji: { type: String, required: true },
color: { type: String, required: true },
order: { type: Number, default: 0 },
},
{ timestamps: true }
);
module.exports = mongoose.model("FamilyMember", familyMemberSchema);

12
backend/models/Photo.js Normal file
View File

@@ -0,0 +1,12 @@
const mongoose = require("mongoose");
const photoSchema = new mongoose.Schema(
{
url: { type: String, required: true },
caption: { type: String },
active: { type: Boolean, default: true },
},
{ timestamps: true }
);
module.exports = mongoose.model("Photo", photoSchema);

View File

@@ -0,0 +1,15 @@
const mongoose = require("mongoose");
const scheduleSchema = new mongoose.Schema(
{
title: { type: String, required: true },
description: { type: String },
startDate: { type: Date, required: true },
endDate: { type: Date, required: true },
familyMemberId: { type: mongoose.Schema.Types.ObjectId, ref: "FamilyMember" },
isAllDay: { type: Boolean, default: false },
},
{ timestamps: true }
);
module.exports = mongoose.model("Schedule", scheduleSchema);

11
backend/models/Setting.js Normal file
View File

@@ -0,0 +1,11 @@
const mongoose = require("mongoose");
const settingSchema = new mongoose.Schema(
{
key: { type: String, required: true, unique: true },
value: { type: mongoose.Schema.Types.Mixed },
},
{ timestamps: true }
);
module.exports = mongoose.model("Setting", settingSchema);

13
backend/models/Todo.js Normal file
View File

@@ -0,0 +1,13 @@
const mongoose = require("mongoose");
const todoSchema = new mongoose.Schema(
{
familyMemberId: { type: mongoose.Schema.Types.ObjectId, ref: "FamilyMember" },
title: { type: String, required: true },
completed: { type: Boolean, default: false },
dueDate: { type: Date },
},
{ timestamps: true }
);
module.exports = mongoose.model("Todo", todoSchema);

1213
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
backend/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "bini-google-tv-backend",
"version": "0.1.0",
"private": true,
"main": "server.js",
"type": "commonjs",
"scripts": {
"start": "node server.js",
"dev": "node server.js",
"seed": "node scripts/seed.js",
"demo": "node scripts/demo.js"
},
"dependencies": {
"axios": "^1.7.9",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.19.2",
"mongoose": "^8.9.0"
}
}

View File

@@ -0,0 +1,69 @@
const express = require("express");
const Announcement = require("../models/Announcement");
const router = express.Router();
router.get("/", async (req, res) => {
try {
const filter = {};
if (req.query.active === "true") {
filter.active = true;
}
const announcements = await Announcement.find(filter).sort({
priority: -1,
createdAt: -1,
});
res.json(announcements);
} catch (error) {
res.status(500).json({ message: "Failed to fetch announcements" });
}
});
router.post("/", async (req, res) => {
try {
const announcement = await Announcement.create(req.body);
res.status(201).json(announcement);
} catch (error) {
res.status(400).json({ message: "Failed to create announcement" });
}
});
router.get("/:id", async (req, res) => {
try {
const announcement = await Announcement.findById(req.params.id);
if (!announcement) {
return res.status(404).json({ message: "Announcement not found" });
}
res.json(announcement);
} catch (error) {
res.status(400).json({ message: "Failed to fetch announcement" });
}
});
router.put("/:id", async (req, res) => {
try {
const announcement = await Announcement.findByIdAndUpdate(req.params.id, req.body, {
new: true,
});
if (!announcement) {
return res.status(404).json({ message: "Announcement not found" });
}
res.json(announcement);
} catch (error) {
res.status(400).json({ message: "Failed to update announcement" });
}
});
router.delete("/:id", async (req, res) => {
try {
const announcement = await Announcement.findByIdAndDelete(req.params.id);
if (!announcement) {
return res.status(404).json({ message: "Announcement not found" });
}
res.json({ ok: true });
} catch (error) {
res.status(400).json({ message: "Failed to delete announcement" });
}
});
module.exports = router;

94
backend/routes/bible.js Normal file
View File

@@ -0,0 +1,94 @@
const express = require("express");
const BibleVerse = require("../models/BibleVerse");
const router = express.Router();
const pickRandomVerse = async (filter) => {
const results = await BibleVerse.aggregate([
{ $match: filter },
{ $sample: { size: 1 } },
]);
return results[0] || null;
};
router.get("/today", async (req, res) => {
try {
const targetDate = req.query.date || new Date().toISOString().slice(0, 10);
const datedVerse = await pickRandomVerse({
active: true,
date: targetDate,
});
if (datedVerse) {
return res.json(datedVerse);
}
const undatedVerse = await pickRandomVerse({
active: true,
$or: [{ date: { $exists: false } }, { date: null }, { date: "" }],
});
if (undatedVerse) {
return res.json(undatedVerse);
}
const anyVerse = await pickRandomVerse({ active: true });
if (!anyVerse) {
return res.status(404).json({ message: "No bible verses available" });
}
return res.json(anyVerse);
} catch (error) {
res.status(500).json({ message: "Failed to fetch bible verse" });
}
});
router.get("/verses", async (req, res) => {
try {
const filter = {};
if (req.query.active === "true") {
filter.active = true;
}
const verses = await BibleVerse.find(filter).sort({
date: -1,
createdAt: -1,
});
res.json(verses);
} catch (error) {
res.status(500).json({ message: "Failed to fetch bible verses" });
}
});
router.post("/verses", async (req, res) => {
try {
const verse = await BibleVerse.create(req.body);
res.status(201).json(verse);
} catch (error) {
res.status(400).json({ message: "Failed to create bible verse" });
}
});
router.put("/verses/:id", async (req, res) => {
try {
const verse = await BibleVerse.findByIdAndUpdate(req.params.id, req.body, {
new: true,
});
if (!verse) {
return res.status(404).json({ message: "Bible verse not found" });
}
res.json(verse);
} catch (error) {
res.status(400).json({ message: "Failed to update bible verse" });
}
});
router.delete("/verses/:id", async (req, res) => {
try {
const verse = await BibleVerse.findByIdAndDelete(req.params.id);
if (!verse) {
return res.status(404).json({ message: "Bible verse not found" });
}
res.json({ ok: true });
} catch (error) {
res.status(400).json({ message: "Failed to delete bible verse" });
}
});
module.exports = router;

62
backend/routes/family.js Normal file
View File

@@ -0,0 +1,62 @@
const express = require("express");
const FamilyMember = require("../models/FamilyMember");
const router = express.Router();
router.get("/", async (req, res) => {
try {
const members = await FamilyMember.find().sort({ order: 1, createdAt: 1 });
res.json(members);
} catch (error) {
res.status(500).json({ message: "Failed to fetch family members" });
}
});
router.post("/", async (req, res) => {
try {
const member = await FamilyMember.create(req.body);
res.status(201).json(member);
} catch (error) {
res.status(400).json({ message: "Failed to create family member" });
}
});
router.get("/:id", async (req, res) => {
try {
const member = await FamilyMember.findById(req.params.id);
if (!member) {
return res.status(404).json({ message: "Family member not found" });
}
res.json(member);
} catch (error) {
res.status(400).json({ message: "Failed to fetch family member" });
}
});
router.put("/:id", async (req, res) => {
try {
const member = await FamilyMember.findByIdAndUpdate(req.params.id, req.body, {
new: true,
});
if (!member) {
return res.status(404).json({ message: "Family member not found" });
}
res.json(member);
} catch (error) {
res.status(400).json({ message: "Failed to update family member" });
}
});
router.delete("/:id", async (req, res) => {
try {
const member = await FamilyMember.findByIdAndDelete(req.params.id);
if (!member) {
return res.status(404).json({ message: "Family member not found" });
}
res.json({ ok: true });
} catch (error) {
res.status(400).json({ message: "Failed to delete family member" });
}
});
module.exports = router;

40
backend/routes/photos.js Normal file
View File

@@ -0,0 +1,40 @@
const express = require("express");
const Photo = require("../models/Photo");
const router = express.Router();
router.get("/", async (req, res) => {
try {
const filter = {};
if (req.query.active === "true") {
filter.active = true;
}
const photos = await Photo.find(filter).sort({ createdAt: -1 });
res.json(photos);
} catch (error) {
res.status(500).json({ message: "Failed to fetch photos" });
}
});
router.post("/", async (req, res) => {
try {
const photo = await Photo.create(req.body);
res.status(201).json(photo);
} catch (error) {
res.status(400).json({ message: "Failed to create photo" });
}
});
router.delete("/:id", async (req, res) => {
try {
const photo = await Photo.findByIdAndDelete(req.params.id);
if (!photo) {
return res.status(404).json({ message: "Photo not found" });
}
res.json({ ok: true });
} catch (error) {
res.status(400).json({ message: "Failed to delete photo" });
}
});
module.exports = router;

115
backend/routes/schedules.js Normal file
View File

@@ -0,0 +1,115 @@
const express = require("express");
const Schedule = require("../models/Schedule");
const router = express.Router();
const startOfWeek = (date = new Date()) => {
const copy = new Date(date);
const day = copy.getDay();
const diff = day === 0 ? -6 : 1 - day;
copy.setDate(copy.getDate() + diff);
copy.setHours(0, 0, 0, 0);
return copy;
};
const endOfWeek = (date = new Date()) => {
const start = startOfWeek(date);
const end = new Date(start);
end.setDate(end.getDate() + 6);
end.setHours(23, 59, 59, 999);
return end;
};
const startOfMonth = (date = new Date()) => {
return new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0);
};
const endOfMonth = (date = new Date()) => {
return new Date(date.getFullYear(), date.getMonth() + 1, 0, 23, 59, 59, 999);
};
router.get("/", async (req, res) => {
try {
const schedules = await Schedule.find().sort({ startDate: 1 });
res.json(schedules);
} catch (error) {
res.status(500).json({ message: "Failed to fetch schedules" });
}
});
router.get("/week", async (req, res) => {
try {
const start = startOfWeek();
const end = endOfWeek();
const schedules = await Schedule.find({
startDate: { $lte: end },
endDate: { $gte: start },
}).sort({ startDate: 1 });
res.json(schedules);
} catch (error) {
res.status(500).json({ message: "Failed to fetch weekly schedules" });
}
});
router.get("/month", async (req, res) => {
try {
const start = startOfMonth();
const end = endOfMonth();
const schedules = await Schedule.find({
startDate: { $lte: end },
endDate: { $gte: start },
}).sort({ startDate: 1 });
res.json(schedules);
} catch (error) {
res.status(500).json({ message: "Failed to fetch monthly schedules" });
}
});
router.post("/", async (req, res) => {
try {
const schedule = await Schedule.create(req.body);
res.status(201).json(schedule);
} catch (error) {
res.status(400).json({ message: "Failed to create schedule" });
}
});
router.get("/:id", async (req, res) => {
try {
const schedule = await Schedule.findById(req.params.id);
if (!schedule) {
return res.status(404).json({ message: "Schedule not found" });
}
res.json(schedule);
} catch (error) {
res.status(400).json({ message: "Failed to fetch schedule" });
}
});
router.put("/:id", async (req, res) => {
try {
const schedule = await Schedule.findByIdAndUpdate(req.params.id, req.body, {
new: true,
});
if (!schedule) {
return res.status(404).json({ message: "Schedule not found" });
}
res.json(schedule);
} catch (error) {
res.status(400).json({ message: "Failed to update schedule" });
}
});
router.delete("/:id", async (req, res) => {
try {
const schedule = await Schedule.findByIdAndDelete(req.params.id);
if (!schedule) {
return res.status(404).json({ message: "Schedule not found" });
}
res.json({ ok: true });
} catch (error) {
res.status(400).json({ message: "Failed to delete schedule" });
}
});
module.exports = router;

81
backend/routes/todos.js Normal file
View File

@@ -0,0 +1,81 @@
const express = require("express");
const Todo = require("../models/Todo");
const router = express.Router();
const getDayRange = (date = new Date()) => {
const start = new Date(date);
start.setHours(0, 0, 0, 0);
const end = new Date(date);
end.setHours(23, 59, 59, 999);
return { start, end };
};
router.get("/", async (req, res) => {
try {
const todos = await Todo.find().sort({ dueDate: 1, createdAt: -1 });
res.json(todos);
} catch (error) {
res.status(500).json({ message: "Failed to fetch todos" });
}
});
router.get("/today", async (req, res) => {
try {
const { start, end } = getDayRange();
const todos = await Todo.find({ dueDate: { $gte: start, $lte: end } }).sort({
dueDate: 1,
createdAt: -1,
});
res.json(todos);
} catch (error) {
res.status(500).json({ message: "Failed to fetch today todos" });
}
});
router.post("/", async (req, res) => {
try {
const todo = await Todo.create(req.body);
res.status(201).json(todo);
} catch (error) {
res.status(400).json({ message: "Failed to create todo" });
}
});
router.get("/:id", async (req, res) => {
try {
const todo = await Todo.findById(req.params.id);
if (!todo) {
return res.status(404).json({ message: "Todo not found" });
}
res.json(todo);
} catch (error) {
res.status(400).json({ message: "Failed to fetch todo" });
}
});
router.put("/:id", async (req, res) => {
try {
const todo = await Todo.findByIdAndUpdate(req.params.id, req.body, { new: true });
if (!todo) {
return res.status(404).json({ message: "Todo not found" });
}
res.json(todo);
} catch (error) {
res.status(400).json({ message: "Failed to update todo" });
}
});
router.delete("/:id", async (req, res) => {
try {
const todo = await Todo.findByIdAndDelete(req.params.id);
if (!todo) {
return res.status(404).json({ message: "Todo not found" });
}
res.json({ ok: true });
} catch (error) {
res.status(400).json({ message: "Failed to delete todo" });
}
});
module.exports = router;

35
backend/routes/weather.js Normal file
View File

@@ -0,0 +1,35 @@
const express = require("express");
const axios = require("axios");
const config = require("../config/api");
const router = express.Router();
router.get("/", async (req, res) => {
try {
const { apiKey, baseUrl, city, units, language } = config.weather;
if (!apiKey) {
return res.status(400).json({ message: "OPENWEATHER_API_KEY is not set" });
}
const { q, lat, lon } = req.query;
const params = {
appid: apiKey,
units,
lang: language,
};
if (lat && lon) {
params.lat = lat;
params.lon = lon;
} else {
params.q = q || city;
}
const response = await axios.get(`${baseUrl}/weather`, { params });
res.json(response.data);
} catch (error) {
res.status(500).json({ message: "Failed to fetch weather" });
}
});
module.exports = router;

29
backend/scripts/demo.js Normal file
View File

@@ -0,0 +1,29 @@
const path = require("path");
const { spawn } = require("child_process");
const run = (command, args) =>
new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: "inherit" });
child.on("close", (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`Command failed: ${command} ${args.join(" ")}`));
});
});
const start = async () => {
const seedPath = path.join(__dirname, "seed.js");
const serverPath = path.join(__dirname, "..", "server.js");
await run("node", [seedPath]);
const server = spawn("node", [serverPath], { stdio: "inherit" });
server.on("close", (code) => process.exit(code ?? 0));
};
start().catch((error) => {
console.error("Demo failed", error);
process.exit(1);
});

132
backend/scripts/seed.js Normal file
View File

@@ -0,0 +1,132 @@
const dotenv = require("dotenv");
const mongoose = require("mongoose");
const FamilyMember = require("../models/FamilyMember");
const Todo = require("../models/Todo");
const Schedule = require("../models/Schedule");
const Announcement = require("../models/Announcement");
const Photo = require("../models/Photo");
dotenv.config();
const connect = async () => {
const mongoUri = process.env.MONGODB_URI;
if (!mongoUri) {
throw new Error("MONGODB_URI is not set");
}
mongoose.set("strictQuery", true);
await mongoose.connect(mongoUri);
};
const seed = async () => {
await Promise.all([
FamilyMember.deleteMany({}),
Todo.deleteMany({}),
Schedule.deleteMany({}),
Announcement.deleteMany({}),
Photo.deleteMany({}),
]);
const family = await FamilyMember.insertMany([
{ name: "Dad", emoji: ":)", color: "#0F766E", order: 1 },
{ name: "Mom", emoji: "<3", color: "#C2410C", order: 2 },
{ name: "Son", emoji: ":D", color: "#1D4ED8", order: 3 },
{ name: "Daughter", emoji: ":-)", color: "#7C3AED", order: 4 },
]);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 9, 0);
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
await Todo.insertMany([
{
familyMemberId: family[0]._id,
title: "Grocery run",
completed: false,
dueDate: today,
},
{
familyMemberId: family[1]._id,
title: "Team meeting",
completed: false,
dueDate: today,
},
{
familyMemberId: family[2]._id,
title: "Math homework",
completed: false,
dueDate: today,
},
{
familyMemberId: family[3]._id,
title: "Piano lesson",
completed: false,
dueDate: tomorrow,
},
]);
await Schedule.insertMany([
{
title: "Family dinner",
description: "Everyone at home",
startDate: today,
endDate: new Date(today.getTime() + 2 * 60 * 60 * 1000),
familyMemberId: family[0]._id,
isAllDay: false,
},
{
title: "Soccer practice",
description: "School field",
startDate: new Date(today.getTime() + 4 * 60 * 60 * 1000),
endDate: new Date(today.getTime() + 5 * 60 * 60 * 1000),
familyMemberId: family[2]._id,
isAllDay: false,
},
]);
await Announcement.insertMany([
{
title: "Weekend trip",
content: "Pack light and be ready by 8 AM",
priority: 2,
active: true,
},
{
title: "Trash day",
content: "Take out bins tonight",
priority: 1,
active: true,
},
]);
await Photo.insertMany([
{
url: "https://picsum.photos/1200/800?random=10",
caption: "Summer vacation",
active: true,
},
{
url: "https://picsum.photos/1200/800?random=11",
caption: "Family hike",
active: true,
},
{
url: "https://picsum.photos/1200/800?random=12",
caption: "Birthday party",
active: true,
},
]);
};
connect()
.then(seed)
.then(() => {
console.log("Seed data inserted");
return mongoose.disconnect();
})
.then(() => process.exit(0))
.catch((error) => {
console.error("Seed failed", error);
process.exit(1);
});

43
backend/server.js Normal file
View File

@@ -0,0 +1,43 @@
const express = require("express");
const cors = require("cors");
const dotenv = require("dotenv");
const connectDb = require("./config/db");
const familyRoutes = require("./routes/family");
const todoRoutes = require("./routes/todos");
const scheduleRoutes = require("./routes/schedules");
const announcementRoutes = require("./routes/announcements");
const weatherRoutes = require("./routes/weather");
const bibleRoutes = require("./routes/bible");
const photoRoutes = require("./routes/photos");
dotenv.config();
const app = express();
const port = process.env.PORT || 4000;
app.use(cors());
app.use(express.json({ limit: "2mb" }));
app.get("/health", (req, res) => {
res.json({ ok: true });
});
app.use("/api/family", familyRoutes);
app.use("/api/todos", todoRoutes);
app.use("/api/schedules", scheduleRoutes);
app.use("/api/announcements", announcementRoutes);
app.use("/api/weather", weatherRoutes);
app.use("/api/bible", bibleRoutes);
app.use("/api/photos", photoRoutes);
connectDb()
.then(() => {
app.listen(port, () => {
console.log(`Server listening on ${port}`);
});
})
.catch((error) => {
console.error("Failed to start server", error);
process.exit(1);
});

27
docker-compose.yml Normal file
View File

@@ -0,0 +1,27 @@
version: "3.9"
services:
backend:
build:
context: ./backend
ports:
- "4000:4000"
environment:
PORT: "4000"
MONGODB_URI: "mongodb://mongo:27017/google-tv-dashboard"
env_file:
- ./backend/.env
depends_on:
- mongo
restart: unless-stopped
mongo:
image: mongo:7
ports:
- "27017:27017"
volumes:
- mongo-data:/data/db
restart: unless-stopped
volumes:
mongo-data:

291
docs/project-plan.md Normal file
View File

@@ -0,0 +1,291 @@
# Google TV Family Dashboard App - Project Plan
Google TV용 가족 대시보드 앱 구현 계획입니다. TV 화면에서 일일 정보(달력, 날씨, 할일, 성경 말씀)를 표시하고, Flutter 앱을 통해 데이터를 입력/관리할 수 있는 시스템입니다.
---
## System Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Google TV │
│ Flutter TV App (APK) │
└──────────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Backend Server │
│ Node.js + Express API │
│ MongoDB │
└──────────────────────────┬──────────────────────────────────────┘
┌──────────────┼──────────────┐
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ Weather │ │ Bible │ │ Flutter │
│ API │ │ API │ │ Mobile │
└───────────┘ └───────────┘ └───────────┘
```
---
## Key Decisions
| 항목 | 선택 | 비고 |
|------|------|------|
| **TV App** | Flutter for Android TV | 모바일 앱과 코드 공유, APK로 TV 설치 |
| **DB** | MongoDB | 확장성, 유연한 스키마 |
| **Weather API** | OpenWeatherMap | 무료 1000 calls/day, 환경변수로 변경 가능 |
| **Bible API** | bible-api.com | 영어+한글 동시 표시, 환경변수로 변경 가능 |
| **가족 구성원** | Admin 기능으로 관리 | Flutter 앱 내 설정에서 추가/수정/삭제 |
| **사진 갤러리** | Admin에서 업로드 | TV 화면에 랜덤 슬라이드쇼로 표시 |
---
## TV Screen Specifications (43인치 기준)
| 항목 | 값 | 설명 |
|------|-----|------|
| **해상도** | 1920 x 1080 px | Full HD 기준 (4K TV도 호환) |
| **화면 비율** | 16:9 | 표준 와이드스크린 |
| **실제 크기** | 95.3cm x 53.6cm | 43인치 대각선 기준 |
| **Safe Zone** | 90% 영역 사용 | 가장자리 5% 여백 권장 |
### UI 레이아웃 가이드
```
┌────────────────────────────────────────────────────────────────────────┐
│ 1920px (16:9 @ 1080p) │
├────────────────────────────────────────────────────────────────────────┤
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Safe Zone (90%) │ │
│ │ ┌─────────────────────────────┬──────────────────────────┐ │ │
│ │ │ │ │ │ │
│ │ │ 메인 영역 (65%) │ 사이드바 (35%) │ │ │
│ │ │ 1248px │ 672px │ │ │
│ │ │ │ │ │ │
│ │ │ - 날씨 │ - 월간 달력 │ │ │
│ │ │ - 오늘의 할일 │ - 주간 일정 │ │ │
│ │ │ - 오늘의 말씀 │ - 공지사항 │ │ │
│ │ │ │ │ │ │
│ │ └─────────────────────────────┴──────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ 1080px
└────────────────────────────────────────────────────────────────────────┘
```
### 폰트 크기 권장 (시청 거리 2~3m 기준)
| 요소 | 크기 | 용도 |
|------|------|------|
| 헤더/시간 | 48-64px | 날짜, 현재 시간 |
| 제목 | 32-40px | 섹션 제목 |
| 본문 | 24-28px | 할일, 일정 내용 |
| 보조 텍스트 | 18-20px | 부가 정보 |
---
## TV Display Layout (Single Usage Dashboard)
모든 정보와 기능을 한 화면에서 볼 수 있는 통합 대시보드 레이아웃입니다.
```
┌────────────────────────────────────────────────────────────────────────┐
│ 📅 2026.01.24 (금) 15:43:36 🌤️ 서울 12°C 맑음 │
├──────────────────────┬─────────────────────────┬───────────────────────┤
│ [📅 월간 달력] │ [🖼️ 가족 사진 앨범] │ [✅ 오늘의 할일] │
│ │ │ │
│ 1 2 3 4 5 6 7 │ (랜덤 슬라이드쇼) │ 👨 아빠: 마트, 운동 │
│ 8 9 10 11 12 13 14 │ (30초 간격 전환) │ 👩 엄마: 회의 │
│ 15 16 17 18 19 20 21 │ │ 👦 아들: 수학 숙제 │
│ 22 23 24 25 26 27 28 │ │ 👧 딸: 피아노 가기 │
│ 29 30 31 │ │ │
├──────────────────────┤ ├───────────────────────┤
│ [📋 주간 일정] │ │ [📖 오늘의 말씀] │
│ │ │ │
│ 금: 가족 모임 │ │ "The fear of the LORD │
│ 토: 결혼식 참석 │ │ is the beginning..." │
│ 일: 교회 예배 │ │ │
│ │ │ "여호와를 경외하는..." │
├──────────────────────┤ │ - 잠언 1:7 │
│ [📢 공지사항] │ │ │
│ • 다음 주 여행 계획 │ │ │
└──────────────────────┴─────────────────────────┴───────────────────────┘
```
### 위젯 구성
1. **Header**: 날짜, 시간, 실시간 날씨 (Top Bar)
2. **Left Column (Plan)**:
- 월간 달력 (이번 달 전체 뷰)
- 주간 주요 일정 (리스트)
- 공지사항 (텍스트 롤링)
3. **Center Column (Memory)**:
- **가족 사진 위젯**: Admin에서 업로드한 사진들을 랜덤하게 표시 (디지털 액자 기능)
4. **Right Column (Focus)**:
- 가족별 오늘의 할일 (아바타와 함께 표시)
- 오늘의 말씀 (한글/영어 병기)
---
## MongoDB Collections (Updated)
### photos
```javascript
{
_id: ObjectId,
url: "https://.../photo.jpg", // 또는 base64 (저장 용량 고려 필요)
caption: "2025 여름 휴가",
active: true,
createdAt: Date
}
```
### family_members (기존 동일)
...
---
## API Endpoints (Updated)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET/POST | `/api/photos` | 사진 목록 조회/업로드 |
| DELETE | `/api/photos/:id` | 사진 삭제 |
| ... | ... | (기존 API 동일) |
```
bini-google-tv/
├── docs/
│ └── project-plan.md
├── backend/
│ ├── server.js
│ ├── config/
│ │ ├── db.js
│ │ └── api.js
│ ├── models/
│ │ ├── FamilyMember.js
│ │ ├── Todo.js
│ │ ├── Schedule.js
│ │ ├── Announcement.js
│ │ └── Setting.js
│ ├── routes/
│ │ ├── family.js
│ │ ├── todos.js
│ │ ├── schedules.js
│ │ ├── announcements.js
│ │ ├── weather.js
│ │ └── bible.js
│ ├── .env.example
│ └── package.json
└── flutter_app/
├── lib/
│ ├── main.dart
│ ├── config/
│ ├── models/
│ ├── services/
│ ├── screens/
│ │ ├── tv/
│ │ ├── mobile/
│ │ └── admin/
│ └── widgets/
└── pubspec.yaml
```
---
## MongoDB Collections
### family_members
```javascript
{
_id: ObjectId,
name: "아빠",
emoji: "👨",
color: "#3498db",
order: 1,
createdAt: Date
}
```
### todos
```javascript
{
_id: ObjectId,
familyMemberId: ObjectId,
title: "마트 장보기",
completed: false,
dueDate: Date,
createdAt: Date
}
```
### schedules
```javascript
{
_id: ObjectId,
title: "가족 모임",
description: "할머니 댁 방문",
startDate: Date,
endDate: Date,
familyMemberId: ObjectId,
isAllDay: true,
createdAt: Date
}
```
### announcements
```javascript
{
_id: ObjectId,
title: "이번 주 외식",
content: "금요일 저녁 외식 예정",
priority: 1,
active: true,
createdAt: Date
}
```
---
## API Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET/POST | `/api/family` | 가족 구성원 조회/추가 |
| GET/PUT/DELETE | `/api/family/:id` | 특정 구성원 조회/수정/삭제 |
| GET/POST | `/api/todos` | 할일 조회/추가 |
| GET/PUT/DELETE | `/api/todos/:id` | 특정 할일 조회/수정/삭제 |
| GET | `/api/todos/today` | 오늘의 할일 조회 |
| GET/POST | `/api/schedules` | 일정 조회/추가 |
| GET | `/api/schedules/week` | 주간 일정 조회 |
| GET | `/api/schedules/month` | 월간 일정 조회 |
| GET/POST | `/api/announcements` | 공지사항 조회/추가 |
| GET | `/api/weather` | 현재 날씨 조회 |
| GET | `/api/bible/today` | 오늘의 말씀 조회 |
---
## Development Phases
| Phase | 내용 | 예상 시간 |
|-------|------|----------|
| 1 | Backend + MongoDB 설정 | 2-3시간 |
| 2 | REST API 구현 | 3-4시간 |
| 3 | Flutter 공통 구조 + 모델 | 2시간 |
| 4 | TV Display 화면 | 3-4시간 |
| 5 | Mobile 입력 화면 | 4-5시간 |
| 6 | Admin (가족구성원/설정) | 2시간 |
| 7 | 통합 + TV APK 빌드 | 2-3시간 |
**총 예상 시간: 18-23시간**
---
## TV App Installation
1. **Flutter 빌드**: `flutter build apk --release`
2. **TV에 설치**:
- USB로 APK 전송 후 파일 관리자에서 설치
- 또는 ADB 사용: `adb install app-release.apk`
3. **TV 홈에서 앱 실행**

45
flutter_app/.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

30
flutter_app/.metadata Normal file
View File

@@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "f6ff1529fd6d8af5f706051d9251ac9231c83407"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
- platform: android
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

16
flutter_app/README.md Normal file
View File

@@ -0,0 +1,16 @@
# google_tv_dashboard
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

View File

@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

14
flutter_app/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.example.google_tv_dashboard"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.google_tv_dashboard"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="google_tv_dashboard"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,5 @@
package com.example.google_tv_dashboard
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

View File

@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

421
flutter_app/pubspec.lock Normal file
View File

@@ -0,0 +1,421 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
async:
dependency: transitive
description:
name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev"
source: hosted
version: "2.13.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.4.0"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
url: "https://pub.dev"
source: hosted
version: "1.0.8"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
url: "https://pub.dev"
source: hosted
version: "2.1.5"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
google_fonts:
dependency: "direct main"
description:
name: google_fonts
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
url: "https://pub.dev"
source: hosted
version: "6.3.3"
hooks:
dependency: transitive
description:
name: hooks
sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
intl:
dependency: "direct main"
description:
name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
url: "https://pub.dev"
source: hosted
version: "0.19.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
url: "https://pub.dev"
source: hosted
version: "0.17.4"
nested:
dependency: transitive
description:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "7fd0c4d8ac8980011753b9bdaed2bf15111365924cdeeeaeb596214ea2b03537"
url: "https://pub.dev"
source: hosted
version: "9.2.4"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
url: "https://pub.dev"
source: hosted
version: "2.2.22"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
provider:
dependency: "direct main"
description:
name: provider
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
url: "https://pub.dev"
source: hosted
version: "1.10.1"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.7"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
url: "https://pub.dev"
source: hosted
version: "15.0.2"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.10.3 <4.0.0"
flutter: ">=3.38.4"

23
flutter_app/pubspec.yaml Normal file
View File

@@ -0,0 +1,23 @@
name: google_tv_dashboard
description: Family dashboard for Google TV and mobile admin.
publish_to: "none"
version: 0.1.0
environment:
sdk: ">=3.3.0 <4.0.0"
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.6
http: ^1.2.2
intl: ^0.19.0
provider: ^6.1.2
google_fonts: ^6.1.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter:
uses-material-design: true

View File

@@ -0,0 +1,30 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:google_tv_dashboard/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}

BIN
flutter_app/web/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html>
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="google_tv_dashboard">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>google_tv_dashboard</title>
<link rel="manifest" href="manifest.json">
</head>
<body>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>

View File

@@ -0,0 +1,35 @@
{
"name": "google_tv_dashboard",
"short_name": "google_tv_dashboard",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "A new Flutter project.",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "icons/Icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/Icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/Icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/Icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}