Initial commit
28
.gitignore
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
13
backend/models/Announcement.js
Normal 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);
|
||||||
13
backend/models/BibleVerse.js
Normal 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);
|
||||||
13
backend/models/FamilyMember.js
Normal 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
@@ -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);
|
||||||
15
backend/models/Schedule.js
Normal 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
@@ -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
@@ -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
20
backend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
69
backend/routes/announcements.js
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
28
flutter_app/analysis_options.yaml
Normal 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
@@ -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
|
||||||
44
flutter_app/android/app/build.gradle.kts
Normal 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 = "../.."
|
||||||
|
}
|
||||||
7
flutter_app/android/app/src/debug/AndroidManifest.xml
Normal 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>
|
||||||
45
flutter_app/android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.example.google_tv_dashboard
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
BIN
flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 544 B |
BIN
flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
18
flutter_app/android/app/src/main/res/values-night/styles.xml
Normal 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>
|
||||||
18
flutter_app/android/app/src/main/res/values/styles.xml
Normal 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>
|
||||||
7
flutter_app/android/app/src/profile/AndroidManifest.xml
Normal 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>
|
||||||
24
flutter_app/android/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
2
flutter_app/android/gradle.properties
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
|
android.useAndroidX=true
|
||||||
5
flutter_app/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
||||||
26
flutter_app/android/settings.gradle.kts
Normal 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")
|
||||||
20
flutter_app/lib/config/api_config.dart
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
class ApiConfig {
|
||||||
|
static const String baseUrl = String.fromEnvironment(
|
||||||
|
"API_BASE_URL",
|
||||||
|
defaultValue: "http://localhost:4000",
|
||||||
|
);
|
||||||
|
|
||||||
|
static const bool useMockData = bool.fromEnvironment(
|
||||||
|
"USE_MOCK_DATA",
|
||||||
|
defaultValue: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const String family = "/api/family";
|
||||||
|
static const String todos = "/api/todos";
|
||||||
|
static const String schedules = "/api/schedules";
|
||||||
|
static const String announcements = "/api/announcements";
|
||||||
|
static const String weather = "/api/weather";
|
||||||
|
static const String bibleToday = "/api/bible/today";
|
||||||
|
static const String bibleVerses = "/api/bible/verses";
|
||||||
|
static const String photos = "/api/photos";
|
||||||
|
}
|
||||||
132
flutter_app/lib/main.dart
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:intl/date_symbol_data_local.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'config/api_config.dart';
|
||||||
|
import 'screens/admin/admin_screen.dart';
|
||||||
|
import 'screens/mobile/mobile_home_screen.dart';
|
||||||
|
import 'screens/tv/tv_dashboard_screen.dart';
|
||||||
|
import 'services/announcement_service.dart';
|
||||||
|
import 'services/api_client.dart';
|
||||||
|
import 'services/bible_service.dart';
|
||||||
|
import 'services/bible_verse_service.dart';
|
||||||
|
import 'services/family_service.dart';
|
||||||
|
import 'services/photo_service.dart';
|
||||||
|
import 'services/schedule_service.dart';
|
||||||
|
import 'services/todo_service.dart';
|
||||||
|
import 'services/weather_service.dart';
|
||||||
|
|
||||||
|
Future<void> main() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
// Hide status bar for TV immersive experience
|
||||||
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||||
|
|
||||||
|
await initializeDateFormatting();
|
||||||
|
|
||||||
|
runApp(const MyApp());
|
||||||
|
}
|
||||||
|
|
||||||
|
class MyApp extends StatelessWidget {
|
||||||
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Shared ApiClient instance
|
||||||
|
final apiClient = ApiClient();
|
||||||
|
|
||||||
|
return MultiProvider(
|
||||||
|
providers: [
|
||||||
|
Provider<ApiClient>.value(value: apiClient),
|
||||||
|
Provider<WeatherService>(create: (_) => WeatherService(apiClient)),
|
||||||
|
Provider<BibleService>(create: (_) => BibleService(apiClient)),
|
||||||
|
Provider<BibleVerseService>(
|
||||||
|
create: (_) => BibleVerseService(apiClient),
|
||||||
|
),
|
||||||
|
Provider<TodoService>(create: (_) => TodoService(apiClient)),
|
||||||
|
Provider<ScheduleService>(create: (_) => ScheduleService(apiClient)),
|
||||||
|
Provider<AnnouncementService>(
|
||||||
|
create: (_) => AnnouncementService(apiClient),
|
||||||
|
),
|
||||||
|
Provider<PhotoService>(create: (_) => PhotoService(apiClient)),
|
||||||
|
Provider<FamilyService>(create: (_) => FamilyService(apiClient)),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
title: 'Bini Google TV Dashboard',
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
theme: ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
scaffoldBackgroundColor: const Color(
|
||||||
|
0xFF0F172A,
|
||||||
|
), // Deep Midnight Navy
|
||||||
|
colorScheme: const ColorScheme.dark(
|
||||||
|
primary: Color(0xFFFFD700), // Cinema Gold
|
||||||
|
onPrimary: Colors.black,
|
||||||
|
secondary: Color(0xFF4FC3F7), // Sky Blue
|
||||||
|
onSecondary: Colors.black,
|
||||||
|
surface: Color(0xFF1E293B), // Slate 800
|
||||||
|
onSurface: Colors.white,
|
||||||
|
background: Color(0xFF0F172A),
|
||||||
|
onBackground: Colors.white,
|
||||||
|
error: Color(0xFFFF6E40), // Deep Orange
|
||||||
|
),
|
||||||
|
cardTheme: CardThemeData(
|
||||||
|
color: const Color(0xFF1E293B),
|
||||||
|
elevation: 4,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textTheme: TextTheme(
|
||||||
|
displayLarge: GoogleFonts.outfit(
|
||||||
|
fontSize: 64,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
), // Header time
|
||||||
|
displayMedium: GoogleFonts.outfit(
|
||||||
|
fontSize: 40,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: const Color(0xFFF1F5F9),
|
||||||
|
), // Section titles
|
||||||
|
bodyLarge: GoogleFonts.mulish(
|
||||||
|
fontSize: 24,
|
||||||
|
color: const Color(0xFFE2E8F0),
|
||||||
|
), // Main content
|
||||||
|
bodyMedium: GoogleFonts.mulish(
|
||||||
|
fontSize: 18,
|
||||||
|
color: const Color(0xFFCBD5E1),
|
||||||
|
), // Secondary content
|
||||||
|
displaySmall: GoogleFonts.outfit(
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
), // Clock usage
|
||||||
|
headlineSmall: GoogleFonts.outfit(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
titleLarge: GoogleFonts.outfit(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
titleMedium: GoogleFonts.outfit(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: const Color(0xFFF1F5F9),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
initialRoute: '/',
|
||||||
|
routes: {
|
||||||
|
'/': (context) => const TvDashboardScreen(),
|
||||||
|
'/mobile': (context) => const MobileHomeScreen(),
|
||||||
|
'/admin': (context) => const AdminScreen(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
flutter_app/lib/models/announcement.dart
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
class Announcement {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String content;
|
||||||
|
final int priority;
|
||||||
|
final bool active;
|
||||||
|
|
||||||
|
const Announcement({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.content,
|
||||||
|
required this.priority,
|
||||||
|
required this.active,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Announcement.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Announcement(
|
||||||
|
id: json["_id"] as String? ?? "",
|
||||||
|
title: json["title"] as String? ?? "",
|
||||||
|
content: json["content"] as String? ?? "",
|
||||||
|
priority: (json["priority"] as num?)?.toInt() ?? 0,
|
||||||
|
active: json["active"] as bool? ?? true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
"title": title,
|
||||||
|
"content": content,
|
||||||
|
"priority": priority,
|
||||||
|
"active": active,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
34
flutter_app/lib/models/bible_verse.dart
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
class BibleVerse {
|
||||||
|
final String id;
|
||||||
|
final String text;
|
||||||
|
final String reference;
|
||||||
|
final String? date;
|
||||||
|
final bool active;
|
||||||
|
|
||||||
|
const BibleVerse({
|
||||||
|
required this.id,
|
||||||
|
required this.text,
|
||||||
|
required this.reference,
|
||||||
|
required this.date,
|
||||||
|
required this.active,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory BibleVerse.fromJson(Map<String, dynamic> json) {
|
||||||
|
return BibleVerse(
|
||||||
|
id: json["_id"] as String? ?? "",
|
||||||
|
text: json["text"] as String? ?? "",
|
||||||
|
reference: json["reference"] as String? ?? "",
|
||||||
|
date: json["date"] as String?,
|
||||||
|
active: json["active"] as bool? ?? true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
"text": text,
|
||||||
|
"reference": reference,
|
||||||
|
"date": date,
|
||||||
|
"active": active,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
29
flutter_app/lib/models/family_member.dart
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
class FamilyMember {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String emoji;
|
||||||
|
final String color;
|
||||||
|
final int order;
|
||||||
|
|
||||||
|
const FamilyMember({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.emoji,
|
||||||
|
required this.color,
|
||||||
|
required this.order,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory FamilyMember.fromJson(Map<String, dynamic> json) {
|
||||||
|
return FamilyMember(
|
||||||
|
id: json["_id"] as String? ?? "",
|
||||||
|
name: json["name"] as String? ?? "",
|
||||||
|
emoji: json["emoji"] as String? ?? "",
|
||||||
|
color: json["color"] as String? ?? "",
|
||||||
|
order: (json["order"] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {"name": name, "emoji": emoji, "color": color, "order": order};
|
||||||
|
}
|
||||||
|
}
|
||||||
26
flutter_app/lib/models/photo.dart
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
class Photo {
|
||||||
|
final String id;
|
||||||
|
final String url;
|
||||||
|
final String caption;
|
||||||
|
final bool active;
|
||||||
|
|
||||||
|
const Photo({
|
||||||
|
required this.id,
|
||||||
|
required this.url,
|
||||||
|
required this.caption,
|
||||||
|
required this.active,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Photo.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Photo(
|
||||||
|
id: json["_id"] as String? ?? "",
|
||||||
|
url: json["url"] as String? ?? "",
|
||||||
|
caption: json["caption"] as String? ?? "",
|
||||||
|
active: json["active"] as bool? ?? true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {"url": url, "caption": caption, "active": active};
|
||||||
|
}
|
||||||
|
}
|
||||||
45
flutter_app/lib/models/schedule_item.dart
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
class ScheduleItem {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final DateTime startDate;
|
||||||
|
final DateTime endDate;
|
||||||
|
final String familyMemberId;
|
||||||
|
final bool isAllDay;
|
||||||
|
|
||||||
|
const ScheduleItem({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.description,
|
||||||
|
required this.startDate,
|
||||||
|
required this.endDate,
|
||||||
|
required this.familyMemberId,
|
||||||
|
required this.isAllDay,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ScheduleItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ScheduleItem(
|
||||||
|
id: json["_id"] as String? ?? "",
|
||||||
|
title: json["title"] as String? ?? "",
|
||||||
|
description: json["description"] as String? ?? "",
|
||||||
|
startDate:
|
||||||
|
DateTime.tryParse(json["startDate"] as String? ?? "") ??
|
||||||
|
DateTime.now(),
|
||||||
|
endDate:
|
||||||
|
DateTime.tryParse(json["endDate"] as String? ?? "") ?? DateTime.now(),
|
||||||
|
familyMemberId: json["familyMemberId"] as String? ?? "",
|
||||||
|
isAllDay: json["isAllDay"] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
"title": title,
|
||||||
|
"description": description,
|
||||||
|
"startDate": startDate.toIso8601String(),
|
||||||
|
"endDate": endDate.toIso8601String(),
|
||||||
|
"familyMemberId": familyMemberId,
|
||||||
|
"isAllDay": isAllDay,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
36
flutter_app/lib/models/todo_item.dart
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
class TodoItem {
|
||||||
|
final String id;
|
||||||
|
final String familyMemberId;
|
||||||
|
final String title;
|
||||||
|
final bool completed;
|
||||||
|
final DateTime? dueDate;
|
||||||
|
|
||||||
|
const TodoItem({
|
||||||
|
required this.id,
|
||||||
|
required this.familyMemberId,
|
||||||
|
required this.title,
|
||||||
|
required this.completed,
|
||||||
|
required this.dueDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TodoItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
return TodoItem(
|
||||||
|
id: json["_id"] as String? ?? "",
|
||||||
|
familyMemberId: json["familyMemberId"] as String? ?? "",
|
||||||
|
title: json["title"] as String? ?? "",
|
||||||
|
completed: json["completed"] as bool? ?? false,
|
||||||
|
dueDate: json["dueDate"] != null
|
||||||
|
? DateTime.tryParse(json["dueDate"] as String)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
"familyMemberId": familyMemberId,
|
||||||
|
"title": title,
|
||||||
|
"completed": completed,
|
||||||
|
"dueDate": dueDate?.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
25
flutter_app/lib/models/weather_info.dart
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
class WeatherInfo {
|
||||||
|
final String description;
|
||||||
|
final double temperature;
|
||||||
|
final String icon;
|
||||||
|
final String city;
|
||||||
|
|
||||||
|
const WeatherInfo({
|
||||||
|
required this.description,
|
||||||
|
required this.temperature,
|
||||||
|
required this.icon,
|
||||||
|
required this.city,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory WeatherInfo.fromJson(Map<String, dynamic> json) {
|
||||||
|
final weather = (json["weather"] as List<dynamic>? ?? []).isNotEmpty
|
||||||
|
? json["weather"][0] as Map<String, dynamic>
|
||||||
|
: {};
|
||||||
|
return WeatherInfo(
|
||||||
|
description: weather["description"] as String? ?? "",
|
||||||
|
temperature: (json["main"]?["temp"] as num?)?.toDouble() ?? 0,
|
||||||
|
icon: weather["icon"] as String? ?? "",
|
||||||
|
city: json["name"] as String? ?? "",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
450
flutter_app/lib/screens/admin/admin_screen.dart
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../models/bible_verse.dart';
|
||||||
|
import '../../models/family_member.dart';
|
||||||
|
import '../../models/photo.dart';
|
||||||
|
import '../../services/bible_verse_service.dart';
|
||||||
|
import '../../services/family_service.dart';
|
||||||
|
import '../../services/photo_service.dart';
|
||||||
|
|
||||||
|
class AdminScreen extends StatefulWidget {
|
||||||
|
const AdminScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AdminScreen> createState() => _AdminScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminScreenState extends State<AdminScreen> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return DefaultTabController(
|
||||||
|
length: 3,
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Admin Settings'),
|
||||||
|
bottom: const TabBar(
|
||||||
|
tabs: [
|
||||||
|
Tab(text: 'Family Members'),
|
||||||
|
Tab(text: 'Photos'),
|
||||||
|
Tab(text: 'Bible Verses'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: const TabBarView(
|
||||||
|
children: [
|
||||||
|
FamilyManagerTab(),
|
||||||
|
PhotoManagerTab(),
|
||||||
|
BibleVerseManagerTab(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FamilyManagerTab extends StatefulWidget {
|
||||||
|
const FamilyManagerTab({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FamilyManagerTab> createState() => _FamilyManagerTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FamilyManagerTabState extends State<FamilyManagerTab> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: () => _showAddMemberDialog(context),
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
body: FutureBuilder<List<FamilyMember>>(
|
||||||
|
future: Provider.of<FamilyService>(context).fetchFamilyMembers(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (!snapshot.hasData)
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
final members = snapshot.data!;
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: members.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final member = members[index];
|
||||||
|
Color memberColor;
|
||||||
|
try {
|
||||||
|
memberColor = Color(
|
||||||
|
int.parse(member.color.replaceAll('#', '0xFF')),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
memberColor = Colors.grey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: memberColor,
|
||||||
|
child: Text(
|
||||||
|
member.emoji,
|
||||||
|
style: const TextStyle(fontSize: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(member.name),
|
||||||
|
subtitle: Text('Order: ${member.order}'),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.delete, color: Colors.red),
|
||||||
|
onPressed: () async {
|
||||||
|
await Provider.of<FamilyService>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
).deleteFamilyMember(member.id);
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAddMemberDialog(BuildContext context) {
|
||||||
|
final nameController = TextEditingController();
|
||||||
|
final emojiController = TextEditingController(text: '👤');
|
||||||
|
final colorController = TextEditingController(text: '0xFFFFD700');
|
||||||
|
final orderController = TextEditingController(text: '1');
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Add Family Member'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: nameController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Name'),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: emojiController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Emoji'),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: colorController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Color (Hex 0xAARRGGBB)',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: orderController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Order'),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
if (nameController.text.isNotEmpty) {
|
||||||
|
await Provider.of<FamilyService>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
).createFamilyMember(
|
||||||
|
FamilyMember(
|
||||||
|
id: '',
|
||||||
|
name: nameController.text,
|
||||||
|
emoji: emojiController.text,
|
||||||
|
color: colorController.text.replaceFirst(
|
||||||
|
'0xFF',
|
||||||
|
'#',
|
||||||
|
), // Simple conversion
|
||||||
|
order: int.tryParse(orderController.text) ?? 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Add'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PhotoManagerTab extends StatefulWidget {
|
||||||
|
const PhotoManagerTab({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PhotoManagerTab> createState() => _PhotoManagerTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PhotoManagerTabState extends State<PhotoManagerTab> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: () => _showAddPhotoDialog(context),
|
||||||
|
child: const Icon(Icons.add_a_photo),
|
||||||
|
),
|
||||||
|
body: FutureBuilder<List<Photo>>(
|
||||||
|
future: Provider.of<PhotoService>(context).fetchPhotos(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (!snapshot.hasData)
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
final photos = snapshot.data!;
|
||||||
|
return GridView.builder(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 3,
|
||||||
|
crossAxisSpacing: 8,
|
||||||
|
mainAxisSpacing: 8,
|
||||||
|
),
|
||||||
|
itemCount: photos.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final photo = photos[index];
|
||||||
|
return GridTile(
|
||||||
|
footer: GridTileBar(
|
||||||
|
backgroundColor: Colors.black54,
|
||||||
|
title: Text(photo.caption),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.delete, color: Colors.white),
|
||||||
|
onPressed: () async {
|
||||||
|
await Provider.of<PhotoService>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
).deletePhoto(photo.id);
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Image.network(
|
||||||
|
photo.url,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) =>
|
||||||
|
const Center(child: Icon(Icons.broken_image)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAddPhotoDialog(BuildContext context) {
|
||||||
|
final urlController = TextEditingController();
|
||||||
|
final captionController = TextEditingController();
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Add Photo URL'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: urlController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Image URL'),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: captionController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Caption'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
if (urlController.text.isNotEmpty) {
|
||||||
|
await Provider.of<PhotoService>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
).createPhoto(
|
||||||
|
Photo(
|
||||||
|
id: '',
|
||||||
|
url: urlController.text,
|
||||||
|
caption: captionController.text,
|
||||||
|
active: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Add'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BibleVerseManagerTab extends StatefulWidget {
|
||||||
|
const BibleVerseManagerTab({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BibleVerseManagerTab> createState() => _BibleVerseManagerTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BibleVerseManagerTabState extends State<BibleVerseManagerTab> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: () => _showAddVerseDialog(context),
|
||||||
|
child: const Icon(Icons.menu_book),
|
||||||
|
),
|
||||||
|
body: FutureBuilder<List<BibleVerse>>(
|
||||||
|
future: Provider.of<BibleVerseService>(context).fetchVerses(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (!snapshot.hasData) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
final verses = snapshot.data!;
|
||||||
|
if (verses.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: Text(
|
||||||
|
'No verses added yet',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: verses.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final verse = verses[index];
|
||||||
|
return ListTile(
|
||||||
|
title: Text(verse.reference),
|
||||||
|
subtitle: Text(
|
||||||
|
verse.text,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (verse.date != null && verse.date!.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
verse.date!,
|
||||||
|
style:
|
||||||
|
const TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete, color: Colors.red),
|
||||||
|
onPressed: () async {
|
||||||
|
await Provider.of<BibleVerseService>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
).deleteVerse(verse.id);
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAddVerseDialog(BuildContext context) {
|
||||||
|
final textController = TextEditingController();
|
||||||
|
final referenceController = TextEditingController();
|
||||||
|
final dateController = TextEditingController();
|
||||||
|
bool isActive = true;
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setDialogState) => AlertDialog(
|
||||||
|
title: const Text('Add Bible Verse'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: referenceController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Reference (e.g., Psalms 23:1)',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: textController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Verse Text (Korean)',
|
||||||
|
),
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: dateController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Date (YYYY-MM-DD) - Optional',
|
||||||
|
hintText: '2024-01-01',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Active'),
|
||||||
|
value: isActive,
|
||||||
|
onChanged: (value) {
|
||||||
|
setDialogState(() {
|
||||||
|
isActive = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
if (textController.text.isNotEmpty &&
|
||||||
|
referenceController.text.isNotEmpty) {
|
||||||
|
await Provider.of<BibleVerseService>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
).createVerse(
|
||||||
|
BibleVerse(
|
||||||
|
id: '',
|
||||||
|
text: textController.text,
|
||||||
|
reference: referenceController.text,
|
||||||
|
date: dateController.text.isEmpty
|
||||||
|
? null
|
||||||
|
: dateController.text,
|
||||||
|
active: isActive,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Add'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
431
flutter_app/lib/screens/mobile/mobile_home_screen.dart
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../../models/todo_item.dart';
|
||||||
|
import '../../models/schedule_item.dart';
|
||||||
|
import '../../models/announcement.dart';
|
||||||
|
import '../../models/family_member.dart';
|
||||||
|
import '../../services/todo_service.dart';
|
||||||
|
import '../../services/schedule_service.dart';
|
||||||
|
import '../../services/announcement_service.dart';
|
||||||
|
import '../../services/family_service.dart';
|
||||||
|
|
||||||
|
class MobileHomeScreen extends StatefulWidget {
|
||||||
|
const MobileHomeScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MobileHomeScreen> createState() => _MobileHomeScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MobileHomeScreenState extends State<MobileHomeScreen> {
|
||||||
|
int _currentIndex = 0;
|
||||||
|
|
||||||
|
final List<Widget> _screens = [
|
||||||
|
const MobileTodoScreen(),
|
||||||
|
const MobileScheduleScreen(),
|
||||||
|
const MobileAnnouncementScreen(),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Bini Family Manager'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.settings),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pushNamed(context, '/admin');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _screens[_currentIndex],
|
||||||
|
bottomNavigationBar: BottomNavigationBar(
|
||||||
|
currentIndex: _currentIndex,
|
||||||
|
onTap: (index) {
|
||||||
|
setState(() {
|
||||||
|
_currentIndex = index;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
items: const [
|
||||||
|
BottomNavigationBarItem(icon: Icon(Icons.check_box), label: 'Todos'),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.calendar_today),
|
||||||
|
label: 'Schedule',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(icon: Icon(Icons.campaign), label: 'Notices'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MobileTodoScreen extends StatefulWidget {
|
||||||
|
const MobileTodoScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MobileTodoScreen> createState() => _MobileTodoScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MobileTodoScreenState extends State<MobileTodoScreen> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: () => _showAddTodoDialog(context),
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
body: FutureBuilder<List<TodoItem>>(
|
||||||
|
future: Provider.of<TodoService>(
|
||||||
|
context,
|
||||||
|
).fetchTodos(), // Fetch all or today? Let's fetch all for manager
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (!snapshot.hasData)
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
final todos = snapshot.data!;
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: todos.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final todo = todos[index];
|
||||||
|
return ListTile(
|
||||||
|
title: Text(todo.title),
|
||||||
|
subtitle: Text(
|
||||||
|
todo.dueDate != null
|
||||||
|
? DateFormat('MM/dd').format(todo.dueDate!)
|
||||||
|
: 'No date',
|
||||||
|
),
|
||||||
|
trailing: Checkbox(
|
||||||
|
value: todo.completed,
|
||||||
|
onChanged: (val) async {
|
||||||
|
await Provider.of<TodoService>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
).updateTodo(
|
||||||
|
TodoItem(
|
||||||
|
id: todo.id,
|
||||||
|
familyMemberId: todo.familyMemberId,
|
||||||
|
title: todo.title,
|
||||||
|
completed: val ?? false,
|
||||||
|
dueDate: todo.dueDate,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
onLongPress: () async {
|
||||||
|
await Provider.of<TodoService>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
).deleteTodo(todo.id);
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAddTodoDialog(BuildContext context) async {
|
||||||
|
final titleController = TextEditingController();
|
||||||
|
final familyMembers = await Provider.of<FamilyService>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
).fetchFamilyMembers();
|
||||||
|
String? selectedMemberId =
|
||||||
|
familyMembers.isNotEmpty ? familyMembers.first.id : null;
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Add Todo'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: titleController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Task'),
|
||||||
|
),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: selectedMemberId,
|
||||||
|
items: familyMembers
|
||||||
|
.map(
|
||||||
|
(m) => DropdownMenuItem(value: m.id, child: Text(m.name)),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
onChanged: (val) => selectedMemberId = val,
|
||||||
|
decoration: const InputDecoration(labelText: 'Assign to'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
if (selectedMemberId != null && titleController.text.isNotEmpty) {
|
||||||
|
await Provider.of<TodoService>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
).createTodo(
|
||||||
|
TodoItem(
|
||||||
|
id: '',
|
||||||
|
familyMemberId: selectedMemberId!,
|
||||||
|
title: titleController.text,
|
||||||
|
completed: false,
|
||||||
|
dueDate: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Add'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MobileScheduleScreen extends StatefulWidget {
|
||||||
|
const MobileScheduleScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MobileScheduleScreen> createState() => _MobileScheduleScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MobileScheduleScreenState extends State<MobileScheduleScreen> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: () => _showAddScheduleDialog(context),
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
body: FutureBuilder<List<ScheduleItem>>(
|
||||||
|
future: Provider.of<ScheduleService>(context).fetchSchedules(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (!snapshot.hasData)
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
final schedules = snapshot.data!;
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: schedules.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = schedules[index];
|
||||||
|
return ListTile(
|
||||||
|
title: Text(item.title),
|
||||||
|
subtitle: Text(
|
||||||
|
'${DateFormat('MM/dd HH:mm').format(item.startDate)} - ${item.description}',
|
||||||
|
),
|
||||||
|
onLongPress: () async {
|
||||||
|
await Provider.of<ScheduleService>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
).deleteSchedule(item.id);
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAddScheduleDialog(BuildContext context) async {
|
||||||
|
final titleController = TextEditingController();
|
||||||
|
final descController = TextEditingController();
|
||||||
|
final familyMembers = await Provider.of<FamilyService>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
).fetchFamilyMembers();
|
||||||
|
String? selectedMemberId =
|
||||||
|
familyMembers.isNotEmpty ? familyMembers.first.id : null;
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Add Schedule'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: titleController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Title'),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: descController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Description'),
|
||||||
|
),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: selectedMemberId,
|
||||||
|
items: familyMembers
|
||||||
|
.map(
|
||||||
|
(m) => DropdownMenuItem(value: m.id, child: Text(m.name)),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
onChanged: (val) => selectedMemberId = val,
|
||||||
|
decoration: const InputDecoration(labelText: 'For whom?'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
if (selectedMemberId != null && titleController.text.isNotEmpty) {
|
||||||
|
await Provider.of<ScheduleService>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
).createSchedule(
|
||||||
|
ScheduleItem(
|
||||||
|
id: '',
|
||||||
|
title: titleController.text,
|
||||||
|
description: descController.text,
|
||||||
|
startDate: DateTime.now(),
|
||||||
|
endDate: DateTime.now().add(const Duration(hours: 1)),
|
||||||
|
familyMemberId: selectedMemberId!,
|
||||||
|
isAllDay: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Add'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MobileAnnouncementScreen extends StatefulWidget {
|
||||||
|
const MobileAnnouncementScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MobileAnnouncementScreen> createState() =>
|
||||||
|
_MobileAnnouncementScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MobileAnnouncementScreenState extends State<MobileAnnouncementScreen> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: () => _showAddAnnouncementDialog(context),
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
body: FutureBuilder<List<Announcement>>(
|
||||||
|
future: Provider.of<AnnouncementService>(context).fetchAnnouncements(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (!snapshot.hasData)
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
final items = snapshot.data!;
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
return ListTile(
|
||||||
|
title: Text(item.title),
|
||||||
|
subtitle: Text(item.content),
|
||||||
|
trailing: Switch(
|
||||||
|
value: item.active,
|
||||||
|
onChanged: (val) async {
|
||||||
|
await Provider.of<AnnouncementService>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
).updateAnnouncement(
|
||||||
|
Announcement(
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
content: item.content,
|
||||||
|
priority: item.priority,
|
||||||
|
active: val,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
onLongPress: () async {
|
||||||
|
await Provider.of<AnnouncementService>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
).deleteAnnouncement(item.id);
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAddAnnouncementDialog(BuildContext context) {
|
||||||
|
final titleController = TextEditingController();
|
||||||
|
final contentController = TextEditingController();
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Add Announcement'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: titleController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Title'),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: contentController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Content'),
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
if (titleController.text.isNotEmpty) {
|
||||||
|
await Provider.of<AnnouncementService>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
).createAnnouncement(
|
||||||
|
Announcement(
|
||||||
|
id: '',
|
||||||
|
title: titleController.text,
|
||||||
|
content: contentController.text,
|
||||||
|
priority: 1,
|
||||||
|
active: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Add'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
flutter_app/lib/screens/tv/tv_dashboard_screen.dart
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../widgets/digital_clock_widget.dart';
|
||||||
|
import '../../widgets/weather_widget.dart';
|
||||||
|
import '../../widgets/calendar_widget.dart';
|
||||||
|
import '../../widgets/schedule_list_widget.dart';
|
||||||
|
import '../../widgets/announcement_widget.dart';
|
||||||
|
import '../../widgets/photo_slideshow_widget.dart';
|
||||||
|
import '../../widgets/todo_list_widget.dart';
|
||||||
|
import '../../widgets/bible_verse_widget.dart';
|
||||||
|
|
||||||
|
class TvDashboardScreen extends StatefulWidget {
|
||||||
|
const TvDashboardScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TvDashboardScreen> createState() => _TvDashboardScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TvDashboardScreenState extends State<TvDashboardScreen> {
|
||||||
|
// Timer for periodic refresh (every 5 minutes for data, 1 second for clock)
|
||||||
|
Timer? _dataRefreshTimer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Initial data fetch could be triggered here or within widgets
|
||||||
|
_startDataRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startDataRefresh() {
|
||||||
|
_dataRefreshTimer = Timer.periodic(const Duration(minutes: 5), (timer) {
|
||||||
|
// Trigger refreshes if needed, or let widgets handle their own polling
|
||||||
|
// For simplicity, we assume widgets or providers handle their data
|
||||||
|
setState(() {}); // Rebuild to refresh UI state if needed
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_dataRefreshTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// 1920x1080 reference
|
||||||
|
return Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32.0), // Outer margin safe zone
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Header: Time and Weather
|
||||||
|
SizedBox(
|
||||||
|
height: 100,
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [const DigitalClockWidget(), const WeatherWidget()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
// Main Content Grid
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Left Column: Calendar, Schedule, Announcement
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const Expanded(flex: 4, child: CalendarWidget()),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Expanded(flex: 4, child: ScheduleListWidget()),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Expanded(flex: 2, child: AnnouncementWidget()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
// Center Column: Photo Slideshow
|
||||||
|
Expanded(
|
||||||
|
flex: 4,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.5),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: const PhotoSlideshowWidget(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
// Right Column: Todos, Bible Verse
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const Expanded(flex: 6, child: TodoListWidget()),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Expanded(flex: 3, child: BibleVerseWidget()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Hidden trigger for admin/mobile view (e.g. long press corner)
|
||||||
|
GestureDetector(
|
||||||
|
onLongPress: () {
|
||||||
|
Navigator.of(context).pushNamed('/admin');
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
color: Colors.transparent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
flutter_app/lib/services/announcement_service.dart
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import "../config/api_config.dart";
|
||||||
|
import "../models/announcement.dart";
|
||||||
|
import "api_client.dart";
|
||||||
|
import "mock_data.dart";
|
||||||
|
|
||||||
|
class AnnouncementService {
|
||||||
|
final ApiClient _client;
|
||||||
|
|
||||||
|
AnnouncementService(this._client);
|
||||||
|
|
||||||
|
Future<List<Announcement>> fetchAnnouncements({
|
||||||
|
bool activeOnly = false,
|
||||||
|
}) async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
final items = List<Announcement>.from(MockDataStore.announcements);
|
||||||
|
if (activeOnly) {
|
||||||
|
return items.where((item) => item.active).toList();
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
final query = activeOnly ? {"active": "true"} : null;
|
||||||
|
final data = await _client.getList(ApiConfig.announcements, query: query);
|
||||||
|
return data
|
||||||
|
.map((item) => Announcement.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Announcement> createAnnouncement(Announcement announcement) async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
final created = Announcement(
|
||||||
|
id: "announcement-${DateTime.now().millisecondsSinceEpoch}",
|
||||||
|
title: announcement.title,
|
||||||
|
content: announcement.content,
|
||||||
|
priority: announcement.priority,
|
||||||
|
active: announcement.active,
|
||||||
|
);
|
||||||
|
MockDataStore.announcements.add(created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
final data = await _client.post(
|
||||||
|
ApiConfig.announcements,
|
||||||
|
announcement.toJson(),
|
||||||
|
);
|
||||||
|
return Announcement.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Announcement> updateAnnouncement(Announcement announcement) async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
final index = MockDataStore.announcements.indexWhere(
|
||||||
|
(item) => item.id == announcement.id,
|
||||||
|
);
|
||||||
|
if (index != -1) {
|
||||||
|
MockDataStore.announcements[index] = announcement;
|
||||||
|
}
|
||||||
|
return announcement;
|
||||||
|
}
|
||||||
|
final data = await _client.put(
|
||||||
|
"${ApiConfig.announcements}/${announcement.id}",
|
||||||
|
announcement.toJson(),
|
||||||
|
);
|
||||||
|
return Announcement.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteAnnouncement(String id) async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
MockDataStore.announcements.removeWhere((item) => item.id == id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _client.delete("${ApiConfig.announcements}/$id");
|
||||||
|
}
|
||||||
|
}
|
||||||
71
flutter_app/lib/services/api_client.dart
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import "dart:convert";
|
||||||
|
import "package:http/http.dart" as http;
|
||||||
|
import "../config/api_config.dart";
|
||||||
|
|
||||||
|
class ApiClient {
|
||||||
|
final http.Client _client;
|
||||||
|
|
||||||
|
ApiClient({http.Client? client}) : _client = client ?? http.Client();
|
||||||
|
|
||||||
|
Uri _uri(String path, [Map<String, dynamic>? query]) {
|
||||||
|
return Uri.parse(ApiConfig.baseUrl).replace(
|
||||||
|
path: path,
|
||||||
|
queryParameters: query?.map((key, value) => MapEntry(key, "$value")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<dynamic>> getList(
|
||||||
|
String path, {
|
||||||
|
Map<String, dynamic>? query,
|
||||||
|
}) async {
|
||||||
|
final response = await _client.get(_uri(path, query));
|
||||||
|
_ensureSuccess(response);
|
||||||
|
return jsonDecode(response.body) as List<dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> getMap(
|
||||||
|
String path, {
|
||||||
|
Map<String, dynamic>? query,
|
||||||
|
}) async {
|
||||||
|
final response = await _client.get(_uri(path, query));
|
||||||
|
_ensureSuccess(response);
|
||||||
|
return jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> post(
|
||||||
|
String path,
|
||||||
|
Map<String, dynamic> body,
|
||||||
|
) async {
|
||||||
|
final response = await _client.post(
|
||||||
|
_uri(path),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
_ensureSuccess(response);
|
||||||
|
return jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> put(
|
||||||
|
String path,
|
||||||
|
Map<String, dynamic> body,
|
||||||
|
) async {
|
||||||
|
final response = await _client.put(
|
||||||
|
_uri(path),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
_ensureSuccess(response);
|
||||||
|
return jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> delete(String path) async {
|
||||||
|
final response = await _client.delete(_uri(path));
|
||||||
|
_ensureSuccess(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _ensureSuccess(http.Response response) {
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throw Exception("Request failed: ${response.statusCode}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
flutter_app/lib/services/bible_service.dart
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import "../config/api_config.dart";
|
||||||
|
import "../models/bible_verse.dart";
|
||||||
|
import "api_client.dart";
|
||||||
|
import "mock_data.dart";
|
||||||
|
|
||||||
|
class BibleService {
|
||||||
|
final ApiClient _client;
|
||||||
|
|
||||||
|
BibleService(this._client);
|
||||||
|
|
||||||
|
Future<BibleVerse> fetchTodayVerse({String? date}) async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
final verses = MockDataStore.bibleVerses;
|
||||||
|
if (verses.isEmpty) {
|
||||||
|
return MockDataStore.bible;
|
||||||
|
}
|
||||||
|
verses.shuffle();
|
||||||
|
return verses.first;
|
||||||
|
}
|
||||||
|
final data = await _client.getMap(
|
||||||
|
ApiConfig.bibleToday,
|
||||||
|
query: date == null ? null : {"date": date},
|
||||||
|
);
|
||||||
|
return BibleVerse.fromJson(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
flutter_app/lib/services/bible_verse_service.dart
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import "../config/api_config.dart";
|
||||||
|
import "../models/bible_verse.dart";
|
||||||
|
import "api_client.dart";
|
||||||
|
import "mock_data.dart";
|
||||||
|
|
||||||
|
class BibleVerseService {
|
||||||
|
final ApiClient _client;
|
||||||
|
|
||||||
|
BibleVerseService(this._client);
|
||||||
|
|
||||||
|
Future<List<BibleVerse>> fetchVerses({bool activeOnly = false}) async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
final items = List<BibleVerse>.from(MockDataStore.bibleVerses);
|
||||||
|
if (activeOnly) {
|
||||||
|
return items.where((item) => item.active).toList();
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
final query = activeOnly ? {"active": "true"} : null;
|
||||||
|
final data = await _client.getList(ApiConfig.bibleVerses, query: query);
|
||||||
|
return data
|
||||||
|
.map((item) => BibleVerse.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BibleVerse> createVerse(BibleVerse verse) async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
final created = BibleVerse(
|
||||||
|
id: "bible-${DateTime.now().millisecondsSinceEpoch}",
|
||||||
|
text: verse.text,
|
||||||
|
reference: verse.reference,
|
||||||
|
date: verse.date,
|
||||||
|
active: verse.active,
|
||||||
|
);
|
||||||
|
MockDataStore.bibleVerses.add(created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
final data = await _client.post(ApiConfig.bibleVerses, verse.toJson());
|
||||||
|
return BibleVerse.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BibleVerse> updateVerse(BibleVerse verse) async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
final index = MockDataStore.bibleVerses.indexWhere(
|
||||||
|
(item) => item.id == verse.id,
|
||||||
|
);
|
||||||
|
if (index != -1) {
|
||||||
|
MockDataStore.bibleVerses[index] = verse;
|
||||||
|
}
|
||||||
|
return verse;
|
||||||
|
}
|
||||||
|
final data = await _client.put(
|
||||||
|
"${ApiConfig.bibleVerses}/${verse.id}",
|
||||||
|
verse.toJson(),
|
||||||
|
);
|
||||||
|
return BibleVerse.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteVerse(String id) async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
MockDataStore.bibleVerses.removeWhere((item) => item.id == id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _client.delete("${ApiConfig.bibleVerses}/$id");
|
||||||
|
}
|
||||||
|
}
|
||||||
71
flutter_app/lib/services/family_service.dart
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import "../config/api_config.dart";
|
||||||
|
import "../models/family_member.dart";
|
||||||
|
import "api_client.dart";
|
||||||
|
import "mock_data.dart";
|
||||||
|
|
||||||
|
class FamilyService {
|
||||||
|
final ApiClient _client;
|
||||||
|
|
||||||
|
FamilyService(this._client);
|
||||||
|
|
||||||
|
Future<List<FamilyMember>> fetchFamilyMembers() async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
return List<FamilyMember>.from(MockDataStore.familyMembers);
|
||||||
|
}
|
||||||
|
final data = await _client.getList(ApiConfig.family);
|
||||||
|
return data
|
||||||
|
.map((item) => FamilyMember.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<FamilyMember> createFamilyMember(FamilyMember member) async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
final created = FamilyMember(
|
||||||
|
id: "family-${DateTime.now().millisecondsSinceEpoch}",
|
||||||
|
name: member.name,
|
||||||
|
emoji: member.emoji,
|
||||||
|
color: member.color,
|
||||||
|
order: member.order,
|
||||||
|
);
|
||||||
|
MockDataStore.familyMembers.add(created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
final data = await _client.post(ApiConfig.family, member.toJson());
|
||||||
|
return FamilyMember.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<FamilyMember> updateFamilyMember(FamilyMember member) async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
final index = MockDataStore.familyMembers.indexWhere(
|
||||||
|
(item) => item.id == member.id,
|
||||||
|
);
|
||||||
|
if (index != -1) {
|
||||||
|
MockDataStore.familyMembers[index] = member;
|
||||||
|
}
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
final data = await _client.put(
|
||||||
|
"${ApiConfig.family}/${member.id}",
|
||||||
|
member.toJson(),
|
||||||
|
);
|
||||||
|
return FamilyMember.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteFamilyMember(String id) async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
MockDataStore.familyMembers.removeWhere((item) => item.id == id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _client.delete("${ApiConfig.family}/$id");
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<FamilyMember>> fetchMembers() => fetchFamilyMembers();
|
||||||
|
|
||||||
|
Future<FamilyMember> createMember(FamilyMember member) =>
|
||||||
|
createFamilyMember(member);
|
||||||
|
|
||||||
|
Future<FamilyMember> updateMember(FamilyMember member) =>
|
||||||
|
updateFamilyMember(member);
|
||||||
|
|
||||||
|
Future<void> deleteMember(String id) => deleteFamilyMember(id);
|
||||||
|
}
|
||||||
149
flutter_app/lib/services/mock_data.dart
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import "../models/announcement.dart";
|
||||||
|
import "../models/bible_verse.dart";
|
||||||
|
import "../models/family_member.dart";
|
||||||
|
import "../models/photo.dart";
|
||||||
|
import "../models/schedule_item.dart";
|
||||||
|
import "../models/todo_item.dart";
|
||||||
|
import "../models/weather_info.dart";
|
||||||
|
|
||||||
|
class MockDataStore {
|
||||||
|
static final List<FamilyMember> familyMembers = [
|
||||||
|
const FamilyMember(
|
||||||
|
id: "family-1",
|
||||||
|
name: "Dad",
|
||||||
|
emoji: ":)",
|
||||||
|
color: "#0F766E",
|
||||||
|
order: 1,
|
||||||
|
),
|
||||||
|
const FamilyMember(
|
||||||
|
id: "family-2",
|
||||||
|
name: "Mom",
|
||||||
|
emoji: "<3",
|
||||||
|
color: "#C2410C",
|
||||||
|
order: 2,
|
||||||
|
),
|
||||||
|
const FamilyMember(
|
||||||
|
id: "family-3",
|
||||||
|
name: "Son",
|
||||||
|
emoji: ":D",
|
||||||
|
color: "#1D4ED8",
|
||||||
|
order: 3,
|
||||||
|
),
|
||||||
|
const FamilyMember(
|
||||||
|
id: "family-4",
|
||||||
|
name: "Daughter",
|
||||||
|
emoji: ":-)",
|
||||||
|
color: "#7C3AED",
|
||||||
|
order: 4,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
static final List<TodoItem> todos = [
|
||||||
|
TodoItem(
|
||||||
|
id: "todo-1",
|
||||||
|
familyMemberId: "family-1",
|
||||||
|
title: "Grocery run",
|
||||||
|
completed: false,
|
||||||
|
dueDate: DateTime.now(),
|
||||||
|
),
|
||||||
|
TodoItem(
|
||||||
|
id: "todo-2",
|
||||||
|
familyMemberId: "family-2",
|
||||||
|
title: "Team meeting",
|
||||||
|
completed: false,
|
||||||
|
dueDate: DateTime.now(),
|
||||||
|
),
|
||||||
|
TodoItem(
|
||||||
|
id: "todo-3",
|
||||||
|
familyMemberId: "family-3",
|
||||||
|
title: "Math homework",
|
||||||
|
completed: false,
|
||||||
|
dueDate: DateTime.now(),
|
||||||
|
),
|
||||||
|
TodoItem(
|
||||||
|
id: "todo-4",
|
||||||
|
familyMemberId: "family-4",
|
||||||
|
title: "Piano lesson",
|
||||||
|
completed: false,
|
||||||
|
dueDate: DateTime.now().add(const Duration(days: 1)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
static final List<ScheduleItem> schedules = [
|
||||||
|
ScheduleItem(
|
||||||
|
id: "schedule-1",
|
||||||
|
title: "Family dinner",
|
||||||
|
description: "Everyone at home",
|
||||||
|
startDate: DateTime.now(),
|
||||||
|
endDate: DateTime.now().add(const Duration(hours: 2)),
|
||||||
|
familyMemberId: "family-1",
|
||||||
|
isAllDay: false,
|
||||||
|
),
|
||||||
|
ScheduleItem(
|
||||||
|
id: "schedule-2",
|
||||||
|
title: "Soccer practice",
|
||||||
|
description: "School field",
|
||||||
|
startDate: DateTime.now().add(const Duration(hours: 3)),
|
||||||
|
endDate: DateTime.now().add(const Duration(hours: 4)),
|
||||||
|
familyMemberId: "family-3",
|
||||||
|
isAllDay: false,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
static final List<Announcement> announcements = [
|
||||||
|
const Announcement(
|
||||||
|
id: "announcement-1",
|
||||||
|
title: "Weekend trip",
|
||||||
|
content: "Pack light and be ready by 8 AM",
|
||||||
|
priority: 2,
|
||||||
|
active: true,
|
||||||
|
),
|
||||||
|
const Announcement(
|
||||||
|
id: "announcement-2",
|
||||||
|
title: "Trash day",
|
||||||
|
content: "Take out bins tonight",
|
||||||
|
priority: 1,
|
||||||
|
active: true,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
static final List<Photo> photos = [
|
||||||
|
const Photo(
|
||||||
|
id: "photo-1",
|
||||||
|
url: "https://picsum.photos/1200/800?random=21",
|
||||||
|
caption: "Summer vacation",
|
||||||
|
active: true,
|
||||||
|
),
|
||||||
|
const Photo(
|
||||||
|
id: "photo-2",
|
||||||
|
url: "https://picsum.photos/1200/800?random=22",
|
||||||
|
caption: "Family hike",
|
||||||
|
active: true,
|
||||||
|
),
|
||||||
|
const Photo(
|
||||||
|
id: "photo-3",
|
||||||
|
url: "https://picsum.photos/1200/800?random=23",
|
||||||
|
caption: "Birthday party",
|
||||||
|
active: true,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
static WeatherInfo weather = const WeatherInfo(
|
||||||
|
description: "clear sky",
|
||||||
|
temperature: 12,
|
||||||
|
icon: "01d",
|
||||||
|
city: "Seoul",
|
||||||
|
);
|
||||||
|
|
||||||
|
static final List<BibleVerse> bibleVerses = [
|
||||||
|
const BibleVerse(
|
||||||
|
id: "bible-1",
|
||||||
|
text: "여호와를 경외하는 것이 지식의 근본이니라.",
|
||||||
|
reference: "잠언 1:7",
|
||||||
|
date: null,
|
||||||
|
active: true,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
static BibleVerse bible = bibleVerses.first;
|
||||||
|
}
|
||||||
48
flutter_app/lib/services/photo_service.dart
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import "../config/api_config.dart";
|
||||||
|
import "../models/photo.dart";
|
||||||
|
import "api_client.dart";
|
||||||
|
import "mock_data.dart";
|
||||||
|
|
||||||
|
class PhotoService {
|
||||||
|
final ApiClient _client;
|
||||||
|
|
||||||
|
PhotoService(this._client);
|
||||||
|
|
||||||
|
Future<List<Photo>> fetchPhotos({bool activeOnly = false}) async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
final items = List<Photo>.from(MockDataStore.photos);
|
||||||
|
if (activeOnly) {
|
||||||
|
return items.where((item) => item.active).toList();
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
final query = activeOnly ? {"active": "true"} : null;
|
||||||
|
final data = await _client.getList(ApiConfig.photos, query: query);
|
||||||
|
return data
|
||||||
|
.map((item) => Photo.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Photo> createPhoto(Photo photo) async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
final created = Photo(
|
||||||
|
id: "photo-${DateTime.now().millisecondsSinceEpoch}",
|
||||||
|
url: photo.url,
|
||||||
|
caption: photo.caption,
|
||||||
|
active: photo.active,
|
||||||
|
);
|
||||||
|
MockDataStore.photos.add(created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
final data = await _client.post(ApiConfig.photos, photo.toJson());
|
||||||
|
return Photo.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deletePhoto(String id) async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
MockDataStore.photos.removeWhere((item) => item.id == id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _client.delete("${ApiConfig.photos}/$id");
|
||||||
|
}
|
||||||
|
}
|
||||||
83
flutter_app/lib/services/schedule_service.dart
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import "../config/api_config.dart";
|
||||||
|
import "../models/schedule_item.dart";
|
||||||
|
import "api_client.dart";
|
||||||
|
import "mock_data.dart";
|
||||||
|
|
||||||
|
class ScheduleService {
|
||||||
|
final ApiClient _client;
|
||||||
|
|
||||||
|
ScheduleService(this._client);
|
||||||
|
|
||||||
|
Future<List<ScheduleItem>> fetchSchedules() async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
return List<ScheduleItem>.from(MockDataStore.schedules);
|
||||||
|
}
|
||||||
|
final data = await _client.getList(ApiConfig.schedules);
|
||||||
|
return data
|
||||||
|
.map((item) => ScheduleItem.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<ScheduleItem>> fetchWeeklySchedules() async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
return List<ScheduleItem>.from(MockDataStore.schedules);
|
||||||
|
}
|
||||||
|
final data = await _client.getList("${ApiConfig.schedules}/week");
|
||||||
|
return data
|
||||||
|
.map((item) => ScheduleItem.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<ScheduleItem>> fetchMonthlySchedules() async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
return List<ScheduleItem>.from(MockDataStore.schedules);
|
||||||
|
}
|
||||||
|
final data = await _client.getList("${ApiConfig.schedules}/month");
|
||||||
|
return data
|
||||||
|
.map((item) => ScheduleItem.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ScheduleItem> createSchedule(ScheduleItem schedule) async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
final created = ScheduleItem(
|
||||||
|
id: "schedule-${DateTime.now().millisecondsSinceEpoch}",
|
||||||
|
title: schedule.title,
|
||||||
|
description: schedule.description,
|
||||||
|
startDate: schedule.startDate,
|
||||||
|
endDate: schedule.endDate,
|
||||||
|
familyMemberId: schedule.familyMemberId,
|
||||||
|
isAllDay: schedule.isAllDay,
|
||||||
|
);
|
||||||
|
MockDataStore.schedules.add(created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
final data = await _client.post(ApiConfig.schedules, schedule.toJson());
|
||||||
|
return ScheduleItem.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ScheduleItem> updateSchedule(ScheduleItem schedule) async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
final index = MockDataStore.schedules.indexWhere(
|
||||||
|
(item) => item.id == schedule.id,
|
||||||
|
);
|
||||||
|
if (index != -1) {
|
||||||
|
MockDataStore.schedules[index] = schedule;
|
||||||
|
}
|
||||||
|
return schedule;
|
||||||
|
}
|
||||||
|
final data = await _client.put(
|
||||||
|
"${ApiConfig.schedules}/${schedule.id}",
|
||||||
|
schedule.toJson(),
|
||||||
|
);
|
||||||
|
return ScheduleItem.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteSchedule(String id) async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
MockDataStore.schedules.removeWhere((item) => item.id == id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _client.delete("${ApiConfig.schedules}/$id");
|
||||||
|
}
|
||||||
|
}
|
||||||
79
flutter_app/lib/services/todo_service.dart
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import "../config/api_config.dart";
|
||||||
|
import "../models/todo_item.dart";
|
||||||
|
import "api_client.dart";
|
||||||
|
import "mock_data.dart";
|
||||||
|
|
||||||
|
class TodoService {
|
||||||
|
final ApiClient _client;
|
||||||
|
|
||||||
|
TodoService(this._client);
|
||||||
|
|
||||||
|
Future<List<TodoItem>> fetchTodos() async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
return List<TodoItem>.from(MockDataStore.todos);
|
||||||
|
}
|
||||||
|
final data = await _client.getList(ApiConfig.todos);
|
||||||
|
return data
|
||||||
|
.map((item) => TodoItem.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<TodoItem>> fetchTodayTodos() async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
final today = DateTime.now();
|
||||||
|
return MockDataStore.todos.where((todo) => todo.dueDate != null).where((
|
||||||
|
todo,
|
||||||
|
) {
|
||||||
|
final date = todo.dueDate!;
|
||||||
|
return date.year == today.year &&
|
||||||
|
date.month == today.month &&
|
||||||
|
date.day == today.day;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
final data = await _client.getList("${ApiConfig.todos}/today");
|
||||||
|
return data
|
||||||
|
.map((item) => TodoItem.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<TodoItem> createTodo(TodoItem todo) async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
final created = TodoItem(
|
||||||
|
id: "todo-${DateTime.now().millisecondsSinceEpoch}",
|
||||||
|
familyMemberId: todo.familyMemberId,
|
||||||
|
title: todo.title,
|
||||||
|
completed: todo.completed,
|
||||||
|
dueDate: todo.dueDate,
|
||||||
|
);
|
||||||
|
MockDataStore.todos.add(created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
final data = await _client.post(ApiConfig.todos, todo.toJson());
|
||||||
|
return TodoItem.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<TodoItem> updateTodo(TodoItem todo) async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
final index = MockDataStore.todos.indexWhere(
|
||||||
|
(item) => item.id == todo.id,
|
||||||
|
);
|
||||||
|
if (index != -1) {
|
||||||
|
MockDataStore.todos[index] = todo;
|
||||||
|
}
|
||||||
|
return todo;
|
||||||
|
}
|
||||||
|
final data = await _client.put(
|
||||||
|
"${ApiConfig.todos}/${todo.id}",
|
||||||
|
todo.toJson(),
|
||||||
|
);
|
||||||
|
return TodoItem.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteTodo(String id) async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
MockDataStore.todos.removeWhere((item) => item.id == id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _client.delete("${ApiConfig.todos}/$id");
|
||||||
|
}
|
||||||
|
}
|
||||||
19
flutter_app/lib/services/weather_service.dart
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import "../config/api_config.dart";
|
||||||
|
import "../models/weather_info.dart";
|
||||||
|
import "api_client.dart";
|
||||||
|
import "mock_data.dart";
|
||||||
|
|
||||||
|
class WeatherService {
|
||||||
|
final ApiClient _client;
|
||||||
|
|
||||||
|
WeatherService(this._client);
|
||||||
|
|
||||||
|
Future<WeatherInfo> fetchWeather({String? city}) async {
|
||||||
|
if (ApiConfig.useMockData) {
|
||||||
|
return MockDataStore.weather;
|
||||||
|
}
|
||||||
|
final query = city != null ? {"q": city} : null;
|
||||||
|
final data = await _client.getMap(ApiConfig.weather, query: query);
|
||||||
|
return WeatherInfo.fromJson(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
169
flutter_app/lib/widgets/announcement_widget.dart
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../models/announcement.dart';
|
||||||
|
import '../services/announcement_service.dart';
|
||||||
|
|
||||||
|
class AnnouncementWidget extends StatefulWidget {
|
||||||
|
const AnnouncementWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AnnouncementWidget> createState() => _AnnouncementWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AnnouncementWidgetState extends State<AnnouncementWidget> {
|
||||||
|
final PageController _pageController = PageController();
|
||||||
|
Timer? _timer;
|
||||||
|
int _currentPage = 0;
|
||||||
|
List<Announcement> _announcements = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_fetchAnnouncements();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _fetchAnnouncements() async {
|
||||||
|
try {
|
||||||
|
final data = await Provider.of<AnnouncementService>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
).fetchAnnouncements(activeOnly: true);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_announcements = data;
|
||||||
|
});
|
||||||
|
_startAutoScroll();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Handle error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startAutoScroll() {
|
||||||
|
_timer?.cancel();
|
||||||
|
if (_announcements.length > 1) {
|
||||||
|
_timer = Timer.periodic(const Duration(seconds: 10), (timer) {
|
||||||
|
if (_pageController.hasClients) {
|
||||||
|
_currentPage++;
|
||||||
|
if (_currentPage >= _announcements.length) {
|
||||||
|
_currentPage = 0;
|
||||||
|
}
|
||||||
|
_pageController.animateToPage(
|
||||||
|
_currentPage,
|
||||||
|
duration: const Duration(milliseconds: 800),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_timer?.cancel();
|
||||||
|
_pageController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_announcements.isEmpty) {
|
||||||
|
// Show default placeholder if no announcements
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).cardTheme.color,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: Colors.white10),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: const Center(
|
||||||
|
child: Text(
|
||||||
|
'Welcome Home! Have a great day.',
|
||||||
|
style: TextStyle(color: Colors.white70, fontSize: 18),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).cardTheme.color, // Slightly lighter/distinct background
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
PageView.builder(
|
||||||
|
controller: _pageController,
|
||||||
|
itemCount: _announcements.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = _announcements[index];
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.campaign,
|
||||||
|
color: Theme.of(context).colorScheme.secondary,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
item.title,
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.headlineSmall
|
||||||
|
?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.secondary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
item.content,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyLarge?.copyWith(color: Colors.white),
|
||||||
|
maxLines: 3,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// Page Indicator
|
||||||
|
if (_announcements.length > 1)
|
||||||
|
Positioned(
|
||||||
|
bottom: 16,
|
||||||
|
right: 16,
|
||||||
|
child: Row(
|
||||||
|
children: List.generate(_announcements.length, (index) {
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: _currentPage == index
|
||||||
|
? Theme.of(context).colorScheme.secondary
|
||||||
|
: Colors.white24,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
83
flutter_app/lib/widgets/bible_verse_widget.dart
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../models/bible_verse.dart';
|
||||||
|
import '../services/bible_service.dart';
|
||||||
|
|
||||||
|
class BibleVerseWidget extends StatelessWidget {
|
||||||
|
const BibleVerseWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Theme.of(context).cardTheme.color!.withOpacity(0.8),
|
||||||
|
Theme.of(context).cardTheme.color!,
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: Colors.white10),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: FutureBuilder<BibleVerse>(
|
||||||
|
future: Provider.of<BibleService>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
).fetchTodayVerse(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return const Center(
|
||||||
|
child: Text(
|
||||||
|
'Verse Unavailable',
|
||||||
|
style: TextStyle(color: Colors.white54),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!snapshot.hasData) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final verse = snapshot.data!;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.format_quote,
|
||||||
|
color: Color(0xFFBB86FC),
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
verse.text,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
height: 1.5,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
verse.reference,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
112
flutter_app/lib/widgets/calendar_widget.dart
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
class CalendarWidget extends StatelessWidget {
|
||||||
|
const CalendarWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final firstDayOfMonth = DateTime(now.year, now.month, 1);
|
||||||
|
final lastDayOfMonth = DateTime(now.year, now.month + 1, 0);
|
||||||
|
final daysInMonth = lastDayOfMonth.day;
|
||||||
|
final startingWeekday = firstDayOfMonth.weekday; // Mon=1, Sun=7
|
||||||
|
|
||||||
|
// Simple calendar logic
|
||||||
|
// We need to pad the beginning with empty slots
|
||||||
|
// If week starts on Sunday, adjust accordingly. Let's assume Mon start for now or use locale.
|
||||||
|
// Let's assume standard Sun-Sat or Mon-Sun. Let's go with Sun-Sat for standard calendar view often seen in KR/US.
|
||||||
|
// DateTime.weekday: Mon=1, Sun=7.
|
||||||
|
// If we want Sun start: Sun=0, Mon=1...
|
||||||
|
// Let's adjust so Sunday is first.
|
||||||
|
|
||||||
|
int offset =
|
||||||
|
startingWeekday %
|
||||||
|
7; // If startingWeekday is 7 (Sun), offset is 0. If 1 (Mon), offset is 1.
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).cardTheme.color,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
DateFormat('MMMM yyyy').format(now),
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(Icons.calendar_today, color: Colors.white54),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Days Header
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: ['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day) {
|
||||||
|
return Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
day,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white54,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// Days Grid
|
||||||
|
Expanded(
|
||||||
|
child: GridView.builder(
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 7,
|
||||||
|
childAspectRatio: 1.0,
|
||||||
|
),
|
||||||
|
itemCount: 42, // 6 rows max to be safe
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final dayNumber = index - offset + 1;
|
||||||
|
if (dayNumber < 1 || dayNumber > daysInMonth) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final isToday = dayNumber == now.day;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.all(4),
|
||||||
|
decoration: isToday
|
||||||
|
? BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'$dayNumber',
|
||||||
|
style: TextStyle(
|
||||||
|
color: isToday ? Colors.black : Colors.white,
|
||||||
|
fontWeight: isToday
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
flutter_app/lib/widgets/digital_clock_widget.dart
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
class DigitalClockWidget extends StatefulWidget {
|
||||||
|
const DigitalClockWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DigitalClockWidget> createState() => _DigitalClockWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DigitalClockWidgetState extends State<DigitalClockWidget> {
|
||||||
|
DateTime _now = DateTime.now();
|
||||||
|
Timer? _timer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||||
|
setState(() {
|
||||||
|
_now = DateTime.now();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_timer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Format: 2026.01.24 (Sat) 15:43:36
|
||||||
|
final dateStr = DateFormat('yyyy.MM.dd (E)', 'ko_KR').format(_now);
|
||||||
|
final timeStr = DateFormat('HH:mm:ss').format(_now);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'$dateStr $timeStr',
|
||||||
|
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
144
flutter_app/lib/widgets/photo_slideshow_widget.dart
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../models/photo.dart';
|
||||||
|
import '../services/photo_service.dart';
|
||||||
|
|
||||||
|
class PhotoSlideshowWidget extends StatefulWidget {
|
||||||
|
const PhotoSlideshowWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PhotoSlideshowWidget> createState() => _PhotoSlideshowWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PhotoSlideshowWidgetState extends State<PhotoSlideshowWidget> {
|
||||||
|
List<Photo> _photos = [];
|
||||||
|
int _currentIndex = 0;
|
||||||
|
Timer? _timer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_fetchPhotos();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _fetchPhotos() async {
|
||||||
|
try {
|
||||||
|
final photos = await Provider.of<PhotoService>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
).fetchPhotos(activeOnly: true);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_photos = photos;
|
||||||
|
});
|
||||||
|
_startSlideshow();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Handle error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startSlideshow() {
|
||||||
|
_timer?.cancel();
|
||||||
|
if (_photos.length > 1) {
|
||||||
|
_timer = Timer.periodic(const Duration(seconds: 30), (timer) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_currentIndex = (_currentIndex + 1) % _photos.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_timer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_photos.isEmpty) {
|
||||||
|
return Container(
|
||||||
|
color: Colors.black,
|
||||||
|
child: const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.photo_library, size: 64, color: Colors.white24),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No Photos Available',
|
||||||
|
style: TextStyle(color: Colors.white54, fontSize: 24),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final currentPhoto = _photos[_currentIndex];
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 1000),
|
||||||
|
child: Image.network(
|
||||||
|
currentPhoto.url,
|
||||||
|
key: ValueKey<String>(currentPhoto.id),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return Container(
|
||||||
|
color: Colors.grey[900],
|
||||||
|
child: const Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.broken_image,
|
||||||
|
color: Colors.white54,
|
||||||
|
size: 48,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Gradient overlay for caption
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Colors.transparent, Colors.black87],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Text(
|
||||||
|
currentPhoto.caption,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
shadows: [
|
||||||
|
Shadow(
|
||||||
|
color: Colors.black45,
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: Offset(1, 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
flutter_app/lib/widgets/schedule_list_widget.dart
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../models/schedule_item.dart';
|
||||||
|
import '../services/schedule_service.dart';
|
||||||
|
|
||||||
|
class ScheduleListWidget extends StatelessWidget {
|
||||||
|
const ScheduleListWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).cardTheme.color,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Weekly Schedule',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Expanded(
|
||||||
|
child: FutureBuilder<List<ScheduleItem>>(
|
||||||
|
future: Provider.of<ScheduleService>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
).fetchWeeklySchedules(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return const Center(
|
||||||
|
child: Text(
|
||||||
|
'Failed to load schedules',
|
||||||
|
style: TextStyle(color: Colors.white54),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!snapshot.hasData || snapshot.data!.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: Text(
|
||||||
|
'No schedules this week',
|
||||||
|
style: TextStyle(color: Colors.white54),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final schedules = snapshot.data!;
|
||||||
|
// Sort by date
|
||||||
|
schedules.sort((a, b) => a.startDate.compareTo(b.startDate));
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
itemCount: schedules.length,
|
||||||
|
separatorBuilder: (context, index) =>
|
||||||
|
const Divider(color: Colors.white10),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = schedules[index];
|
||||||
|
final dateStr = DateFormat(
|
||||||
|
'E, MMM d',
|
||||||
|
).format(item.startDate);
|
||||||
|
final timeStr = item.isAllDay
|
||||||
|
? 'All Day'
|
||||||
|
: DateFormat('HH:mm').format(item.startDate);
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white10,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
DateFormat('d').format(item.startDate),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
DateFormat('E').format(item.startDate),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
item.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
timeStr,
|
||||||
|
style: const TextStyle(color: Colors.white54),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
174
flutter_app/lib/widgets/todo_list_widget.dart
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../models/todo_item.dart';
|
||||||
|
import '../models/family_member.dart';
|
||||||
|
import '../services/todo_service.dart';
|
||||||
|
import '../services/family_service.dart';
|
||||||
|
|
||||||
|
class TodoListWidget extends StatelessWidget {
|
||||||
|
const TodoListWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).cardTheme.color,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"Today's Todos",
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
Icons.check_circle_outline,
|
||||||
|
color: Theme.of(context).colorScheme.secondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Expanded(
|
||||||
|
child: FutureBuilder<List<dynamic>>(
|
||||||
|
future: Future.wait([
|
||||||
|
Provider.of<TodoService>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
).fetchTodayTodos(),
|
||||||
|
Provider.of<FamilyService>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
).fetchFamilyMembers(),
|
||||||
|
]),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return const Center(
|
||||||
|
child: Text(
|
||||||
|
'Failed to load todos',
|
||||||
|
style: TextStyle(color: Colors.white54),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!snapshot.hasData) {
|
||||||
|
return const Center(
|
||||||
|
child: Text(
|
||||||
|
'No todos today',
|
||||||
|
style: TextStyle(color: Colors.white54),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final todos = snapshot.data![0] as List<TodoItem>;
|
||||||
|
final members = snapshot.data![1] as List<FamilyMember>;
|
||||||
|
|
||||||
|
if (todos.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.thumb_up, color: Colors.white24, size: 32),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'All done for today!',
|
||||||
|
style: TextStyle(color: Colors.white54),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
itemCount: todos.length,
|
||||||
|
separatorBuilder: (context, index) =>
|
||||||
|
const Divider(color: Colors.white10),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final todo = todos[index];
|
||||||
|
final member = members.firstWhere(
|
||||||
|
(m) => m.id == todo.familyMemberId,
|
||||||
|
orElse: () => const FamilyMember(
|
||||||
|
id: '',
|
||||||
|
name: 'Unknown',
|
||||||
|
emoji: '👤',
|
||||||
|
color: '#888888',
|
||||||
|
order: 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse color
|
||||||
|
Color memberColor;
|
||||||
|
try {
|
||||||
|
memberColor = Color(
|
||||||
|
int.parse(member.color.replaceAll('#', '0xFF')),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
memberColor = Colors.grey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: memberColor.withOpacity(0.2),
|
||||||
|
child: Text(
|
||||||
|
member.emoji,
|
||||||
|
style: const TextStyle(fontSize: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
todo.title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: todo.completed ? Colors.white54 : Colors.white,
|
||||||
|
decoration: todo.completed
|
||||||
|
? TextDecoration.lineThrough
|
||||||
|
: null,
|
||||||
|
decorationColor: Colors.white54,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: Checkbox(
|
||||||
|
value: todo.completed,
|
||||||
|
onChanged: (val) async {
|
||||||
|
// Toggle completion
|
||||||
|
final updated = TodoItem(
|
||||||
|
id: todo.id,
|
||||||
|
familyMemberId: todo.familyMemberId,
|
||||||
|
title: todo.title,
|
||||||
|
completed: val ?? false,
|
||||||
|
dueDate: todo.dueDate,
|
||||||
|
);
|
||||||
|
await Provider.of<TodoService>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
).updateTodo(updated);
|
||||||
|
// Force rebuild? In a real app we'd use a reactive state.
|
||||||
|
// Here we rely on the parent or timer to refresh, or we could convert this to StatefulWidget.
|
||||||
|
// For now, let's just let the next refresh cycle pick it up, or if the user interacts, maybe we should optimistic update?
|
||||||
|
// Given it's a TV dashboard, interaction might be rare, but if it is interactive:
|
||||||
|
(context as Element)
|
||||||
|
.markNeedsBuild(); // HACK to refresh
|
||||||
|
},
|
||||||
|
activeColor: Theme.of(context).colorScheme.secondary,
|
||||||
|
checkColor: Colors.black,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
flutter_app/lib/widgets/weather_widget.dart
Normal file
@@ -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
@@ -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
@@ -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
|
||||||
30
flutter_app/test/widget_test.dart
Normal 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
|
After Width: | Height: | Size: 917 B |
BIN
flutter_app/web/icons/Icon-192.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
flutter_app/web/icons/Icon-512.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
flutter_app/web/icons/Icon-maskable-192.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
flutter_app/web/icons/Icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
38
flutter_app/web/index.html
Normal 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>
|
||||||
35
flutter_app/web/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||