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"
|
||||
}
|
||||
]
|
||||
}
|
||||