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: