diff --git a/backend/models/GoogleConfig.js b/backend/models/GoogleConfig.js
new file mode 100644
index 0000000..9c8bdf2
--- /dev/null
+++ b/backend/models/GoogleConfig.js
@@ -0,0 +1,18 @@
+const mongoose = require("mongoose");
+
+const googleConfigSchema = new mongoose.Schema(
+ {
+ key: { type: String, required: true, unique: true }, // e.g., 'photos_auth'
+ tokens: {
+ access_token: String,
+ refresh_token: String,
+ scope: String,
+ token_type: String,
+ expiry_date: Number,
+ },
+ albumId: { type: String }, // Optional: to filter by a specific album
+ },
+ { timestamps: true }
+);
+
+module.exports = mongoose.model("GoogleConfig", googleConfigSchema);
diff --git a/backend/package-lock.json b/backend/package-lock.json
index 1aae6b9..86fe7b3 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -12,10 +12,28 @@
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.19.2",
+ "googleapis": "^171.0.0",
"mongoose": "^8.9.0",
"multer": "^1.4.5-lts.1"
}
},
+ "node_modules/@isaacs/cliui": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+ "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^5.1.2",
+ "string-width-cjs": "npm:string-width@^4.2.0",
+ "strip-ansi": "^7.0.1",
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+ "wrap-ansi": "^8.1.0",
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/@mongodb-js/saslprep": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.5.tgz",
@@ -25,6 +43,16 @@
"sparse-bitfield": "^3.0.3"
}
},
+ "node_modules/@pkgjs/parseargs": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/@types/webidl-conversions": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
@@ -53,6 +81,39 @@
"node": ">= 0.6"
}
},
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
@@ -82,6 +143,41 @@
"proxy-from-env": "^1.1.0"
}
},
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "license": "MIT"
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/bignumber.js": {
+ "version": "9.3.1",
+ "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
+ "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@@ -106,6 +202,15 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
+ "node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
"node_modules/bson": {
"version": "6.10.4",
"resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz",
@@ -115,6 +220,12 @@
"node": ">=16.20.1"
}
},
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -170,6 +281,24 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -256,6 +385,29 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/data-uri-to-buffer": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
+ "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -319,12 +471,33 @@
"node": ">= 0.4"
}
},
+ "node_modules/eastasianwidth": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+ "license": "MIT"
+ },
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
+ "node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "license": "MIT"
+ },
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -440,6 +613,35 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "license": "MIT"
+ },
+ "node_modules/fetch-blob": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
+ "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/jimmywarting"
+ },
+ {
+ "type": "paypal",
+ "url": "https://paypal.me/jimmywarting"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "node-domexception": "^1.0.0",
+ "web-streams-polyfill": "^3.0.3"
+ },
+ "engines": {
+ "node": "^12.20 || >= 14.13"
+ }
+ },
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -478,6 +680,22 @@
}
}
},
+ "node_modules/foreground-child": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
+ "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
+ "license": "ISC",
+ "dependencies": {
+ "cross-spawn": "^7.0.6",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
@@ -494,6 +712,18 @@
"node": ">= 6"
}
},
+ "node_modules/formdata-polyfill": {
+ "version": "4.0.10",
+ "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
+ "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
+ "license": "MIT",
+ "dependencies": {
+ "fetch-blob": "^3.1.2"
+ },
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -521,6 +751,159 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/gaxios": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz",
+ "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "extend": "^3.0.2",
+ "https-proxy-agent": "^7.0.1",
+ "node-fetch": "^3.3.2",
+ "rimraf": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/gcp-metadata": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz",
+ "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "gaxios": "^5.0.0",
+ "json-bigint": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/gcp-metadata/node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/gcp-metadata/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/gcp-metadata/node_modules/gaxios": {
+ "version": "5.1.3",
+ "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz",
+ "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "extend": "^3.0.2",
+ "https-proxy-agent": "^5.0.0",
+ "is-stream": "^2.0.0",
+ "node-fetch": "^2.6.9"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/gcp-metadata/node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/gcp-metadata/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
+ "node_modules/gcp-metadata/node_modules/node-fetch": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+ "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "whatwg-url": "^5.0.0"
+ },
+ "engines": {
+ "node": "4.x || >=6.0.0"
+ },
+ "peerDependencies": {
+ "encoding": "^0.1.0"
+ },
+ "peerDependenciesMeta": {
+ "encoding": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/gcp-metadata/node_modules/tr46": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
+ "node_modules/gcp-metadata/node_modules/webidl-conversions": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "peer": true
+ },
+ "node_modules/gcp-metadata/node_modules/whatwg-url": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "tr46": "~0.0.3",
+ "webidl-conversions": "^3.0.0"
+ }
+ },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -558,6 +941,96 @@
"node": ">= 0.4"
}
},
+ "node_modules/glob": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
+ "license": "ISC",
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/google-auth-library": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz",
+ "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "base64-js": "^1.3.0",
+ "ecdsa-sig-formatter": "^1.0.11",
+ "gaxios": "^7.0.0",
+ "gcp-metadata": "^8.0.0",
+ "google-logging-utils": "^1.0.0",
+ "gtoken": "^8.0.0",
+ "jws": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/google-auth-library/node_modules/gcp-metadata": {
+ "version": "8.1.2",
+ "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
+ "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "gaxios": "^7.0.0",
+ "google-logging-utils": "^1.0.0",
+ "json-bigint": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/google-logging-utils": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
+ "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/googleapis": {
+ "version": "171.0.0",
+ "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-171.0.0.tgz",
+ "integrity": "sha512-z+wpYZ9wfO/v58b/7fM0JqwDR6dx6yE3UdQZ9vWrzmzNVHFd85Uud8QewFfm8ht/3Hx2ISLbCs8RfnbrqR8X4A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "google-auth-library": "^10.2.0",
+ "googleapis-common": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/googleapis-common": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.1.tgz",
+ "integrity": "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "extend": "^3.0.2",
+ "gaxios": "^7.0.0-rc.4",
+ "google-auth-library": "^10.1.0",
+ "qs": "^6.7.0",
+ "url-template": "^2.0.8"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -570,6 +1043,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/gtoken": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz",
+ "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==",
+ "license": "MIT",
+ "dependencies": {
+ "gaxios": "^7.0.0",
+ "jws": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -629,6 +1115,42 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/https-proxy-agent/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -656,12 +1178,86 @@
"node": ">= 0.10"
}
},
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-stream": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "node_modules/jackspeak": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
+ "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/cliui": "^8.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ },
+ "optionalDependencies": {
+ "@pkgjs/parseargs": "^0.11.0"
+ }
+ },
+ "node_modules/json-bigint": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
+ "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bignumber.js": "^9.0.0"
+ }
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
"node_modules/kareem": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz",
@@ -671,6 +1267,12 @@
"node": ">=12.0.0"
}
},
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "license": "ISC"
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -746,6 +1348,21 @@
"node": ">= 0.6"
}
},
+ "node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -755,6 +1372,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/minipass": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
+ "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
@@ -929,6 +1555,44 @@
"node": ">= 0.6"
}
},
+ "node_modules/node-domexception": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+ "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+ "deprecated": "Use your platform's native DOMException instead",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/jimmywarting"
+ },
+ {
+ "type": "github",
+ "url": "https://paypal.me/jimmywarting"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.5.0"
+ }
+ },
+ "node_modules/node-fetch": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
+ "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
+ "license": "MIT",
+ "dependencies": {
+ "data-uri-to-buffer": "^4.0.0",
+ "fetch-blob": "^3.1.4",
+ "formdata-polyfill": "^4.0.10"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/node-fetch"
+ }
+ },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -962,6 +1626,12 @@
"node": ">= 0.8"
}
},
+ "node_modules/package-json-from-dist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+ "license": "BlueOak-1.0.0"
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -971,6 +1641,31 @@
"node": ">= 0.8"
}
},
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-scurry": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+ "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^10.2.0",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
@@ -1071,6 +1766,21 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
+ "node_modules/rimraf": {
+ "version": "5.0.10",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz",
+ "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==",
+ "license": "ISC",
+ "dependencies": {
+ "glob": "^10.3.7"
+ },
+ "bin": {
+ "rimraf": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1148,6 +1858,27 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@@ -1226,6 +1957,18 @@
"integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==",
"license": "MIT"
},
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/sparse-bitfield": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
@@ -1267,6 +2010,102 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
+ "node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "license": "MIT",
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/string-width-cjs": {
+ "name": "string-width",
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/string-width-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
+ "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/strip-ansi-cjs": {
+ "name": "strip-ansi",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1316,6 +2155,12 @@
"node": ">= 0.8"
}
},
+ "node_modules/url-template": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz",
+ "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==",
+ "license": "BSD"
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -1340,6 +2185,15 @@
"node": ">= 0.8"
}
},
+ "node_modules/web-streams-polyfill": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
+ "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
@@ -1362,6 +2216,112 @@
"node": ">=18"
}
},
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs": {
+ "name": "wrap-ansi",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
diff --git a/backend/package.json b/backend/package.json
index 3be5776..1c5e245 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -15,6 +15,7 @@
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.19.2",
+ "googleapis": "^171.0.0",
"mongoose": "^8.9.0",
"multer": "^1.4.5-lts.1"
}
diff --git a/backend/routes/photos.js b/backend/routes/photos.js
index 0fd059f..7e6075a 100644
--- a/backend/routes/photos.js
+++ b/backend/routes/photos.js
@@ -2,13 +2,24 @@ const express = require("express");
const path = require("path");
const fs = require("fs");
const multer = require("multer");
+const { google } = require("googleapis");
+const axios = require("axios");
const Photo = require("../models/Photo");
+const GoogleConfig = require("../models/GoogleConfig");
const router = express.Router();
const uploadsDir = path.join(__dirname, "..", "uploads");
fs.mkdirSync(uploadsDir, { recursive: true });
+const oauth2Client = new google.auth.OAuth2(
+ process.env.GOOGLE_CLIENT_ID,
+ process.env.GOOGLE_CLIENT_SECRET,
+ process.env.GOOGLE_REDIRECT_URI
+);
+
+const PHOTOS_SCOPE = ["https://www.googleapis.com/auth/photoslibrary.readonly"];
+
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadsDir);
@@ -22,15 +33,106 @@ const storage = multer.diskStorage({
const upload = multer({ storage });
+// OAuth Endpoints
+router.get("/auth/url", (req, res) => {
+ const url = oauth2Client.generateAuthUrl({
+ access_type: "offline",
+ scope: PHOTOS_SCOPE,
+ prompt: "consent",
+ });
+ res.json({ url });
+});
+
+router.get("/auth/callback", async (req, res) => {
+ const { code } = req.query;
+ try {
+ const { tokens } = await oauth2Client.getToken(code);
+ await GoogleConfig.findOneAndUpdate(
+ { key: "photos_auth" },
+ { tokens },
+ { upsert: true }
+ );
+ res.send("
Authentication successful!
You can close this window and return to the application.
");
+ } catch (error) {
+ console.error("Auth error:", error);
+ res.status(500).send("Authentication failed");
+ }
+});
+
+router.get("/status", async (req, res) => {
+ try {
+ const config = await GoogleConfig.findOne({ key: "photos_auth" });
+ res.json({ connected: !!config && !!config.tokens });
+ } catch (error) {
+ res.status(500).json({ message: "Failed to check status" });
+ }
+});
+
+router.get("/disconnect", async (req, res) => {
+ try {
+ await GoogleConfig.deleteOne({ key: "photos_auth" });
+ res.json({ ok: true });
+ } catch (error) {
+ res.status(500).json({ message: "Failed to disconnect" });
+ }
+});
+
+// Fetching Routes
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);
+
+ // Get local photos
+ const localPhotos = await Photo.find(filter).sort({ createdAt: -1 });
+
+ // Check if Google Photos is connected
+ const config = await GoogleConfig.findOne({ key: "photos_auth" });
+
+ if (config && config.tokens) {
+ try {
+ oauth2Client.setCredentials(config.tokens);
+
+ // Refresh token if needed
+ if (config.tokens.expiry_date <= Date.now()) {
+ const { tokens } = await oauth2Client.refreshAccessToken();
+ config.tokens = tokens;
+ await config.save();
+ oauth2Client.setCredentials(tokens);
+ }
+
+ const tokensInfo = await oauth2Client.getAccessToken();
+ const accessToken = tokensInfo.token;
+
+ const response = await axios.get(
+ "https://photoslibrary.googleapis.com/v1/mediaItems?pageSize=100",
+ {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ }
+ );
+
+ const googlePhotos = (response.data.mediaItems || []).map((item) => ({
+ id: item.id,
+ url: `${item.baseUrl}=w2048-h1024`,
+ caption: item.description || "Google Photo",
+ active: true,
+ source: "google"
+ }));
+
+ // Combine local and google photos
+ return res.json([...localPhotos, ...googlePhotos]);
+ } catch (gError) {
+ console.error("Error fetching Google Photos:", gError);
+ // Fallback to local photos only if Google fails
+ return res.json(localPhotos);
+ }
+ }
+
+ res.json(localPhotos);
} catch (error) {
+ console.error("Fetch error:", error);
res.status(500).json({ message: "Failed to fetch photos" });
}
});
diff --git a/flutter_app/lib/screens/admin/admin_screen.dart b/flutter_app/lib/screens/admin/admin_screen.dart
index bf5d7d0..6f155d5 100644
--- a/flutter_app/lib/screens/admin/admin_screen.dart
+++ b/flutter_app/lib/screens/admin/admin_screen.dart
@@ -3,6 +3,7 @@ import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
+import 'package:url_launcher/url_launcher.dart';
import '../../models/bible_verse.dart';
import '../../models/family_member.dart';
import '../../models/photo.dart';
@@ -567,8 +568,79 @@ class PhotoManagerTab extends StatefulWidget {
@override
State createState() => _PhotoManagerTabState();
}
-
class _PhotoManagerTabState extends State {
+ Widget _buildGooglePhotosHeader() {
+ return FutureBuilder(
+ future: Provider.of(context, listen: false).getGoogleStatus(),
+ builder: (context, snapshot) {
+ final isConnected = snapshot.data ?? false;
+ return Container(
+ padding: const EdgeInsets.all(16),
+ margin: const EdgeInsets.all(8),
+ decoration: BoxDecoration(
+ color: isConnected ? Colors.green.withOpacity(0.1) : Colors.blue.withOpacity(0.1),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(
+ color: isConnected ? Colors.green : Colors.blue,
+ width: 1,
+ ),
+ ),
+ child: Row(
+ children: [
+ Icon(
+ isConnected ? Icons.cloud_done : Icons.cloud_off,
+ color: isConnected ? Colors.green : Colors.blue,
+ ),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ isConnected ? 'Google Photos Connected' : 'Google Photos Not Connected',
+ style: const TextStyle(fontWeight: FontWeight.bold),
+ ),
+ Text(
+ isConnected
+ ? 'Photos will be synced automatically.'
+ : 'Connect to sync your Google Photos albums.',
+ style: TextStyle(color: Colors.grey[600], fontSize: 12),
+ ),
+ ],
+ ),
+ ),
+ ElevatedButton(
+ onPressed: () async {
+ if (isConnected) {
+ await Provider.of(context, listen: false).disconnectGoogle();
+ setState(() {});
+ } else {
+ try {
+ final url = await Provider.of(context, listen: false).getGoogleAuthUrl();
+ final uri = Uri.parse(url);
+ if (await canLaunchUrl(uri)) {
+ await launchUrl(uri, mode: LaunchMode.externalApplication);
+ }
+ } catch (e) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Failed to connect: $e')),
+ );
+ }
+ }
+ },
+ style: ElevatedButton.styleFrom(
+ backgroundColor: isConnected ? Colors.red.withOpacity(0.1) : null,
+ foregroundColor: isConnected ? Colors.red : null,
+ ),
+ child: Text(isConnected ? 'Disconnect' : 'Connect'),
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ }
+
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -576,47 +648,56 @@ class _PhotoManagerTabState extends State {
onPressed: () => _showAddPhotoDialog(context),
child: const Icon(Icons.add_a_photo),
),
- body: FutureBuilder>(
- future: Provider.of(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(
- context,
- listen: false,
- ).deletePhoto(photo.id);
- setState(() {});
- },
+ body: Column(
+ children: [
+ _buildGooglePhotosHeader(),
+ Expanded(
+ child: FutureBuilder>(
+ future: Provider.of(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,
),
- ),
- child: Image.network(
- photo.url,
- fit: BoxFit.cover,
- errorBuilder: (_, __, ___) =>
- const Center(child: Icon(Icons.broken_image)),
- ),
- );
- },
- );
- },
+ 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(
+ context,
+ listen: false,
+ ).deletePhoto(photo.id);
+ setState(() {});
+ },
+ ),
+ ),
+ child: photo.url.startsWith('http')
+ ? Image.network(
+ photo.url,
+ fit: BoxFit.cover,
+ errorBuilder: (_, __, ___) =>
+ const Center(child: Icon(Icons.broken_image)),
+ )
+ : const Center(child: Icon(Icons.photo)),
+ );
+ },
+ );
+ },
+ ),
+ ),
+ ],
),
);
}
@@ -780,6 +861,7 @@ class _BibleVerseManagerTabState extends State {
itemBuilder: (context, index) {
final verse = verses[index];
return ListTile(
+ onTap: () => _showEditVerseDialog(context, verse),
title: Text(verse.reference),
subtitle: Text(
verse.text,
@@ -795,6 +877,10 @@ class _BibleVerseManagerTabState extends State {
style:
const TextStyle(fontSize: 12, color: Colors.grey),
),
+ IconButton(
+ icon: const Icon(Icons.edit, color: Colors.blue),
+ onPressed: () => _showEditVerseDialog(context, verse),
+ ),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () async {
@@ -816,53 +902,86 @@ class _BibleVerseManagerTabState extends State {
}
void _showAddVerseDialog(BuildContext context) {
- final textController = TextEditingController();
- final referenceController = TextEditingController();
- final dateController = TextEditingController();
- bool isActive = true;
+ _showVerseDialog(context, null);
+ }
+
+ void _showEditVerseDialog(BuildContext context, BibleVerse verse) {
+ _showVerseDialog(context, verse);
+ }
+
+ void _showVerseDialog(BuildContext context, BibleVerse? verse) {
+ final isEditing = verse != null;
+ final textController = TextEditingController(text: verse?.text ?? '');
+ final referenceController = TextEditingController(text: verse?.reference ?? '');
+ final dateController = TextEditingController(text: verse?.date ?? '');
+ bool isActive = verse?.active ?? 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)',
+ title: Text(isEditing ? 'Edit Bible Verse' : 'Add Bible Verse'),
+ content: SizedBox(
+ width: 600, // Increased width
+ child: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ TextField(
+ controller: referenceController,
+ decoration: const InputDecoration(
+ labelText: 'Reference (e.g., Psalms 23:1)',
+ border: OutlineInputBorder(),
+ ),
),
- ),
- const SizedBox(height: 8),
- TextField(
- controller: textController,
- decoration: const InputDecoration(
- labelText: 'Verse Text (Korean)',
+ const SizedBox(height: 16),
+ TextField(
+ controller: textController,
+ decoration: const InputDecoration(
+ labelText: 'Verse Text (Korean)',
+ hintText: 'Enter verse text here. Use \\n for line breaks.',
+ border: OutlineInputBorder(),
+ alignLabelWithHint: true,
+ ),
+ maxLines: 8, // Increased maxLines
),
- 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: 16),
+ TextField(
+ controller: dateController,
+ readOnly: true,
+ decoration: const InputDecoration(
+ labelText: 'Date (YYYY-MM-DD) - Optional',
+ hintText: 'Click to select date',
+ border: OutlineInputBorder(),
+ suffixIcon: Icon(Icons.calendar_today),
+ ),
+ onTap: () async {
+ final DateTime? picked = await showDatePicker(
+ context: context,
+ initialDate: DateTime.now(),
+ firstDate: DateTime(2000),
+ lastDate: DateTime(2101),
+ );
+ if (picked != null) {
+ setDialogState(() {
+ dateController.text =
+ picked.toIso8601String().split('T')[0];
+ });
+ }
+ },
),
- ),
- const SizedBox(height: 8),
- SwitchListTile(
- title: const Text('Active'),
- value: isActive,
- onChanged: (value) {
- setDialogState(() {
- isActive = value;
- });
- },
- ),
- ],
+ const SizedBox(height: 8),
+ SwitchListTile(
+ title: const Text('Active'),
+ value: isActive,
+ onChanged: (value) {
+ setDialogState(() {
+ isActive = value;
+ });
+ },
+ ),
+ ],
+ ),
),
),
actions: [
@@ -870,31 +989,42 @@ class _BibleVerseManagerTabState extends State {
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
- TextButton(
+ ElevatedButton(
+ style: ElevatedButton.styleFrom(
+ padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
+ ),
onPressed: () async {
if (textController.text.isNotEmpty &&
referenceController.text.isNotEmpty) {
- await Provider.of(
- context,
- listen: false,
- ).createVerse(
- BibleVerse(
- id: '',
- text: textController.text,
- reference: referenceController.text,
- date: dateController.text.isEmpty
- ? null
- : dateController.text,
- active: isActive,
- ),
+ final newVerse = BibleVerse(
+ id: verse?.id ?? '',
+ text: textController.text,
+ reference: referenceController.text,
+ date: dateController.text.isEmpty
+ ? null
+ : dateController.text,
+ active: isActive,
);
+
+ if (isEditing) {
+ await Provider.of(
+ context,
+ listen: false,
+ ).updateVerse(newVerse);
+ } else {
+ await Provider.of(
+ context,
+ listen: false,
+ ).createVerse(newVerse);
+ }
+
if (mounted) {
Navigator.pop(context);
setState(() {});
}
}
},
- child: const Text('Add'),
+ child: Text(isEditing ? 'Save Verse' : 'Add Verse'),
),
],
),
@@ -938,6 +1068,7 @@ class _AnnouncementManagerTabState extends State {
itemBuilder: (context, index) {
final announcement = announcements[index];
return ListTile(
+ onTap: () => _showEditAnnouncementDialog(context, announcement),
title: Text(announcement.title),
subtitle: Text(
announcement.content,
@@ -950,23 +1081,32 @@ class _AnnouncementManagerTabState extends State {
if (announcement.priority > 0)
Container(
padding: const EdgeInsets.symmetric(
- horizontal: 6, vertical: 2),
+ horizontal: 8,
+ vertical: 2,
+ ),
+ margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
- color: Colors.orangeAccent,
+ color: Colors.orange,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'P${announcement.priority}',
style: const TextStyle(
- color: Colors.white, fontSize: 10),
+ fontSize: 10,
+ color: Colors.white,
+ ),
),
),
- const SizedBox(width: 8),
Icon(
announcement.active ? Icons.check_circle : Icons.cancel,
color: announcement.active ? Colors.green : Colors.grey,
size: 16,
),
+ IconButton(
+ icon: const Icon(Icons.edit, color: Colors.blue),
+ onPressed: () =>
+ _showEditAnnouncementDialog(context, announcement),
+ ),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () async {
@@ -988,45 +1128,72 @@ class _AnnouncementManagerTabState extends State {
}
void _showAddAnnouncementDialog(BuildContext context) {
- final titleController = TextEditingController();
- final contentController = TextEditingController();
- final priorityController = TextEditingController(text: '0');
- bool isActive = true;
+ _showAnnouncementDialog(context, null);
+ }
+
+ void _showEditAnnouncementDialog(
+ BuildContext context, Announcement announcement) {
+ _showAnnouncementDialog(context, announcement);
+ }
+
+ void _showAnnouncementDialog(BuildContext context, Announcement? announcement) {
+ final isEditing = announcement != null;
+ final titleController =
+ TextEditingController(text: announcement?.title ?? '');
+ final contentController =
+ TextEditingController(text: announcement?.content ?? '');
+ final priorityController =
+ TextEditingController(text: announcement?.priority.toString() ?? '0');
+ bool isActive = announcement?.active ?? true;
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
- title: const Text('Add Announcement'),
- content: SingleChildScrollView(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- TextField(
- controller: titleController,
- decoration: const InputDecoration(labelText: 'Title'),
- ),
- TextField(
- controller: contentController,
- decoration: const InputDecoration(labelText: 'Content'),
- maxLines: 3,
- ),
- TextField(
- controller: priorityController,
- decoration:
- const InputDecoration(labelText: 'Priority (0-10)'),
- keyboardType: TextInputType.number,
- ),
- SwitchListTile(
- title: const Text('Active'),
- value: isActive,
- onChanged: (value) {
- setDialogState(() {
- isActive = value;
- });
- },
- ),
- ],
+ title: Text(isEditing ? 'Edit Announcement' : 'Add Announcement'),
+ content: SizedBox(
+ width: 600,
+ child: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ TextField(
+ controller: titleController,
+ decoration: const InputDecoration(
+ labelText: 'Title',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ const SizedBox(height: 16),
+ TextField(
+ controller: contentController,
+ decoration: const InputDecoration(
+ labelText: 'Content',
+ border: OutlineInputBorder(),
+ ),
+ maxLines: 3,
+ ),
+ const SizedBox(height: 16),
+ TextField(
+ controller: priorityController,
+ decoration: const InputDecoration(
+ labelText: 'Priority (0-10)',
+ border: OutlineInputBorder(),
+ ),
+ keyboardType: TextInputType.number,
+ ),
+ const SizedBox(height: 8),
+ SwitchListTile(
+ title: const Text('Active'),
+ value: isActive,
+ onChanged: (value) {
+ setDialogState(() {
+ isActive = value;
+ });
+ },
+ ),
+ ],
+ ),
),
),
actions: [
@@ -1034,28 +1201,40 @@ class _AnnouncementManagerTabState extends State {
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
- TextButton(
+ ElevatedButton(
+ style: ElevatedButton.styleFrom(
+ padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
+ ),
onPressed: () async {
if (titleController.text.isNotEmpty) {
- await Provider.of(
- context,
- listen: false,
- ).createAnnouncement(
- Announcement(
- id: '',
- title: titleController.text,
- content: contentController.text,
- priority: int.tryParse(priorityController.text) ?? 0,
- active: isActive,
- ),
+ final newAnnouncement = Announcement(
+ id: announcement?.id ?? '',
+ title: titleController.text,
+ content: contentController.text,
+ priority: int.tryParse(priorityController.text) ?? 0,
+ active: isActive,
);
+
+ if (isEditing) {
+ await Provider.of(
+ context,
+ listen: false,
+ ).updateAnnouncement(newAnnouncement);
+ } else {
+ await Provider.of(
+ context,
+ listen: false,
+ ).createAnnouncement(newAnnouncement);
+ }
+
if (mounted) {
Navigator.pop(context);
setState(() {});
}
}
},
- child: const Text('Add'),
+ child:
+ Text(isEditing ? 'Save Announcement' : 'Add Announcement'),
),
],
),
@@ -1099,6 +1278,7 @@ class _ScheduleManagerTabState extends State {
itemBuilder: (context, index) {
final schedule = schedules[index];
return ListTile(
+ onTap: () => _showEditScheduleDialog(context, schedule),
title: Text(schedule.title),
subtitle: Text(
'${schedule.description}\n${schedule.startDate.toIso8601String().split('T')[0]} ~ ${schedule.endDate.toIso8601String().split('T')[0]}',
@@ -1106,15 +1286,24 @@ class _ScheduleManagerTabState extends State {
overflow: TextOverflow.ellipsis,
),
isThreeLine: true,
- trailing: IconButton(
- icon: const Icon(Icons.delete, color: Colors.red),
- onPressed: () async {
- await Provider.of(
- context,
- listen: false,
- ).deleteSchedule(schedule.id);
- setState(() {});
- },
+ trailing: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ IconButton(
+ icon: const Icon(Icons.edit, color: Colors.blue),
+ onPressed: () => _showEditScheduleDialog(context, schedule),
+ ),
+ IconButton(
+ icon: const Icon(Icons.delete, color: Colors.red),
+ onPressed: () async {
+ await Provider.of(
+ context,
+ listen: false,
+ ).deleteSchedule(schedule.id);
+ setState(() {});
+ },
+ ),
+ ],
),
);
},
@@ -1125,81 +1314,137 @@ class _ScheduleManagerTabState extends State {
}
void _showAddScheduleDialog(BuildContext context) {
- final titleController = TextEditingController();
- final descriptionController = TextEditingController();
+ _showScheduleDialog(context, null);
+ }
+
+ void _showEditScheduleDialog(BuildContext context, ScheduleItem schedule) {
+ _showScheduleDialog(context, schedule);
+ }
+
+ void _showScheduleDialog(BuildContext context, ScheduleItem? schedule) {
+ final isEditing = schedule != null;
+ final titleController = TextEditingController(text: schedule?.title ?? '');
+ final descriptionController =
+ TextEditingController(text: schedule?.description ?? '');
final startController = TextEditingController(
- text: DateTime.now().toIso8601String().split('T')[0],
+ text: schedule?.startDate.toIso8601String().split('T')[0] ??
+ DateTime.now().toIso8601String().split('T')[0],
);
final endController = TextEditingController(
- text: DateTime.now().toIso8601String().split('T')[0],
+ text: schedule?.endDate.toIso8601String().split('T')[0] ??
+ DateTime.now().toIso8601String().split('T')[0],
);
- bool isAllDay = true;
- String? selectedFamilyMemberId;
+ const bool isAllDay = true; // Forcing all day as requested
+ String? selectedFamilyMemberId = schedule?.familyMemberId;
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
- title: const Text('Add Schedule'),
- content: SingleChildScrollView(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- TextField(
- controller: titleController,
- decoration: const InputDecoration(labelText: 'Title'),
- ),
- TextField(
- controller: descriptionController,
- decoration: const InputDecoration(labelText: 'Description'),
- ),
- TextField(
- controller: startController,
- decoration: const InputDecoration(
- labelText: 'Start Date (YYYY-MM-DD)',
+ title: Text(isEditing ? 'Edit Schedule' : 'Add Schedule'),
+ content: SizedBox(
+ width: 600,
+ child: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ TextField(
+ controller: titleController,
+ decoration: const InputDecoration(
+ labelText: 'Title',
+ border: OutlineInputBorder(),
+ ),
),
- ),
- TextField(
- controller: endController,
- decoration: const InputDecoration(
- labelText: 'End Date (YYYY-MM-DD)',
+ const SizedBox(height: 16),
+ TextField(
+ controller: descriptionController,
+ decoration: const InputDecoration(
+ labelText: 'Description',
+ border: OutlineInputBorder(),
+ ),
+ maxLines: 2,
),
- ),
- SwitchListTile(
- title: const Text('All Day'),
- value: isAllDay,
- onChanged: (value) {
- setDialogState(() {
- isAllDay = value;
- });
- },
- ),
- FutureBuilder>(
- future: Provider.of(context, listen: false)
- .fetchFamilyMembers(),
- builder: (context, snapshot) {
- if (!snapshot.hasData) return const SizedBox();
- final members = snapshot.data!;
- return DropdownButtonFormField(
- value: selectedFamilyMemberId,
- decoration: const InputDecoration(
- labelText: 'Family Member',
- ),
- items: members.map((member) {
- return DropdownMenuItem(
- value: member.id,
- child: Text(member.name),
- );
- }).toList(),
- onChanged: (value) {
+ const SizedBox(height: 16),
+ TextField(
+ controller: startController,
+ readOnly: true,
+ decoration: const InputDecoration(
+ labelText: 'Start Date (YYYY-MM-DD)',
+ suffixIcon: Icon(Icons.calendar_today),
+ border: OutlineInputBorder(),
+ ),
+ onTap: () async {
+ final DateTime? picked = await showDatePicker(
+ context: context,
+ initialDate: DateTime.tryParse(startController.text) ??
+ DateTime.now(),
+ firstDate: DateTime(2000),
+ lastDate: DateTime(2101),
+ );
+ if (picked != null) {
setDialogState(() {
- selectedFamilyMemberId = value;
+ startController.text =
+ picked.toIso8601String().split('T')[0];
});
- },
- );
- },
- ),
- ],
+ }
+ },
+ ),
+ const SizedBox(height: 16),
+ TextField(
+ controller: endController,
+ readOnly: true,
+ decoration: const InputDecoration(
+ labelText: 'End Date (YYYY-MM-DD)',
+ suffixIcon: Icon(Icons.calendar_today),
+ border: OutlineInputBorder(),
+ ),
+ onTap: () async {
+ final DateTime? picked = await showDatePicker(
+ context: context,
+ initialDate: DateTime.tryParse(endController.text) ??
+ DateTime.now(),
+ firstDate: DateTime(2000),
+ lastDate: DateTime(2101),
+ );
+ if (picked != null) {
+ setDialogState(() {
+ endController.text =
+ picked.toIso8601String().split('T')[0];
+ });
+ }
+ },
+ ),
+ const SizedBox(height: 8),
+ // All Day toggle removed as requested
+ const SizedBox(height: 8),
+ FutureBuilder>(
+ future: Provider.of(context, listen: false)
+ .fetchFamilyMembers(),
+ builder: (context, snapshot) {
+ if (!snapshot.hasData) return const SizedBox();
+ final members = snapshot.data!;
+ return DropdownButtonFormField(
+ value: selectedFamilyMemberId,
+ decoration: const InputDecoration(
+ labelText: 'Family Member',
+ border: OutlineInputBorder(),
+ ),
+ items: members.map((member) {
+ return DropdownMenuItem(
+ value: member.id,
+ child: Text(member.name),
+ );
+ }).toList(),
+ onChanged: (value) {
+ setDialogState(() {
+ selectedFamilyMemberId = value;
+ });
+ },
+ );
+ },
+ ),
+ ],
+ ),
),
),
actions: [
@@ -1207,7 +1452,7 @@ class _ScheduleManagerTabState extends State {
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
- TextButton(
+ ElevatedButton(
onPressed: () async {
if (titleController.text.isNotEmpty) {
final startDate =
@@ -1215,27 +1460,35 @@ class _ScheduleManagerTabState extends State {
final endDate =
DateTime.tryParse(endController.text) ?? DateTime.now();
- await Provider.of(
- context,
- listen: false,
- ).createSchedule(
- ScheduleItem(
- id: '',
- title: titleController.text,
- description: descriptionController.text,
- startDate: startDate,
- endDate: endDate,
- familyMemberId: selectedFamilyMemberId ?? '',
- isAllDay: isAllDay,
- ),
+ final newSchedule = ScheduleItem(
+ id: schedule?.id ?? '',
+ title: titleController.text,
+ description: descriptionController.text,
+ startDate: startDate,
+ endDate: endDate,
+ familyMemberId: selectedFamilyMemberId ?? '',
+ isAllDay: isAllDay,
);
+
+ if (isEditing) {
+ await Provider.of(
+ context,
+ listen: false,
+ ).updateSchedule(newSchedule);
+ } else {
+ await Provider.of(
+ context,
+ listen: false,
+ ).createSchedule(newSchedule);
+ }
+
if (mounted) {
Navigator.pop(context);
setState(() {});
}
}
},
- child: const Text('Add'),
+ child: Text(isEditing ? 'Save Schedule' : 'Add Schedule'),
),
],
),
@@ -1279,6 +1532,7 @@ class _TodoManagerTabState extends State {
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
+ onTap: () => _showEditTodoDialog(context, todo),
leading: Checkbox(
value: todo.completed,
onChanged: (val) async {
@@ -1349,69 +1603,125 @@ class _TodoManagerTabState extends State {
void _showTodoDialog(BuildContext context, TodoItem? todo) {
final isEditing = todo != null;
final titleController = TextEditingController(text: todo?.title ?? '');
+ final todoDueDate = todo?.dueDate;
final dateController = TextEditingController(
- text: todo?.dueDate?.toIso8601String().split('T')[0] ?? '',
+ text: todoDueDate != null ? todoDueDate.toIso8601String().split('T')[0] : '',
+ );
+ final timeController = TextEditingController(
+ text: todoDueDate != null ? TimeOfDay.fromDateTime(todoDueDate).format(context) : '',
);
String? selectedFamilyMemberId = todo?.familyMemberId;
bool isCompleted = todo?.completed ?? false;
+ TimeOfDay? selectedTime = todoDueDate != null ? TimeOfDay.fromDateTime(todoDueDate) : null;
+
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: Text(isEditing ? 'Edit Todo' : 'Add Todo'),
- content: SingleChildScrollView(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- TextField(
- controller: titleController,
- decoration: const InputDecoration(labelText: 'Title'),
- ),
- TextField(
- controller: dateController,
- decoration: const InputDecoration(
- labelText: 'Due Date (YYYY-MM-DD)',
- hintText: '2024-01-01',
+ content: SizedBox(
+ width: 600,
+ child: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ TextField(
+ controller: titleController,
+ decoration: const InputDecoration(
+ labelText: 'Title',
+ border: OutlineInputBorder(),
+ ),
),
- ),
- FutureBuilder>(
- future: Provider.of(context, listen: false)
- .fetchFamilyMembers(),
- builder: (context, snapshot) {
- if (!snapshot.hasData) return const SizedBox();
- final members = snapshot.data!;
- return DropdownButtonFormField(
- value: selectedFamilyMemberId,
- decoration: const InputDecoration(
- labelText: 'Family Member',
- ),
- items: members.map((member) {
- return DropdownMenuItem(
- value: member.id,
- child: Text(member.name),
- );
- }).toList(),
- onChanged: (value) {
+ const SizedBox(height: 16),
+ TextField(
+ controller: dateController,
+ readOnly: true,
+ decoration: const InputDecoration(
+ labelText: 'Due Date',
+ hintText: 'Select date',
+ suffixIcon: Icon(Icons.calendar_today),
+ border: OutlineInputBorder(),
+ ),
+ onTap: () async {
+ final DateTime? picked = await showDatePicker(
+ context: context,
+ initialDate: DateTime.tryParse(dateController.text) ??
+ DateTime.now(),
+ firstDate: DateTime(2000),
+ lastDate: DateTime(2101),
+ );
+ if (picked != null) {
setDialogState(() {
- selectedFamilyMemberId = value;
+ dateController.text =
+ picked.toIso8601String().split('T')[0];
});
- },
- );
- },
- ),
- if (isEditing)
- SwitchListTile(
- title: const Text('Completed'),
- value: isCompleted,
- onChanged: (value) {
- setDialogState(() {
- isCompleted = value;
- });
+ }
},
),
- ],
+ const SizedBox(height: 16),
+ TextField(
+ controller: timeController,
+ readOnly: true,
+ decoration: const InputDecoration(
+ labelText: 'Time (Optional)',
+ hintText: 'Select time',
+ suffixIcon: Icon(Icons.access_time),
+ border: OutlineInputBorder(),
+ ),
+ onTap: () async {
+ final TimeOfDay? picked = await showTimePicker(
+ context: context,
+ initialTime: selectedTime ?? TimeOfDay.now(),
+ );
+ if (picked != null) {
+ setDialogState(() {
+ selectedTime = picked;
+ timeController.text = picked.format(context);
+ });
+ }
+ },
+ ),
+ const SizedBox(height: 16),
+ FutureBuilder>(
+ future: Provider.of(context, listen: false)
+ .fetchFamilyMembers(),
+ builder: (context, snapshot) {
+ if (!snapshot.hasData) return const SizedBox();
+ final members = snapshot.data!;
+ return DropdownButtonFormField(
+ value: selectedFamilyMemberId,
+ decoration: const InputDecoration(
+ labelText: 'Family Member',
+ border: OutlineInputBorder(),
+ ),
+ items: members.map((member) {
+ return DropdownMenuItem(
+ value: member.id,
+ child: Text(member.name),
+ );
+ }).toList(),
+ onChanged: (value) {
+ setDialogState(() {
+ selectedFamilyMemberId = value;
+ });
+ },
+ );
+ },
+ ),
+ if (isEditing)
+ SwitchListTile(
+ title: const Text('Completed'),
+ value: isCompleted,
+ onChanged: (value) {
+ setDialogState(() {
+ isCompleted = value;
+ });
+ },
+ ),
+ ],
+ ),
),
),
actions: [
@@ -1419,20 +1729,33 @@ class _TodoManagerTabState extends State {
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
- TextButton(
+ ElevatedButton(
onPressed: () async {
if (titleController.text.isNotEmpty &&
selectedFamilyMemberId != null) {
- final date = dateController.text.isNotEmpty
- ? DateTime.tryParse(dateController.text)
- : null;
-
+ DateTime? fullDate;
+ if (dateController.text.isNotEmpty) {
+ final baseDate = DateTime.tryParse(dateController.text);
+ if (baseDate != null) {
+ fullDate = baseDate;
+ if (selectedTime != null) {
+ fullDate = DateTime(
+ fullDate.year,
+ fullDate.month,
+ fullDate.day,
+ selectedTime!.hour,
+ selectedTime!.minute,
+ );
+ }
+ }
+ }
+
final newItem = TodoItem(
id: todo?.id ?? '',
familyMemberId: selectedFamilyMemberId!,
title: titleController.text,
completed: isCompleted,
- dueDate: date,
+ dueDate: fullDate,
);
if (isEditing) {
@@ -1458,7 +1781,7 @@ class _TodoManagerTabState extends State {
);
}
},
- child: Text(isEditing ? 'Save' : 'Add'),
+ child: Text(isEditing ? 'Save Todo' : 'Add Todo'),
),
],
),
diff --git a/flutter_app/lib/screens/tv/tv_dashboard_screen.dart b/flutter_app/lib/screens/tv/tv_dashboard_screen.dart
index 5cff193..71c285b 100644
--- a/flutter_app/lib/screens/tv/tv_dashboard_screen.dart
+++ b/flutter_app/lib/screens/tv/tv_dashboard_screen.dart
@@ -90,26 +90,37 @@ class _TvDashboardScreenState extends State {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- // Left Column: Calendar, Schedule, Announcement
+ // Left Column: Calendar, Bible Verse (Swapped)
Expanded(
flex: 3,
child: Column(
children: [
const Expanded(
- flex: 4, child: CalendarWidget()),
+ flex: 3, child: CalendarWidget()), // Increased flex from 2 to 3
const SizedBox(height: 16),
const Expanded(
- flex: 4, child: ScheduleListWidget()),
- const SizedBox(height: 16),
- const Expanded(
- flex: 2, child: AnnouncementWidget()),
+ flex: 3, child: BibleVerseWidget()), // Reduced flex from 4 to 3
],
),
),
const SizedBox(width: 24),
- // Center Column: Photo Slideshow
+ // Center Column: Todos, Weekly Schedule (Swapped)
Expanded(
- flex: 4,
+ flex: 3, // Reduced from 4 to 3
+ child: Column(
+ children: [
+ const Expanded(
+ flex: 6, child: TodoListWidget()),
+ const SizedBox(height: 16),
+ const Expanded(
+ flex: 6, child: ScheduleListWidget()),
+ ],
+ ),
+ ),
+ const SizedBox(width: 24),
+ // Right Column: Photo Slideshow
+ Expanded(
+ flex: 4, // Increased from 3 to 4
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
@@ -125,20 +136,6 @@ class _TvDashboardScreenState extends State {
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()),
- ],
- ),
- ),
],
),
),
diff --git a/flutter_app/lib/services/photo_service.dart b/flutter_app/lib/services/photo_service.dart
index 540abbd..dfd45ad 100644
--- a/flutter_app/lib/services/photo_service.dart
+++ b/flutter_app/lib/services/photo_service.dart
@@ -76,4 +76,22 @@ class PhotoService {
}
await _client.delete("${ApiConfig.photos}/$id");
}
+
+ Future getGoogleAuthUrl() async {
+ final data = await _client.get("${ApiConfig.photos}/auth/url");
+ return data["url"] as String;
+ }
+
+ Future getGoogleStatus() async {
+ try {
+ final data = await _client.get("${ApiConfig.photos}/status");
+ return data["connected"] as bool? ?? false;
+ } catch (e) {
+ return false;
+ }
+ }
+
+ Future disconnectGoogle() async {
+ await _client.get("${ApiConfig.photos}/disconnect");
+ }
}
diff --git a/flutter_app/lib/widgets/announcement_widget.dart b/flutter_app/lib/widgets/announcement_widget.dart
index f155b74..a53d0d9 100644
--- a/flutter_app/lib/widgets/announcement_widget.dart
+++ b/flutter_app/lib/widgets/announcement_widget.dart
@@ -88,7 +88,7 @@ class _AnnouncementWidgetState extends State {
padding: const EdgeInsets.all(16),
child: const Center(
child: Text(
- 'Welcome Home! Have a great day.',
+ '우리 집에 오신 것을 환영합니다! 좋은 하루 되세요.',
style: TextStyle(color: Colors.white70, fontSize: 18),
),
),
diff --git a/flutter_app/lib/widgets/bible_verse_widget.dart b/flutter_app/lib/widgets/bible_verse_widget.dart
index 255760d..12ee4d6 100644
--- a/flutter_app/lib/widgets/bible_verse_widget.dart
+++ b/flutter_app/lib/widgets/bible_verse_widget.dart
@@ -39,7 +39,7 @@ class _BibleVerseWidgetState extends State {
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white10),
),
- padding: const EdgeInsets.all(24),
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 20), // Reduced horizontal padding
child: FutureBuilder(
future: _verseFuture,
builder: (context, snapshot) {
@@ -65,27 +65,38 @@ class _BibleVerseWidgetState extends State {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
- const Icon(
- Icons.format_quote,
- color: Color(0xFFBB86FC),
- size: 32,
+ // Quote icon removed as requested
+ Expanded(
+ child: Center(
+ child: FittedBox(
+ fit: BoxFit.scaleDown,
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 600), // Increased width to utilize space
+ child: Text(
+ verse.text.replaceAll('\\n', '\n'), // Just handle line breaks, keep original text
+ textAlign: TextAlign.center,
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 36,
+ height: 1.4,
+ fontStyle: FontStyle.italic,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ),
+ ),
+ ),
),
const SizedBox(height: 12),
- Text(
- verse.text,
- textAlign: TextAlign.center,
- style: Theme.of(context).textTheme.bodyMedium?.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,
+ FittedBox(
+ fit: BoxFit.scaleDown,
+ child: Text(
+ verse.reference,
+ style: TextStyle(
+ color: Theme.of(context).colorScheme.primary,
+ fontSize: 24, // Reduced from 32
+ fontWeight: FontWeight.bold,
+ ),
),
),
],
diff --git a/flutter_app/lib/widgets/calendar_widget.dart b/flutter_app/lib/widgets/calendar_widget.dart
index d561afe..0b1ac95 100644
--- a/flutter_app/lib/widgets/calendar_widget.dart
+++ b/flutter_app/lib/widgets/calendar_widget.dart
@@ -1,9 +1,51 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
+import 'package:provider/provider.dart';
+import '../models/schedule_item.dart';
+import '../services/schedule_service.dart';
-class CalendarWidget extends StatelessWidget {
+class CalendarWidget extends StatefulWidget {
const CalendarWidget({super.key});
+ @override
+ State createState() => _CalendarWidgetState();
+}
+
+class _CalendarWidgetState extends State {
+ List _schedules = [];
+
+ @override
+ void initState() {
+ super.initState();
+ _loadSchedules();
+ }
+
+ Future _loadSchedules() async {
+ try {
+ final schedules =
+ await Provider.of(context, listen: false)
+ .fetchMonthlySchedules();
+ if (mounted) {
+ setState(() {
+ _schedules = schedules;
+ });
+ }
+ } catch (e) {
+ debugPrint('Error loading schedules: $e');
+ }
+ }
+
+ bool _hasScheduleOn(DateTime date) {
+ final checkDate = DateTime(date.year, date.month, date.day);
+ return _schedules.any((s) {
+ final start =
+ DateTime(s.startDate.year, s.startDate.month, s.startDate.day);
+ final end = DateTime(s.endDate.year, s.endDate.month, s.endDate.day);
+ return (checkDate.isAtSameMomentAs(start) || checkDate.isAfter(start)) &&
+ (checkDate.isAtSameMomentAs(end) || checkDate.isBefore(end));
+ });
+ }
+
@override
Widget build(BuildContext context) {
final now = DateTime.now();
@@ -12,19 +54,10 @@ class CalendarWidget extends StatelessWidget {
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.
+ int offset = startingWeekday % 7;
return Container(
- padding: const EdgeInsets.all(16),
+ padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).cardTheme.color,
borderRadius: BorderRadius.circular(16),
@@ -32,38 +65,45 @@ class CalendarWidget extends StatelessWidget {
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),
- ],
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ DateFormat('yyyy년 M월').format(now),
+ style: Theme.of(context).textTheme.titleLarge?.copyWith(
+ fontWeight: FontWeight.bold,
+ color: Colors.white,
+ fontSize: 20,
+ ),
+ ),
+ const Icon(Icons.calendar_today,
+ color: Colors.white54, size: 20),
+ ],
+ ),
),
- const SizedBox(height: 16),
+ const SizedBox(height: 8),
// Days Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: ['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day) {
+ children: ['일', '월', '화', '수', '목', '금', '토'].asMap().entries.map((entry) {
+ final isSunday = entry.key == 0;
return Expanded(
child: Center(
child: Text(
- day,
- style: const TextStyle(
- color: Colors.white54,
+ entry.value,
+ style: TextStyle(
+ color: isSunday ? Colors.redAccent : Colors.white54,
fontWeight: FontWeight.bold,
+ fontSize: 14,
),
),
),
);
}).toList(),
),
- const SizedBox(height: 8),
+ const SizedBox(height: 4),
// Days Grid
Expanded(
child: Column(
@@ -73,36 +113,60 @@ class CalendarWidget extends StatelessWidget {
children: List.generate(7, (col) {
final index = row * 7 + col;
final dayNumber = index - offset + 1;
+ final isSunday = col == 0;
if (dayNumber < 1 || dayNumber > daysInMonth) {
return const Expanded(child: SizedBox.shrink());
}
+ final dayDate =
+ DateTime(now.year, now.month, dayNumber);
final isToday = dayNumber == now.day;
+ final hasSchedule = _hasScheduleOn(dayDate);
return Expanded(
child: Container(
- margin: const EdgeInsets.all(2),
+ margin: const EdgeInsets.all(1),
decoration: isToday
? BoxDecoration(
- color: Theme.of(context).colorScheme.primary,
+ border: Border.all(
+ color: Theme.of(context).colorScheme.primary,
+ width: 2,
+ ),
shape: BoxShape.circle,
)
: null,
- child: Center(
- child: FittedBox(
- fit: BoxFit.scaleDown,
- child: Text(
- '$dayNumber',
- style: TextStyle(
- color: isToday ? Colors.black : Colors.white,
- fontWeight: isToday
- ? FontWeight.bold
- : FontWeight.normal,
- fontSize: 12,
+ child: Stack(
+ alignment: Alignment.center,
+ children: [
+ Center(
+ child: FittedBox(
+ fit: BoxFit.scaleDown,
+ child: Text(
+ '$dayNumber',
+ style: TextStyle(
+ color: isToday
+ ? Theme.of(context).colorScheme.primary
+ : (isSunday ? Colors.redAccent : Colors.white),
+ fontWeight: FontWeight.bold,
+ fontSize: 18,
+ ),
+ ),
),
),
- ),
+ if (hasSchedule) // Show indicator even if it's today
+ Positioned(
+ bottom: 2,
+ child: Container(
+ width: 14,
+ height: 4,
+ decoration: BoxDecoration(
+ color: Colors.yellowAccent,
+ borderRadius: BorderRadius.circular(2),
+ ),
+ ),
+ ),
+ ],
),
),
);
diff --git a/flutter_app/lib/widgets/schedule_list_widget.dart b/flutter_app/lib/widgets/schedule_list_widget.dart
index 0faf1a3..f60da80 100644
--- a/flutter_app/lib/widgets/schedule_list_widget.dart
+++ b/flutter_app/lib/widgets/schedule_list_widget.dart
@@ -73,7 +73,7 @@ class _ScheduleListWidgetState extends State {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
- 'Weekly Schedule',
+ '주간 일정',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
@@ -102,17 +102,31 @@ class _ScheduleListWidgetState extends State {
);
}
- if (_schedules.isEmpty) {
+ // Correctly filter for current week (Sunday to Saturday)
+ final now = DateTime.now();
+ final today = DateTime(now.year, now.month, now.day);
+ // In Dart, weekday is 1 (Mon) to 7 (Sun).
+ // To get Sunday: if Mon(1), subtract 1. if Sun(7), subtract 0.
+ final daysToSubtract = now.weekday % 7;
+ final startOfWeek = today.subtract(Duration(days: daysToSubtract));
+ final endOfWeek = startOfWeek.add(const Duration(days: 6, hours: 23, minutes: 59, seconds: 59));
+
+ final filteredSchedules = _schedules.where((item) {
+ // Overlap check: schedule starts before week ends AND schedule ends after week starts
+ return item.startDate.isBefore(endOfWeek) && item.endDate.isAfter(startOfWeek);
+ }).toList();
+
+ if (filteredSchedules.isEmpty) {
return const Center(
child: Text(
- 'No schedules this week',
+ '이번 주 일정이 없습니다',
style: TextStyle(color: Colors.white54),
),
);
}
// Sort by date
- final sortedSchedules = List.from(_schedules);
+ final sortedSchedules = List.from(filteredSchedules);
sortedSchedules.sort((a, b) => a.startDate.compareTo(b.startDate));
return ListView.separated(
@@ -121,41 +135,56 @@ class _ScheduleListWidgetState extends State {
const Divider(color: Colors.white10),
itemBuilder: (context, index) {
final item = sortedSchedules[index];
- final timeStr = item.isAllDay
- ? 'All Day'
- : DateFormat('HH:mm').format(item.startDate);
+
+ // Multi-day check
+ final isMultiDay = item.startDate.year != item.endDate.year ||
+ item.startDate.month != item.endDate.month ||
+ item.startDate.day != item.endDate.day;
+
+ String? dateRangeStr;
+ if (isMultiDay) {
+ final startStr = DateFormat('MM/dd').format(item.startDate);
+ final endStr = DateFormat('MM/dd').format(item.endDate);
+ dateRangeStr = '$startStr ~ $endStr';
+ }
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Container(
padding: const EdgeInsets.symmetric(
- horizontal: 12,
- vertical: 8,
+ horizontal: 10,
+ vertical: 6,
),
decoration: BoxDecoration(
- color: Colors.white10,
+ color: isMultiDay ? Colors.blue.withOpacity(0.2) : Colors.white10,
borderRadius: BorderRadius.circular(8),
+ border: isMultiDay ? Border.all(color: Colors.blue.withOpacity(0.5)) : null,
),
- 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,
+ child: FittedBox(
+ fit: BoxFit.scaleDown,
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ DateFormat('d').format(item.startDate),
+ style: TextStyle(
+ color: isMultiDay ? Colors.blue : Colors.white,
+ fontWeight: FontWeight.bold,
+ fontSize: 18,
+ height: 1.2,
+ ),
),
- ),
- Text(
- DateFormat('E').format(item.startDate),
- style: const TextStyle(
- color: Colors.white70,
- fontSize: 12,
+ Text(
+ DateFormat('E').format(item.startDate),
+ style: const TextStyle(
+ color: Colors.white70,
+ fontSize: 12,
+ height: 1.2,
+ ),
),
- ),
- ],
+ ],
+ ),
),
),
title: Text(
@@ -165,10 +194,15 @@ class _ScheduleListWidgetState extends State {
fontWeight: FontWeight.w500,
),
),
- subtitle: Text(
- timeStr,
- style: const TextStyle(color: Colors.white54),
- ),
+ subtitle: dateRangeStr != null
+ ? Text(
+ dateRangeStr,
+ style: const TextStyle(
+ color: Colors.white,
+ fontWeight: FontWeight.bold,
+ ),
+ )
+ : null,
);
},
);
diff --git a/flutter_app/lib/widgets/todo_list_widget.dart b/flutter_app/lib/widgets/todo_list_widget.dart
index 0f35a96..22e0b8d 100644
--- a/flutter_app/lib/widgets/todo_list_widget.dart
+++ b/flutter_app/lib/widgets/todo_list_widget.dart
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart';
+import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../models/todo_item.dart';
import '../models/family_member.dart';
@@ -85,7 +86,7 @@ class _TodoListWidgetState extends State {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
- "Today's Todos",
+ "오늘의 할 일",
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
@@ -128,7 +129,7 @@ class _TodoListWidgetState extends State {
Icon(Icons.thumb_up, color: Colors.white24, size: 32),
SizedBox(height: 8),
Text(
- 'All done for today!',
+ '오늘 할 일을 모두 마쳤습니다!',
style: TextStyle(color: Colors.white54),
),
],
@@ -218,6 +219,12 @@ class _TodoListWidgetState extends State {
decorationColor: Colors.white54,
),
),
+ subtitle: todo.dueDate != null && (todo.dueDate!.hour != 0 || todo.dueDate!.minute != 0)
+ ? Text(
+ DateFormat('HH:mm').format(todo.dueDate!),
+ style: const TextStyle(color: Colors.white54, fontSize: 12),
+ )
+ : null,
trailing: Checkbox(
value: todo.completed,
onChanged: (val) async {
diff --git a/flutter_app/lib/widgets/weather_widget.dart b/flutter_app/lib/widgets/weather_widget.dart
index ffe7afb..93e4411 100644
--- a/flutter_app/lib/widgets/weather_widget.dart
+++ b/flutter_app/lib/widgets/weather_widget.dart
@@ -1,3 +1,4 @@
+import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter/foundation.dart';
@@ -32,65 +33,6 @@ class _WeatherWidgetState extends State {
super.dispose();
}
- Widget _buildAqiIcon(int aqi) {
- Color color;
- IconData icon;
- String label;
-
- switch (aqi) {
- case 1: // Good
- color = Colors.blue;
- icon = Icons.sentiment_very_satisfied;
- label = "좋음";
- break;
- case 2: // Fair
- color = Colors.green;
- icon = Icons.sentiment_satisfied;
- label = "보통";
- break;
- case 3: // Moderate
- color = Colors.yellow[700]!;
- icon = Icons.sentiment_neutral;
- label = "주의";
- break;
- case 4: // Poor
- color = Colors.orange;
- icon = Icons.sentiment_dissatisfied;
- label = "나쁨";
- break;
- case 5: // Very Poor
- color = Colors.red;
- icon = Icons.sentiment_very_dissatisfied;
- label = "매우 나쁨";
- break;
- default:
- return const SizedBox.shrink();
- }
-
- return Column(
- children: [
- Container(
- padding: const EdgeInsets.all(4),
- decoration: BoxDecoration(
- color: color.withOpacity(0.2),
- shape: BoxShape.circle,
- border: Border.all(color: color, width: 2),
- ),
- child: Icon(icon, color: color, size: 20),
- ),
- const SizedBox(height: 2),
- Text(
- label,
- style: TextStyle(
- color: color,
- fontSize: 10,
- fontWeight: FontWeight.bold,
- ),
- ),
- ],
- );
- }
-
@override
Widget build(BuildContext context) {
return FutureBuilder(
@@ -100,116 +42,133 @@ class _WeatherWidgetState extends State {
).fetchWeather(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
- return const Center(
- child: SizedBox(
- width: 20,
- height: 20,
- child: CircularProgressIndicator(strokeWidth: 2),
- ),
- );
+ return const SizedBox.shrink();
}
- if (snapshot.hasError) {
- debugPrint('Weather Error: ${snapshot.error}');
- return const Text(
- 'Weather Unavailable',
- style: TextStyle(color: Colors.white54),
- );
- }
-
- if (!snapshot.hasData) {
+ if (snapshot.hasError || !snapshot.hasData) {
return const SizedBox.shrink();
}
final weather = snapshot.data!;
- // Assuming OpenWeatherMap icon format
- final iconUrl = (ApiConfig.useMockData || kIsWeb)
- ? null
- : (weather.icon.isNotEmpty
- ? "https://openweathermap.org/img/wn/${weather.icon}@2x.png"
- : null);
+ final weatherIcon = _getWeatherIcon(weather.icon);
- return Row(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- // Weather Icon
- if (iconUrl != null)
- Image.network(
- iconUrl,
- width: 72,
- height: 72,
- errorBuilder: (_, __, ___) =>
- const Icon(Icons.wb_sunny, color: Colors.amber),
- )
- else
- const Icon(Icons.wb_sunny, color: Colors.amber, size: 60),
- const SizedBox(width: 16),
-
- // Temperature & City info
- Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Row(
- crossAxisAlignment: CrossAxisAlignment.baseline,
- textBaseline: TextBaseline.alphabetic,
- children: [
- Text(
- '${weather.temperature.round()}°',
- style: Theme.of(context).textTheme.displaySmall?.copyWith(
- fontSize: 42, // Slightly larger to stand out
- color: Colors.yellowAccent,
- fontWeight: FontWeight.bold,
- height: 1.0,
- ),
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
+ decoration: BoxDecoration(
+ color: Colors.black.withOpacity(0.5),
+ borderRadius: BorderRadius.circular(24),
+ border: Border.all(color: Colors.white24, width: 1.5),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ // 1. Weather Icon (Material Icon)
+ Icon(
+ weatherIcon.icon,
+ color: weatherIcon.color,
+ size: 40,
+ ),
+
+ const SizedBox(width: 16),
+
+ // 2. Temperatures
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ // Current
+ Text(
+ '${weather.temperature.round()}°',
+ style: const TextStyle(
+ fontSize: 42, // Reduced from 54
+ fontWeight: FontWeight.bold,
+ color: Colors.white,
+ height: 1.0,
),
- const SizedBox(width: 12),
- Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- '${weather.city} · ${weather.description}',
- style: Theme.of(context).textTheme.titleLarge?.copyWith(
- color: Colors.white70,
- fontWeight: FontWeight.w500,
- fontSize: 20
- ),
- ),
- const SizedBox(height: 4),
- Text(
- '최고:${weather.tempMax.round()}° 최저:${weather.tempMin.round()}°',
- style: Theme.of(context).textTheme.titleLarge?.copyWith(
- color: Colors.white,
- fontSize: 22,
- fontWeight: FontWeight.bold
- ),
- ),
- ],
+ ),
+ const SizedBox(width: 20),
+
+ // Max
+ const Text('▲', style: TextStyle(color: Colors.redAccent, fontSize: 16)),
+ Text(
+ '${weather.tempMax.round()}°',
+ style: const TextStyle(
+ fontSize: 26,
+ fontWeight: FontWeight.bold,
+ color: Colors.white, // Reverted to white
),
- ],
- ),
+ ),
+ const SizedBox(width: 12),
+
+ // Min
+ const Text('▼', style: TextStyle(color: Colors.lightBlueAccent, fontSize: 16)),
+ Text(
+ '${weather.tempMin.round()}°',
+ style: const TextStyle(
+ fontSize: 26,
+ fontWeight: FontWeight.bold,
+ color: Colors.white, // Reverted to white
+ ),
+ ),
+ ],
+ ),
+
+ // 3. AQI
+ if (weather.aqi > 0) ...[
+ const SizedBox(width: 24),
+ Container(width: 2, height: 36, color: Colors.white24),
+ const SizedBox(width: 24),
+ _buildAqiBadge(weather.aqi),
],
- ),
-
- // AQI Indicator (Right side)
- if (weather.aqi > 0) ...[
- const SizedBox(width: 24),
- Container(
- height: 50,
- width: 2,
- color: Colors.white24,
- ),
- const SizedBox(width: 24),
- // Scaled up AQI
- Transform.scale(
- scale: 1.2,
- child: _buildAqiIcon(weather.aqi),
- ),
],
- ],
+ ),
);
},
);
}
+
+ // Helper to map OWM icon codes to Material Icons
+ ({IconData icon, Color color}) _getWeatherIcon(String iconCode) {
+ // Standard OWM codes: https://openweathermap.org/weather-conditions
+ if (iconCode.startsWith('01')) { // Clear sky
+ return (icon: Icons.sunny, color: Colors.amber);
+ } else if (iconCode.startsWith('02') || iconCode.startsWith('03') || iconCode.startsWith('04')) { // Clouds
+ return (icon: Icons.cloud, color: Colors.white70);
+ } else if (iconCode.startsWith('09') || iconCode.startsWith('10')) { // Rain
+ return (icon: Icons.umbrella, color: Colors.blueAccent);
+ } else if (iconCode.startsWith('11')) { // Thunderstorm
+ return (icon: Icons.thunderstorm, color: Colors.yellow);
+ } else if (iconCode.startsWith('13')) { // Snow
+ return (icon: Icons.ac_unit, color: Colors.lightBlueAccent);
+ } else if (iconCode.startsWith('50')) { // Mist/Fog
+ return (icon: Icons.waves, color: Colors.white54);
+ }
+ return (icon: Icons.wb_sunny, color: Colors.amber); // Fallback
+ }
+
+ Widget _buildAqiBadge(int aqi) {
+ Color color;
+ IconData icon;
+ String label;
+
+ switch (aqi) {
+ case 1: color = Colors.blue; icon = Icons.sentiment_very_satisfied; label = "좋음"; break;
+ case 2: color = Colors.green; icon = Icons.sentiment_satisfied; label = "보통"; break;
+ case 3: color = Colors.yellow[700]!; icon = Icons.sentiment_neutral; label = "주의"; break;
+ case 4: color = Colors.orange; icon = Icons.sentiment_dissatisfied; label = "나쁨"; break;
+ case 5: color = Colors.red; icon = Icons.sentiment_very_dissatisfied; label = "매우 나쁨"; break;
+ default: return const SizedBox.shrink();
+ }
+
+ return Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(icon, color: color, size: 36),
+ const SizedBox(width: 10),
+ Text(
+ label,
+ style: TextStyle(color: color, fontSize: 24, fontWeight: FontWeight.bold),
+ ),
+ ],
+ );
+ }
}
diff --git a/flutter_app/pubspec.lock b/flutter_app/pubspec.lock
index fc3cc14..416c98c 100644
--- a/flutter_app/pubspec.lock
+++ b/flutter_app/pubspec.lock
@@ -405,6 +405,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
+ url_launcher:
+ dependency: "direct main"
+ description:
+ name: url_launcher
+ sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.3.2"
+ url_launcher_android:
+ dependency: transitive
+ description:
+ name: url_launcher_android
+ sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.3.28"
+ url_launcher_ios:
+ dependency: transitive
+ description:
+ name: url_launcher_ios
+ sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.3.6"
+ url_launcher_linux:
+ dependency: transitive
+ description:
+ name: url_launcher_linux
+ sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.2.2"
+ url_launcher_macos:
+ dependency: transitive
+ description:
+ name: url_launcher_macos
+ sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.2.5"
+ url_launcher_platform_interface:
+ dependency: transitive
+ description:
+ name: url_launcher_platform_interface
+ sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.3.2"
+ url_launcher_web:
+ dependency: transitive
+ description:
+ name: url_launcher_web
+ sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.2"
+ url_launcher_windows:
+ dependency: transitive
+ description:
+ name: url_launcher_windows
+ sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.5"
vector_math:
dependency: transitive
description:
diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml
index 9ff9ba6..fe8b7ee 100644
--- a/flutter_app/pubspec.yaml
+++ b/flutter_app/pubspec.yaml
@@ -15,6 +15,7 @@ dependencies:
provider: ^6.1.2
google_fonts: ^6.1.0
file_picker: ^8.0.5
+ url_launcher: ^6.3.2
dev_dependencies:
flutter_test: