From dcdf1f60b16a26c4ceb37b9c4d4c6a53c524aab1 Mon Sep 17 00:00:00 2001 From: oxiaoyu <825663811@qq.com> Date: Tue, 19 May 2026 11:46:42 +0800 Subject: [PATCH] create project --- .env.example | 14 + .gitignore | 58 + Jenkinsfile | 85 + backend/Dockerfile | 34 + backend/next-env.d.ts | 5 + backend/next.config.mjs | 6 + backend/package.json | 32 + backend/pnpm-lock.yaml | 748 ++++ backend/prisma/schema.prisma | 60 + backend/prisma/seed.ts | 75 + backend/src/lib/auth.ts | 44 + backend/src/lib/invite-code.ts | 19 + backend/src/lib/prisma.ts | 7 + backend/src/lib/response.ts | 9 + backend/src/pages/api/auth/login.ts | 41 + backend/src/pages/api/auth/logout.ts | 10 + backend/src/pages/api/auth/register.ts | 45 + backend/src/pages/api/bills/index.ts | 56 + backend/src/pages/api/families/[id].ts | 19 + backend/src/pages/api/families/index.ts | 64 + backend/src/pages/api/families/join.ts | 48 + backend/src/pages/api/families/leave.ts | 34 + backend/src/pages/api/hello.ts | 10 + backend/src/pages/api/user/active-family.ts | 25 + backend/src/pages/api/user/email.ts | 25 + backend/src/pages/api/user/profile.ts | 24 + backend/tsconfig.json | 21 + docker-compose.yml | 44 + frontend/analysis_options.yaml | 5 + frontend/lib/api/api_client.dart | 45 + frontend/lib/api/auth_api.dart | 30 + frontend/lib/api/bill_api.dart | 18 + frontend/lib/api/family_api.dart | 26 + frontend/lib/app.dart | 128 + frontend/lib/constants.dart | 28 + frontend/lib/main.dart | 23 + frontend/lib/models/bill.dart | 48 + frontend/lib/models/family.dart | 53 + frontend/lib/models/user.dart | 45 + frontend/lib/providers/auth_provider.dart | 113 + frontend/lib/providers/bill_provider.dart | 45 + frontend/lib/providers/family_provider.dart | 52 + .../lib/screens/add_bill/add_bill_screen.dart | 399 +++ .../lib/screens/auth/bind_email_screen.dart | 204 ++ frontend/lib/screens/auth/login_screen.dart | 215 ++ .../lib/screens/auth/register_screen.dart | 254 ++ .../lib/screens/auth/user_profile_screen.dart | 389 ++ .../family/family_management_screen.dart | 425 +++ frontend/lib/screens/home/home_screen.dart | 541 +++ .../lib/screens/profile/profile_screen.dart | 435 +++ .../screens/statistics/statistics_screen.dart | 696 ++++ frontend/lib/theme/app_theme.dart | 22 + frontend/lib/theme/colors.dart | 13 + frontend/lib/widgets/category_grid.dart | 59 + frontend/lib/widgets/category_icon.dart | 40 + frontend/lib/widgets/glass_card.dart | 55 + frontend/lib/widgets/number_pad.dart | 48 + frontend/lib/widgets/stat_card.dart | 52 + frontend/pubspec.yaml | 28 + index.html | 3113 +++++++++++++++++ 60 files changed, 9309 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Jenkinsfile create mode 100644 backend/Dockerfile create mode 100644 backend/next-env.d.ts create mode 100644 backend/next.config.mjs create mode 100644 backend/package.json create mode 100644 backend/pnpm-lock.yaml create mode 100644 backend/prisma/schema.prisma create mode 100644 backend/prisma/seed.ts create mode 100644 backend/src/lib/auth.ts create mode 100644 backend/src/lib/invite-code.ts create mode 100644 backend/src/lib/prisma.ts create mode 100644 backend/src/lib/response.ts create mode 100644 backend/src/pages/api/auth/login.ts create mode 100644 backend/src/pages/api/auth/logout.ts create mode 100644 backend/src/pages/api/auth/register.ts create mode 100644 backend/src/pages/api/bills/index.ts create mode 100644 backend/src/pages/api/families/[id].ts create mode 100644 backend/src/pages/api/families/index.ts create mode 100644 backend/src/pages/api/families/join.ts create mode 100644 backend/src/pages/api/families/leave.ts create mode 100644 backend/src/pages/api/hello.ts create mode 100644 backend/src/pages/api/user/active-family.ts create mode 100644 backend/src/pages/api/user/email.ts create mode 100644 backend/src/pages/api/user/profile.ts create mode 100644 backend/tsconfig.json create mode 100644 docker-compose.yml create mode 100644 frontend/analysis_options.yaml create mode 100644 frontend/lib/api/api_client.dart create mode 100644 frontend/lib/api/auth_api.dart create mode 100644 frontend/lib/api/bill_api.dart create mode 100644 frontend/lib/api/family_api.dart create mode 100644 frontend/lib/app.dart create mode 100644 frontend/lib/constants.dart create mode 100644 frontend/lib/main.dart create mode 100644 frontend/lib/models/bill.dart create mode 100644 frontend/lib/models/family.dart create mode 100644 frontend/lib/models/user.dart create mode 100644 frontend/lib/providers/auth_provider.dart create mode 100644 frontend/lib/providers/bill_provider.dart create mode 100644 frontend/lib/providers/family_provider.dart create mode 100644 frontend/lib/screens/add_bill/add_bill_screen.dart create mode 100644 frontend/lib/screens/auth/bind_email_screen.dart create mode 100644 frontend/lib/screens/auth/login_screen.dart create mode 100644 frontend/lib/screens/auth/register_screen.dart create mode 100644 frontend/lib/screens/auth/user_profile_screen.dart create mode 100644 frontend/lib/screens/family/family_management_screen.dart create mode 100644 frontend/lib/screens/home/home_screen.dart create mode 100644 frontend/lib/screens/profile/profile_screen.dart create mode 100644 frontend/lib/screens/statistics/statistics_screen.dart create mode 100644 frontend/lib/theme/app_theme.dart create mode 100644 frontend/lib/theme/colors.dart create mode 100644 frontend/lib/widgets/category_grid.dart create mode 100644 frontend/lib/widgets/category_icon.dart create mode 100644 frontend/lib/widgets/glass_card.dart create mode 100644 frontend/lib/widgets/number_pad.dart create mode 100644 frontend/lib/widgets/stat_card.dart create mode 100644 frontend/pubspec.yaml create mode 100644 index.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b719884 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Database +DATABASE_URL="postgresql://postgres:password@localhost:5432/family_accounting" + +# Authentication +NEXTAUTH_SECRET="change-me-to-a-random-secret" +NEXTAUTH_URL="http://localhost:3000" + +# JWT +JWT_SECRET="change-me-to-another-random-secret" + +# Docker (optional) +DB_PASSWORD="password" +REGISTRY="your-registry" +TAG="latest" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a922950 --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# Next.js +.next/ +out/ +build/ + +# Flutter +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ +android/ +ios/ +web/ +linux/ +windows/ +macos/ + +# Environment +.env +.env.local +.env.production +.env.*.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* +pnpm-debug.log* + +# Test +coverage/ + +# Sisyphus +.sisyphus/ + +# Docker +.docker/ + +# Prisma +backend/prisma/*.db +backend/prisma/*.db-journal \ No newline at end of file diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..15cf74a --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,85 @@ +pipeline { + agent any + + environment { + DOCKER_REGISTRY = 'your-registry' + IMAGE_NAME = 'family-accounting' + IMAGE_TAG = "${BUILD_NUMBER}" + CONTAINER_NAME = 'family_accounting_backend' + DEPLOY_DIR = '/opt/family-accounting' + } + + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + stage('Build Docker Image') { + steps { + script { + docker.build("${DOCKER_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}", "-f backend/Dockerfile backend/") + } + } + } + + stage('Push to Registry') { + steps { + script { + docker.withRegistry("https://${DOCKER_REGISTRY}", 'docker-registry-credentials') { + docker.image("${DOCKER_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}").push() + docker.image("${DOCKER_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}").push('latest') + } + } + } + } + + stage('Deploy via SSH') { + steps { + sshagent(['deploy-server-key']) { + sh """ + ssh -o StrictHostKeyChecking=no deploy@your-server << 'EOF' + cd ${DEPLOY_DIR} + export TAG=${IMAGE_TAG} + export REGISTRY=${DOCKER_REGISTRY} + docker compose pull backend + docker compose up -d backend + docker image prune -f + EOF + """ + } + } + } + + stage('Health Check') { + steps { + script { + sleep(10) + sh "curl -f http://your-server:3000/api/hello || exit 1" + } + } + } + + stage('Cleanup') { + steps { + script { + sh """ + docker image prune -f --filter "until=24h" + # 保留最近5个镜像 + docker images ${DOCKER_REGISTRY}/${IMAGE_NAME} --format '{{.ID}}' | tail -n +6 | xargs -r docker rmi + """ + } + } + } + } + + post { + success { + echo 'Deployment successful!' + } + failure { + echo 'Deployment failed!' + } + } +} diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..668f27b --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,34 @@ +# Stage 1: 依赖安装 + 构建 +FROM node:20-alpine AS builder +WORKDIR /app + +# 先装依赖(缓存最大化) +COPY package.json pnpm-lock.yaml ./ +RUN corepack enable && pnpm install --frozen-lockfile + +# Prisma 客户端生成 +COPY prisma/ ./prisma/ +RUN npx prisma generate + +# 复制源码并构建 +COPY . . +RUN npm run build + +# Stage 2: 最小运行时镜像 +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +# Standalone output 包含所有必要运行时文件 +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma +COPY --from=builder /app/node_modules/prisma ./node_modules/prisma + +EXPOSE 3000 + +CMD ["node", "server.js"] \ No newline at end of file diff --git a/backend/next-env.d.ts b/backend/next-env.d.ts new file mode 100644 index 0000000..a4a7b3f --- /dev/null +++ b/backend/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. diff --git a/backend/next.config.mjs b/backend/next.config.mjs new file mode 100644 index 0000000..472f56c --- /dev/null +++ b/backend/next.config.mjs @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + output: "standalone", +}; +export default nextConfig; \ No newline at end of file diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..4b8a1e6 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,32 @@ +{ + "name": "backend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "db:migrate": "prisma migrate dev", + "db:seed": "tsx prisma/seed.ts", + "db:studio": "prisma studio" + }, + "dependencies": { + "next": "^14.2.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "prisma": "^5.14.0", + "@prisma/client": "^5.14.0", + "zod": "^3.23.0", + "jose": "^5.3.0", + "bcryptjs": "^2.4.3" + }, + "devDependencies": { + "typescript": "^5.4.0", + "@types/node": "^20.0.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@types/bcryptjs": "^2.4.0", + "tsx": "^4.11.0" + } +} \ No newline at end of file diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml new file mode 100644 index 0000000..474345d --- /dev/null +++ b/backend/pnpm-lock.yaml @@ -0,0 +1,748 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@prisma/client': + specifier: ^5.14.0 + version: 5.22.0(prisma@5.22.0) + bcryptjs: + specifier: ^2.4.3 + version: 2.4.3 + jose: + specifier: ^5.3.0 + version: 5.10.0 + next: + specifier: ^14.2.0 + version: 14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + prisma: + specifier: ^5.14.0 + version: 5.22.0 + react: + specifier: ^18.3.0 + version: 18.3.1 + react-dom: + specifier: ^18.3.0 + version: 18.3.1(react@18.3.1) + zod: + specifier: ^3.23.0 + version: 3.25.76 + devDependencies: + '@types/bcryptjs': + specifier: ^2.4.0 + version: 2.4.6 + '@types/node': + specifier: ^20.0.0 + version: 20.19.41 + '@types/react': + specifier: ^18.3.0 + version: 18.3.28 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.28) + tsx: + specifier: ^4.11.0 + version: 4.21.0 + typescript: + specifier: ^5.4.0 + version: 5.9.3 + +packages: + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@next/env@14.2.35': + resolution: {integrity: sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==} + + '@next/swc-darwin-arm64@14.2.33': + resolution: {integrity: sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@14.2.33': + resolution: {integrity: sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@14.2.33': + resolution: {integrity: sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-arm64-musl@14.2.33': + resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@next/swc-linux-x64-gnu@14.2.33': + resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-x64-musl@14.2.33': + resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@next/swc-win32-arm64-msvc@14.2.33': + resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-ia32-msvc@14.2.33': + resolution: {integrity: sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@next/swc-win32-x64-msvc@14.2.33': + resolution: {integrity: sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@prisma/client@5.22.0': + resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==} + engines: {node: '>=16.13'} + peerDependencies: + prisma: '*' + peerDependenciesMeta: + prisma: + optional: true + + '@prisma/debug@5.22.0': + resolution: {integrity: sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==} + + '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': + resolution: {integrity: sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==} + + '@prisma/engines@5.22.0': + resolution: {integrity: sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==} + + '@prisma/fetch-engine@5.22.0': + resolution: {integrity: sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==} + + '@prisma/get-platform@5.22.0': + resolution: {integrity: sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==} + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/helpers@0.5.5': + resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==} + + '@types/bcryptjs@2.4.6': + resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==} + + '@types/node@20.19.41': + resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.28': + resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} + + bcryptjs@2.4.3: + resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + caniuse-lite@1.0.30001792: + resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + next@14.2.35: + resolution: {integrity: sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==} + engines: {node: '>=18.17.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.41.2 + react: ^18.2.0 + react-dom: ^18.2.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + sass: + optional: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + prisma@5.22.0: + resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==} + engines: {node: '>=16.13'} + hasBin: true + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + styled-jsx@5.1.1: + resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@next/env@14.2.35': {} + + '@next/swc-darwin-arm64@14.2.33': + optional: true + + '@next/swc-darwin-x64@14.2.33': + optional: true + + '@next/swc-linux-arm64-gnu@14.2.33': + optional: true + + '@next/swc-linux-arm64-musl@14.2.33': + optional: true + + '@next/swc-linux-x64-gnu@14.2.33': + optional: true + + '@next/swc-linux-x64-musl@14.2.33': + optional: true + + '@next/swc-win32-arm64-msvc@14.2.33': + optional: true + + '@next/swc-win32-ia32-msvc@14.2.33': + optional: true + + '@next/swc-win32-x64-msvc@14.2.33': + optional: true + + '@prisma/client@5.22.0(prisma@5.22.0)': + optionalDependencies: + prisma: 5.22.0 + + '@prisma/debug@5.22.0': {} + + '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': {} + + '@prisma/engines@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 + '@prisma/fetch-engine': 5.22.0 + '@prisma/get-platform': 5.22.0 + + '@prisma/fetch-engine@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 + '@prisma/get-platform': 5.22.0 + + '@prisma/get-platform@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + + '@swc/counter@0.1.3': {} + + '@swc/helpers@0.5.5': + dependencies: + '@swc/counter': 0.1.3 + tslib: 2.8.1 + + '@types/bcryptjs@2.4.6': {} + + '@types/node@20.19.41': + dependencies: + undici-types: 6.21.0 + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.28)': + dependencies: + '@types/react': 18.3.28 + + '@types/react@18.3.28': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + bcryptjs@2.4.3: {} + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + caniuse-lite@1.0.30001792: {} + + client-only@0.0.1: {} + + csstype@3.2.3: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + graceful-fs@4.2.11: {} + + jose@5.10.0: {} + + js-tokens@4.0.0: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + nanoid@3.3.12: {} + + next@14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@next/env': 14.2.35 + '@swc/helpers': 0.5.5 + busboy: 1.6.0 + caniuse-lite: 1.0.30001792 + graceful-fs: 4.2.11 + postcss: 8.4.31 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + styled-jsx: 5.1.1(react@18.3.1) + optionalDependencies: + '@next/swc-darwin-arm64': 14.2.33 + '@next/swc-darwin-x64': 14.2.33 + '@next/swc-linux-arm64-gnu': 14.2.33 + '@next/swc-linux-arm64-musl': 14.2.33 + '@next/swc-linux-x64-gnu': 14.2.33 + '@next/swc-linux-x64-musl': 14.2.33 + '@next/swc-win32-arm64-msvc': 14.2.33 + '@next/swc-win32-ia32-msvc': 14.2.33 + '@next/swc-win32-x64-msvc': 14.2.33 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + picocolors@1.1.1: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prisma@5.22.0: + dependencies: + '@prisma/engines': 5.22.0 + optionalDependencies: + fsevents: 2.3.3 + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + resolve-pkg-maps@1.0.0: {} + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + source-map-js@1.2.1: {} + + streamsearch@1.1.0: {} + + styled-jsx@5.1.1(react@18.3.1): + dependencies: + client-only: 0.0.1 + react: 18.3.1 + + tslib@2.8.1: {} + + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.14.0 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + zod@3.25.76: {} diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..5e8f754 --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,60 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(cuid()) + phone String @unique + passwordHash String + nickname String + email String? + familyIds String[] + activeFamilyId String? + joinDate DateTime @default(now()) + createdAt DateTime @default(now()) + bills Bill[] + familiesCreated Family[] @relation("FamilyCreator") +} + +model Family { + id String @id @default(cuid()) + name String + inviteCode String @unique + createdBy String + creator User @relation("FamilyCreator", fields: [createdBy], references: [id]) + createdAt DateTime @default(now()) + members FamilyMember[] + bills Bill[] +} + +model FamilyMember { + id String @id @default(cuid()) + familyId String + family Family @relation(fields: [familyId], references: [id], onDelete: Cascade) + userId String + nickname String + role String + joinedAt DateTime @default(now()) + @@unique([familyId, userId]) +} + +model Bill { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id]) + familyId String? + family Family? @relation(fields: [familyId], references: [id]) + type String + amount Float + category String + note String? + emoji String? + time String + date DateTime + createdAt DateTime @default(now()) +} \ No newline at end of file diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts new file mode 100644 index 0000000..92dce78 --- /dev/null +++ b/backend/prisma/seed.ts @@ -0,0 +1,75 @@ +import { PrismaClient } from '@prisma/client'; +import bcrypt from 'bcryptjs'; + +const prisma = new PrismaClient(); + +async function main() { + const passwordHash = await bcrypt.hash('123456', 10); + + const user = await prisma.user.upsert({ + where: { phone: '13800138000' }, + update: {}, + create: { + phone: '13800138000', + passwordHash, + nickname: '小明', + joinDate: new Date('2026-03-01'), + }, + }); + + const family = await prisma.family.upsert({ + where: { id: 'f_preset_home' }, + update: {}, + create: { + id: 'f_preset_home', + name: '小明的家', + inviteCode: 'ABCDEF', + createdBy: user.id, + members: { + create: { + userId: user.id, + nickname: '小明', + role: 'owner', + }, + }, + }, + }); + + await prisma.user.update({ + where: { id: user.id }, + data: { + familyIds: [family.id], + activeFamilyId: family.id, + }, + }); + + const sampleBills = [ + { type: 'expense' as const, amount: 32, category: '餐饮', emoji: '🍜', note: '午餐-兰州拉面', time: '12:30', date: new Date('2026-05-01') }, + { type: 'expense' as const, amount: 15, category: '交通', emoji: '🚇', note: '地铁通勤', time: '08:45', date: new Date('2026-05-02') }, + { type: 'expense' as const, amount: 128, category: '购物', emoji: '🛒', note: '超市采购', time: '18:20', date: new Date('2026-05-03') }, + { type: 'expense' as const, amount: 45, category: '餐饮', emoji: '☕', note: '星巴克咖啡', time: '15:00', date: new Date('2026-05-03') }, + { type: 'income' as const, amount: 15000, category: '工资', emoji: '💰', note: '5月工资', time: '10:00', date: new Date('2026-05-05') }, + { type: 'expense' as const, amount: 200, category: '居住', emoji: '🏠', note: '物业费', time: '09:00', date: new Date('2026-05-06') }, + { type: 'expense' as const, amount: 88, category: '餐饮', emoji: '🍣', note: '和朋友聚餐', time: '19:30', date: new Date('2026-05-07') }, + { type: 'expense' as const, amount: 56, category: '交通', emoji: '🚕', note: '打车回家', time: '22:15', date: new Date('2026-05-08') }, + ]; + + for (const bill of sampleBills) { + await prisma.bill.create({ + data: { + userId: user.id, + familyId: family.id, + ...bill, + }, + }); + } + + console.log('✅ 种子数据已创建:'); + console.log(' 预设账号: 13800138000 / 123456'); + console.log(' 家庭: 小明的家'); + console.log(' 账单: 8 条示例'); +} + +main() + .catch((e) => { console.error(e); process.exit(1); }) + .finally(() => prisma.$disconnect()); \ No newline at end of file diff --git a/backend/src/lib/auth.ts b/backend/src/lib/auth.ts new file mode 100644 index 0000000..a4fd739 --- /dev/null +++ b/backend/src/lib/auth.ts @@ -0,0 +1,44 @@ +import { SignJWT, jwtVerify } from 'jose'; +import { NextApiRequest, NextApiResponse } from 'next'; + +const secret = new TextEncoder().encode(process.env.JWT_SECRET || 'fallback-secret'); + +export async function signToken(payload: { userId: string; phone: string }) { + return new SignJWT(payload) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime('7d') + .sign(secret); +} + +export async function verifyToken(token: string) { + try { + const { payload } = await jwtVerify(token, secret); + return payload as { userId: string; phone: string }; + } catch { + return null; + } +} + +export interface AuthenticatedRequest extends NextApiRequest { + user?: { userId: string; phone: string }; +} + +export function withAuth( + handler: (req: AuthenticatedRequest, res: NextApiResponse) => Promise +) { + return async (req: AuthenticatedRequest, res: NextApiResponse) => { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + res.status(401).json({ success: false, error: '未提供认证令牌' }); + return; + } + const token = authHeader.slice(7); + const payload = await verifyToken(token); + if (!payload) { + res.status(401).json({ success: false, error: '令牌无效或已过期' }); + return; + } + req.user = payload; + return handler(req, res); + }; +} \ No newline at end of file diff --git a/backend/src/lib/invite-code.ts b/backend/src/lib/invite-code.ts new file mode 100644 index 0000000..6ad88dd --- /dev/null +++ b/backend/src/lib/invite-code.ts @@ -0,0 +1,19 @@ +const CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + +function randomChar(): string { + return CHARS[Math.floor(Math.random() * CHARS.length)]; +} + +export function generateInviteCode(): string { + return Array.from({ length: 6 }, () => randomChar()).join(''); +} + +export async function createUniqueInviteCode( + checkExists: (code: string) => Promise +): Promise { + let code: string; + do { + code = generateInviteCode(); + } while (await checkExists(code)); + return code; +} \ No newline at end of file diff --git a/backend/src/lib/prisma.ts b/backend/src/lib/prisma.ts new file mode 100644 index 0000000..c2ba7e2 --- /dev/null +++ b/backend/src/lib/prisma.ts @@ -0,0 +1,7 @@ +import { PrismaClient } from '@prisma/client'; + +const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }; + +export const prisma = globalForPrisma.prisma ?? new PrismaClient(); + +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; \ No newline at end of file diff --git a/backend/src/lib/response.ts b/backend/src/lib/response.ts new file mode 100644 index 0000000..2f723ff --- /dev/null +++ b/backend/src/lib/response.ts @@ -0,0 +1,9 @@ +import { NextApiResponse } from 'next'; + +export function success(res: NextApiResponse, data: T, status = 200) { + return res.status(status).json({ success: true, data }); +} + +export function error(res: NextApiResponse, message: string, status = 400) { + return res.status(status).json({ success: false, error: message }); +} \ No newline at end of file diff --git a/backend/src/pages/api/auth/login.ts b/backend/src/pages/api/auth/login.ts new file mode 100644 index 0000000..471d970 --- /dev/null +++ b/backend/src/pages/api/auth/login.ts @@ -0,0 +1,41 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { prisma } from '@/lib/prisma'; +import { signToken } from '@/lib/auth'; +import { success, error } from '@/lib/response'; +import bcrypt from 'bcryptjs'; +import { z } from 'zod'; + +const schema = z.object({ + phone: z.string().regex(/^1\d{10}$/), + password: z.string().min(1), +}); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return error(res, 'Method not allowed', 405); + + const parsed = schema.safeParse(req.body); + if (!parsed.success) return error(res, parsed.error.errors[0].message); + + const { phone, password } = parsed.data; + const user = await prisma.user.findUnique({ where: { phone } }); + if (!user) return error(res, '手机号未注册'); + + const valid = await bcrypt.compare(password, user.passwordHash); + if (!valid) return error(res, '密码错误'); + + const token = await signToken({ userId: user.id, phone: user.phone }); + + return success(res, { + token, + user: { + id: user.id, + phone: user.phone, + nickname: user.nickname, + email: user.email, + familyIds: user.familyIds, + activeFamilyId: user.activeFamilyId, + joinDate: user.joinDate.toISOString().slice(0, 7), + createdAt: user.createdAt.toISOString(), + }, + }); +} \ No newline at end of file diff --git a/backend/src/pages/api/auth/logout.ts b/backend/src/pages/api/auth/logout.ts new file mode 100644 index 0000000..8817783 --- /dev/null +++ b/backend/src/pages/api/auth/logout.ts @@ -0,0 +1,10 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { success } from '@/lib/response'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') { + res.status(405).json({ success: false, error: 'Method not allowed' }); + return; + } + return success(res, { message: '已登出' }); +} \ No newline at end of file diff --git a/backend/src/pages/api/auth/register.ts b/backend/src/pages/api/auth/register.ts new file mode 100644 index 0000000..2d82ffd --- /dev/null +++ b/backend/src/pages/api/auth/register.ts @@ -0,0 +1,45 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { prisma } from '@/lib/prisma'; +import { signToken } from '@/lib/auth'; +import { success, error } from '@/lib/response'; +import bcrypt from 'bcryptjs'; +import { z } from 'zod'; + +const schema = z.object({ + phone: z.string().regex(/^1\d{10}$/, '手机号格式不正确'), + password: z.string().min(6, '密码至少6位'), + nickname: z.string().min(1, '昵称不能为空').max(20), +}); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') return error(res, 'Method not allowed', 405); + + const parsed = schema.safeParse(req.body); + if (!parsed.success) return error(res, parsed.error.errors[0].message); + + const { phone, password, nickname } = parsed.data; + + const existing = await prisma.user.findUnique({ where: { phone } }); + if (existing) return error(res, '该手机号已注册'); + + const passwordHash = await bcrypt.hash(password, 10); + const user = await prisma.user.create({ + data: { phone, passwordHash, nickname, joinDate: new Date() }, + }); + + const token = await signToken({ userId: user.id, phone: user.phone }); + + return success(res, { + token, + user: { + id: user.id, + phone: user.phone, + nickname: user.nickname, + email: user.email, + familyIds: user.familyIds, + activeFamilyId: user.activeFamilyId, + joinDate: user.joinDate.toISOString().slice(0, 7), + createdAt: user.createdAt.toISOString(), + }, + }, 201); +} \ No newline at end of file diff --git a/backend/src/pages/api/bills/index.ts b/backend/src/pages/api/bills/index.ts new file mode 100644 index 0000000..a4a9d62 --- /dev/null +++ b/backend/src/pages/api/bills/index.ts @@ -0,0 +1,56 @@ +import type { NextApiResponse } from 'next'; +import { prisma } from '@/lib/prisma'; +import { withAuth, AuthenticatedRequest } from '@/lib/auth'; +import { success, error } from '@/lib/response'; +import { z } from 'zod'; + +const createSchema = z.object({ + type: z.enum(['expense', 'income']), + amount: z.number().positive(), + category: z.string().min(1), + note: z.string().optional().default(''), + emoji: z.string().optional().default('📝'), + time: z.string().regex(/^\d{2}:\d{2}$/), + date: z.string(), + familyId: z.string().nullable().optional().default(null), +}); + +async function handler(req: AuthenticatedRequest, res: NextApiResponse) { + if (req.method === 'GET') { + const { userId, familyId } = req.query; + + const where: any = { userId: req.user!.userId }; + if (familyId) { + where.familyId = familyId as string; + } else if (!userId) { + where.familyId = null; + } + + const bills = await prisma.bill.findMany({ + where, + orderBy: { date: 'desc' }, + take: 100, + }); + + return success(res, bills); + } + + if (req.method === 'POST') { + const parsed = createSchema.safeParse(req.body); + if (!parsed.success) return error(res, parsed.error.errors[0].message); + + const bill = await prisma.bill.create({ + data: { + userId: req.user!.userId, + ...parsed.data, + date: new Date(parsed.data.date), + }, + }); + + return success(res, bill, 201); + } + + return error(res, 'Method not allowed', 405); +} + +export default withAuth(handler); \ No newline at end of file diff --git a/backend/src/pages/api/families/[id].ts b/backend/src/pages/api/families/[id].ts new file mode 100644 index 0000000..c6e6e7a --- /dev/null +++ b/backend/src/pages/api/families/[id].ts @@ -0,0 +1,19 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { prisma } from '@/lib/prisma'; +import { withAuth, AuthenticatedRequest } from '@/lib/auth'; +import { success, error } from '@/lib/response'; + +async function handler(req: AuthenticatedRequest, res: NextApiResponse) { + if (req.method !== 'GET') return error(res, 'Method not allowed', 405); + + const { id } = req.query; + const family = await prisma.family.findUnique({ + where: { id: id as string }, + include: { members: true }, + }); + + if (!family) return error(res, '家庭不存在', 404); + return success(res, family); +} + +export default withAuth(handler); \ No newline at end of file diff --git a/backend/src/pages/api/families/index.ts b/backend/src/pages/api/families/index.ts new file mode 100644 index 0000000..5259ea2 --- /dev/null +++ b/backend/src/pages/api/families/index.ts @@ -0,0 +1,64 @@ +import type { NextApiResponse } from 'next'; +import { prisma } from '@/lib/prisma'; +import { withAuth, AuthenticatedRequest } from '@/lib/auth'; +import { success, error } from '@/lib/response'; +import { createUniqueInviteCode } from '@/lib/invite-code'; +import { z } from 'zod'; + +const createSchema = z.object({ name: z.string().min(1).max(30) }); + +async function handler(req: AuthenticatedRequest, res: NextApiResponse) { + if (req.method === 'GET') { + const user = await prisma.user.findUnique({ where: { id: req.user!.userId } }); + if (!user) return error(res, '用户不存在', 404); + + const families = await prisma.family.findMany({ + where: { id: { in: user.familyIds } }, + include: { members: true }, + }); + return success(res, families); + } + + if (req.method === 'POST') { + const parsed = createSchema.safeParse(req.body); + if (!parsed.success) return error(res, parsed.error.errors[0].message); + + const inviteCode = await createUniqueInviteCode(async (code) => { + const existing = await prisma.family.findUnique({ where: { inviteCode: code } }); + return !!existing; + }); + + const user = await prisma.user.findUnique({ where: { id: req.user!.userId } }); + if (!user) return error(res, '用户不存在', 404); + + const family = await prisma.family.create({ + data: { + name: parsed.data.name, + inviteCode, + createdBy: req.user!.userId, + members: { + create: { + userId: req.user!.userId, + nickname: user.nickname, + role: 'owner', + }, + }, + }, + include: { members: true }, + }); + + await prisma.user.update({ + where: { id: req.user!.userId }, + data: { + familyIds: { push: family.id }, + activeFamilyId: family.id, + }, + }); + + return success(res, family, 201); + } + + return error(res, 'Method not allowed', 405); +} + +export default withAuth(handler); \ No newline at end of file diff --git a/backend/src/pages/api/families/join.ts b/backend/src/pages/api/families/join.ts new file mode 100644 index 0000000..8b0afdf --- /dev/null +++ b/backend/src/pages/api/families/join.ts @@ -0,0 +1,48 @@ +import type { NextApiResponse } from 'next'; +import { prisma } from '@/lib/prisma'; +import { withAuth, AuthenticatedRequest } from '@/lib/auth'; +import { success, error } from '@/lib/response'; +import { z } from 'zod'; + +const schema = z.object({ inviteCode: z.string().length(6) }); + +async function handler(req: AuthenticatedRequest, res: NextApiResponse) { + if (req.method !== 'POST') return error(res, 'Method not allowed', 405); + + const parsed = schema.safeParse(req.body); + if (!parsed.success) return error(res, '邀请码格式不正确'); + + const family = await prisma.family.findUnique({ + where: { inviteCode: parsed.data.inviteCode }, + include: { members: true }, + }); + + if (!family) return error(res, '邀请码无效'); + if (family.members.some(m => m.userId === req.user!.userId)) { + return error(res, '你已是该家庭成员'); + } + + const user = await prisma.user.findUnique({ where: { id: req.user!.userId } }); + if (!user) return error(res, '用户不存在', 404); + + await prisma.familyMember.create({ + data: { + familyId: family.id, + userId: req.user!.userId, + nickname: user.nickname, + role: 'member', + }, + }); + + await prisma.user.update({ + where: { id: req.user!.userId }, + data: { + familyIds: [...user.familyIds, family.id], + activeFamilyId: family.id, + }, + }); + + return success(res, { message: '加入成功', family }); +} + +export default withAuth(handler); \ No newline at end of file diff --git a/backend/src/pages/api/families/leave.ts b/backend/src/pages/api/families/leave.ts new file mode 100644 index 0000000..474e1d5 --- /dev/null +++ b/backend/src/pages/api/families/leave.ts @@ -0,0 +1,34 @@ +import type { NextApiResponse } from 'next'; +import { prisma } from '@/lib/prisma'; +import { withAuth, AuthenticatedRequest } from '@/lib/auth'; +import { success, error } from '@/lib/response'; +import { z } from 'zod'; + +const schema = z.object({ familyId: z.string() }); + +async function handler(req: AuthenticatedRequest, res: NextApiResponse) { + if (req.method !== 'POST') return error(res, 'Method not allowed', 405); + + const parsed = schema.safeParse(req.body); + if (!parsed.success) return error(res, '参数错误'); + + const user = await prisma.user.findUnique({ where: { id: req.user!.userId } }); + if (!user) return error(res, '用户不存在', 404); + + await prisma.familyMember.deleteMany({ + where: { familyId: parsed.data.familyId, userId: req.user!.userId }, + }); + + const newFamilyIds = user.familyIds.filter(id => id !== parsed.data.familyId); + await prisma.user.update({ + where: { id: req.user!.userId }, + data: { + familyIds: newFamilyIds, + activeFamilyId: newFamilyIds.length > 0 ? newFamilyIds[0] : null, + }, + }); + + return success(res, { message: '已退出家庭' }); +} + +export default withAuth(handler); \ No newline at end of file diff --git a/backend/src/pages/api/hello.ts b/backend/src/pages/api/hello.ts new file mode 100644 index 0000000..c3bdc3c --- /dev/null +++ b/backend/src/pages/api/hello.ts @@ -0,0 +1,10 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; + +type Data = { name: string }; + +export default function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + res.status(200).json({ name: 'Family Accounting API' }); +} \ No newline at end of file diff --git a/backend/src/pages/api/user/active-family.ts b/backend/src/pages/api/user/active-family.ts new file mode 100644 index 0000000..8e7ee06 --- /dev/null +++ b/backend/src/pages/api/user/active-family.ts @@ -0,0 +1,25 @@ +import type { NextApiResponse } from 'next'; +import { prisma } from '@/lib/prisma'; +import { withAuth, AuthenticatedRequest } from '@/lib/auth'; +import { success, error } from '@/lib/response'; +import { z } from 'zod'; + +const schema = z.object({ + familyId: z.string().nullable(), +}); + +async function handler(req: AuthenticatedRequest, res: NextApiResponse) { + if (req.method !== 'PUT') return error(res, 'Method not allowed', 405); + + const parsed = schema.safeParse(req.body); + if (!parsed.success) return error(res, parsed.error.errors[0].message); + + await prisma.user.update({ + where: { id: req.user!.userId }, + data: { activeFamilyId: parsed.data.familyId }, + }); + + return success(res, { message: '切换成功' }); +} + +export default withAuth(handler); \ No newline at end of file diff --git a/backend/src/pages/api/user/email.ts b/backend/src/pages/api/user/email.ts new file mode 100644 index 0000000..8e4d0a4 --- /dev/null +++ b/backend/src/pages/api/user/email.ts @@ -0,0 +1,25 @@ +import type { NextApiResponse } from 'next'; +import { prisma } from '@/lib/prisma'; +import { withAuth, AuthenticatedRequest } from '@/lib/auth'; +import { success, error } from '@/lib/response'; +import { z } from 'zod'; + +const schema = z.object({ + email: z.string().email('邮箱格式不正确'), +}); + +async function handler(req: AuthenticatedRequest, res: NextApiResponse) { + if (req.method !== 'PUT') return error(res, 'Method not allowed', 405); + + const parsed = schema.safeParse(req.body); + if (!parsed.success) return error(res, parsed.error.errors[0].message); + + await prisma.user.update({ + where: { id: req.user!.userId }, + data: { email: parsed.data.email }, + }); + + return success(res, { message: '邮箱绑定成功' }); +} + +export default withAuth(handler); \ No newline at end of file diff --git a/backend/src/pages/api/user/profile.ts b/backend/src/pages/api/user/profile.ts new file mode 100644 index 0000000..9407003 --- /dev/null +++ b/backend/src/pages/api/user/profile.ts @@ -0,0 +1,24 @@ +import type { NextApiResponse } from 'next'; +import { prisma } from '@/lib/prisma'; +import { withAuth, AuthenticatedRequest } from '@/lib/auth'; +import { success, error } from '@/lib/response'; + +async function handler(req: AuthenticatedRequest, res: NextApiResponse) { + if (req.method !== 'GET') return error(res, 'Method not allowed', 405); + + const user = await prisma.user.findUnique({ where: { id: req.user!.userId } }); + if (!user) return error(res, '用户不存在', 404); + + return success(res, { + id: user.id, + phone: user.phone, + nickname: user.nickname, + email: user.email, + familyIds: user.familyIds, + activeFamilyId: user.activeFamilyId, + joinDate: user.joinDate.toISOString().slice(0, 7), + createdAt: user.createdAt.toISOString(), + }); +} + +export default withAuth(handler); \ No newline at end of file diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..7dbd9f9 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./src/*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..30bae6b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,44 @@ +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + container_name: family_accounting_db + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${DB_PASSWORD:-password} + POSTGRES_DB: family_accounting + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: family_accounting_backend + ports: + - "3000:3000" + environment: + DATABASE_URL: postgresql://postgres:${DB_PASSWORD:-password}@postgres:5432/family_accounting + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} + NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000} + JWT_SECRET: ${JWT_SECRET} + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + +volumes: + postgres_data: + +networks: + default: + name: family_accounting_network \ No newline at end of file diff --git a/frontend/analysis_options.yaml b/frontend/analysis_options.yaml new file mode 100644 index 0000000..7499b91 --- /dev/null +++ b/frontend/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:flutter_lints/flutter.yaml +linter: + rules: + prefer_const_constructors: true + prefer_const_declarations: true \ No newline at end of file diff --git a/frontend/lib/api/api_client.dart b/frontend/lib/api/api_client.dart new file mode 100644 index 0000000..bee3740 --- /dev/null +++ b/frontend/lib/api/api_client.dart @@ -0,0 +1,45 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class ApiClient { + late final Dio _dio; + final _storage = const FlutterSecureStorage(); + static const _tokenKey = 'auth_token'; + + ApiClient({String? baseUrl}) { + _dio = Dio(BaseOptions( + baseUrl: baseUrl ?? 'http://localhost:3000/api', + connectTimeout: const Duration(seconds: 10), + headers: {'Content-Type': 'application/json'}, + )); + + _dio.interceptors.add(InterceptorsWrapper( + onRequest: (options, handler) async { + final token = await _storage.read(key: _tokenKey); + if (token != null) { + options.headers['Authorization'] = 'Bearer $token'; + } + handler.next(options); + }, + )); + } + + Future> get(String path, {Map? params}) async { + final res = await _dio.get(path, queryParameters: params); + return res.data as Map; + } + + Future> post(String path, {dynamic data}) async { + final res = await _dio.post(path, data: data); + return res.data as Map; + } + + Future> put(String path, {dynamic data}) async { + final res = await _dio.put(path, data: data); + return res.data as Map; + } + + Future saveToken(String token) => _storage.write(key: _tokenKey, value: token); + Future getToken() => _storage.read(key: _tokenKey); + Future clearToken() => _storage.delete(key: _tokenKey); +} \ No newline at end of file diff --git a/frontend/lib/api/auth_api.dart b/frontend/lib/api/auth_api.dart new file mode 100644 index 0000000..beae70a --- /dev/null +++ b/frontend/lib/api/auth_api.dart @@ -0,0 +1,30 @@ +import 'api_client.dart'; + +class AuthApi { + final ApiClient _client; + AuthApi(this._client); + + Future> login(String phone, String password) { + return _client.post('/auth/login', data: {'phone': phone, 'password': password}); + } + + Future> register(String phone, String password, String nickname) { + return _client.post('/auth/register', data: {'phone': phone, 'password': password, 'nickname': nickname}); + } + + Future> logout() { + return _client.post('/auth/logout'); + } + + Future> getProfile() { + return _client.get('/user/profile'); + } + + Future> bindEmail(String email) { + return _client.put('/user/email', data: {'email': email}); + } + + Future> switchActiveFamily(String? familyId) { + return _client.put('/user/active-family', data: {'familyId': familyId}); + } +} \ No newline at end of file diff --git a/frontend/lib/api/bill_api.dart b/frontend/lib/api/bill_api.dart new file mode 100644 index 0000000..fdf60bd --- /dev/null +++ b/frontend/lib/api/bill_api.dart @@ -0,0 +1,18 @@ +import 'api_client.dart'; +import 'package:flutter/foundation.dart'; + +class BillApi { + final ApiClient _client; + BillApi(this._client); + + Future> getBills({String? userId, String? familyId}) { + final params = {}; + if (userId != null) params['userId'] = userId; + if (familyId != null) params['familyId'] = familyId; + return _client.get('/bills', params: params); + } + + Future> createBill(Map data) { + return _client.post('/bills', data: data); + } +} \ No newline at end of file diff --git a/frontend/lib/api/family_api.dart b/frontend/lib/api/family_api.dart new file mode 100644 index 0000000..e62b73a --- /dev/null +++ b/frontend/lib/api/family_api.dart @@ -0,0 +1,26 @@ +import 'api_client.dart'; + +class FamilyApi { + final ApiClient _client; + FamilyApi(this._client); + + Future> getFamilies() { + return _client.get('/families'); + } + + Future> getFamily(String id) { + return _client.get('/families/$id'); + } + + Future> createFamily(String name) { + return _client.post('/families', data: {'name': name}); + } + + Future> joinFamily(String inviteCode) { + return _client.post('/families/join', data: {'inviteCode': inviteCode}); + } + + Future> leaveFamily(String familyId) { + return _client.post('/families/leave', data: {'familyId': familyId}); + } +} \ No newline at end of file diff --git a/frontend/lib/app.dart b/frontend/lib/app.dart new file mode 100644 index 0000000..0a97a2d --- /dev/null +++ b/frontend/lib/app.dart @@ -0,0 +1,128 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'providers/auth_provider.dart'; +import 'screens/home/home_screen.dart'; +import 'screens/auth/login_screen.dart'; +import 'screens/auth/register_screen.dart'; +import 'screens/profile/profile_screen.dart'; +import 'screens/statistics/statistics_screen.dart'; +import 'screens/add_bill/add_bill_screen.dart'; +import 'screens/auth/user_profile_screen.dart'; +import 'screens/auth/bind_email_screen.dart'; +import 'screens/family/family_management_screen.dart'; + +class AppShell extends ConsumerStatefulWidget { + const AppShell({super.key}); + @override + ConsumerState createState() => _AppShellState(); +} + +class _AppShellState extends ConsumerState { + int _currentTab = 0; + bool _showAddBill = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(authProvider.notifier).restoreSession(); + }); + } + + @override + Widget build(BuildContext context) { + final authState = ref.watch(authProvider); + + Widget body; + if (_showAddBill) { + return const AddBillScreen(); + } + + switch (_currentTab) { + case 0: + body = const HomeScreen(); + break; + case 1: + body = const StatisticsScreen(); + break; + case 2: + body = const ProfileScreen(); + break; + default: + body = const HomeScreen(); + } + + return Scaffold( + body: body, + bottomNavigationBar: Container( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + decoration: BoxDecoration( + color: AppColors.cardBg, + borderRadius: BorderRadius.circular(30), + border: Border.all(color: Colors.white.withOpacity(0.8)), + boxShadow: [BoxShadow(color: AppColors.brand.withOpacity(0.08), blurRadius: 32, offset: const Offset(0, 8))], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(30), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), + child: BottomNavigationBar( + currentIndex: _currentTab, + onTap: (i) => setState(() => _currentTab = i), + backgroundColor: Colors.transparent, + elevation: 0, + selectedItemColor: AppColors.brand, + unselectedItemColor: AppColors.textSecondary, + items: const [ + BottomNavigationBarItem(icon: Icon(Icons.home_outlined, size: 22), activeIcon: Icon(Icons.home, size: 22), label: '首页'), + BottomNavigationBarItem(icon: Icon(Icons.bar_chart_outlined, size: 22), activeIcon: Icon(Icons.bar_chart, size: 22), label: '统计'), + BottomNavigationBarItem(icon: Icon(Icons.person_outline, size: 22), activeIcon: Icon(Icons.person, size: 22), label: '我的'), + ], + ), + ), + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: () { + if (!authState.isLoggedIn && mounted) { + _navigateToLogin(); + return; + } + setState(() => _showAddBill = true); + }, + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + gradient: const LinearGradient(colors: [Color(0xFFFF6B35), Color(0xFFFF9F5B)]), + shape: BoxShape.circle, + boxShadow: [BoxShadow(color: AppColors.brand.withOpacity(0.3), blurRadius: 12, offset: const Offset(0, 4))], + ), + child: const Icon(Icons.add, color: Colors.white, size: 24), + ), + ), + ], + ), + ), + ); + } + + void _navigateToLogin() { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => const LoginScreen())); + } +} + +// Colors used in this file +class AppColors { + static const brand = Color(0xFFFF6B35); + static const brandLight = Color(0xFFFF9F5B); + static const cardBg = Color(0xA6FFFFFF); + static const textPrimary = Color(0xFF1C1C1E); + static const textSecondary = Color(0xFF8E8E93); +} \ No newline at end of file diff --git a/frontend/lib/constants.dart b/frontend/lib/constants.dart new file mode 100644 index 0000000..c071749 --- /dev/null +++ b/frontend/lib/constants.dart @@ -0,0 +1,28 @@ +class AppConstants { + static const expenseCategories = [ + {'emoji': '🍜', 'name': '餐饮'}, + {'emoji': '🚗', 'name': '交通'}, + {'emoji': '🛒', 'name': '购物'}, + {'emoji': '🏠', 'name': '居住'}, + {'emoji': '💊', 'name': '医疗'}, + {'emoji': '🎮', 'name': '娱乐'}, + {'emoji': '📚', 'name': '教育'}, + {'emoji': '📦', 'name': '其他'}, + ]; + static const incomeCategories = [ + {'emoji': '💰', 'name': '工资'}, + {'emoji': '📈', 'name': '理财'}, + {'emoji': '🎓', 'name': '兼职'}, + {'emoji': '🥇', 'name': '红包'}, + {'emoji': '🏆', 'name': '奖金'}, + {'emoji': '💲', 'name': '其他'}, + ]; + static String categoryEmoji(String category) { + for (final list in [expenseCategories, incomeCategories]) { + for (final item in list) { + if (item['name'] == category) return item['emoji']!; + } + } + return '📝'; + } +} \ No newline at end of file diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart new file mode 100644 index 0000000..c647938 --- /dev/null +++ b/frontend/lib/main.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'app.dart'; +import 'theme/app_theme.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + runApp(const ProviderScope(child: FamilyAccountingApp())); +} + +class FamilyAccountingApp extends StatelessWidget { + const FamilyAccountingApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: '家庭记账', + theme: AppTheme.light, + home: const AppShell(), + debugShowCheckedModeBanner: false, + ); + } +} \ No newline at end of file diff --git a/frontend/lib/models/bill.dart b/frontend/lib/models/bill.dart new file mode 100644 index 0000000..801a122 --- /dev/null +++ b/frontend/lib/models/bill.dart @@ -0,0 +1,48 @@ +enum BillType { expense, income } + +class Bill { + final String id; + final String userId; + final String? familyId; + final BillType type; + final double amount; + final String category; + final String note; + final String emoji; + final String time; + final String date; + final String createdAt; + + const Bill({ + required this.id, + required this.userId, + this.familyId, + required this.type, + required this.amount, + required this.category, + this.note = '', + this.emoji = '📝', + required this.time, + required this.date, + this.createdAt = '', + }); + + bool get isExpense => type == BillType.expense; + bool get isIncome => type == BillType.income; + + factory Bill.fromJson(Map json) { + return Bill( + id: json['id'] as String? ?? '', + userId: json['userId'] as String? ?? '', + familyId: json['familyId'] as String?, + type: json['type'] as String? == 'income' ? BillType.income : BillType.expense, + amount: (json['amount'] as num?)?.toDouble() ?? 0, + category: json['category'] as String? ?? '', + note: json['note'] as String? ?? '', + emoji: json['emoji'] as String? ?? '📝', + time: json['time'] as String? ?? '00:00', + date: json['date'] as String? ?? '', + createdAt: json['createdAt'] as String? ?? '', + ); + } +} \ No newline at end of file diff --git a/frontend/lib/models/family.dart b/frontend/lib/models/family.dart new file mode 100644 index 0000000..237707a --- /dev/null +++ b/frontend/lib/models/family.dart @@ -0,0 +1,53 @@ +class FamilyMember { + final String userId; + final String nickname; + final String role; + final String joinedAt; + + const FamilyMember({ + required this.userId, + required this.nickname, + this.role = 'member', + this.joinedAt = '', + }); + + factory FamilyMember.fromJson(Map json) { + return FamilyMember( + userId: json['userId'] as String? ?? '', + nickname: json['nickname'] as String? ?? '', + role: json['role'] as String? ?? 'member', + joinedAt: json['joinedAt'] as String? ?? '', + ); + } +} + +class Family { + final String id; + final String name; + final String inviteCode; + final String createdBy; + final String createdAt; + final List members; + + const Family({ + required this.id, + required this.name, + required this.inviteCode, + this.createdBy = '', + this.createdAt = '', + this.members = const [], + }); + + factory Family.fromJson(Map json) { + return Family( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + inviteCode: json['inviteCode'] as String? ?? '', + createdBy: json['createdBy'] as String? ?? '', + createdAt: json['createdAt'] as String? ?? '', + members: (json['members'] as List?) + ?.map((m) => FamilyMember.fromJson(m as Map)) + .toList() ?? [], + ); + } +} \ No newline at end of file diff --git a/frontend/lib/models/user.dart b/frontend/lib/models/user.dart new file mode 100644 index 0000000..6284124 --- /dev/null +++ b/frontend/lib/models/user.dart @@ -0,0 +1,45 @@ +class User { + final String id; + final String phone; + final String nickname; + final String? email; + final List familyIds; + final String? activeFamilyId; + final String joinDate; + final String createdAt; + + const User({ + required this.id, + required this.phone, + required this.nickname, + this.email, + this.familyIds = const [], + this.activeFamilyId, + this.joinDate = '', + this.createdAt = '', + }); + + factory User.fromJson(Map json) { + return User( + id: json['id'] as String? ?? '', + phone: json['phone'] as String? ?? '', + nickname: json['nickname'] as String? ?? '', + email: json['email'] as String?, + familyIds: (json['familyIds'] as List?)?.cast() ?? [], + activeFamilyId: json['activeFamilyId'] as String?, + joinDate: json['joinDate'] as String? ?? '', + createdAt: json['createdAt'] as String? ?? '', + ); + } + + Map toJson() => { + 'id': id, + 'phone': phone, + 'nickname': nickname, + 'email': email, + 'familyIds': familyIds, + 'activeFamilyId': activeFamilyId, + 'joinDate': joinDate, + 'createdAt': createdAt, + }; +} \ No newline at end of file diff --git a/frontend/lib/providers/auth_provider.dart b/frontend/lib/providers/auth_provider.dart new file mode 100644 index 0000000..5c0980b --- /dev/null +++ b/frontend/lib/providers/auth_provider.dart @@ -0,0 +1,113 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../api/api_client.dart'; +import '../api/auth_api.dart'; +import '../models/user.dart'; + +final apiClientProvider = Provider((ref) => ApiClient()); + +final authApiProvider = Provider((ref) { + return AuthApi(ref.read(apiClientProvider)); +}); + +class AuthState { + final bool isLoggedIn; + final User? user; + final String? token; + final bool isLoading; + final String? error; + + const AuthState({ + this.isLoggedIn = false, + this.user, + this.token, + this.isLoading = false, + this.error, + }); + + AuthState copyWith({ + bool? isLoggedIn, + User? user, + String? token, + bool? isLoading, + String? error, + }) { + return AuthState( + isLoggedIn: isLoggedIn ?? this.isLoggedIn, + user: user ?? this.user, + token: token ?? this.token, + isLoading: isLoading ?? this.isLoading, + error: error ?? this.error, + ); + } +} + +class AuthNotifier extends StateNotifier { + final AuthApi _api; + final ApiClient _client; + + AuthNotifier(this._api, this._client) : super(const AuthState()); + + Future login(String phone, String password) async { + state = state.copyWith(isLoading: true, error: null); + try { + final res = await _api.login(phone, password); + if (res['success'] == true) { + final token = res['data']['token'] as String; + final user = User.fromJson(res['data']['user']); + await _client.saveToken(token); + state = AuthState(isLoggedIn: true, user: user, token: token); + } else { + state = state.copyWith(isLoading: false, error: res['error'] as String?); + } + } catch (e) { + state = state.copyWith(isLoading: false, error: '网络错误: $e'); + } + } + + Future register(String phone, String password, String nickname) async { + state = state.copyWith(isLoading: true, error: null); + try { + final res = await _api.register(phone, password, nickname); + if (res['success'] == true) { + final token = res['data']['token'] as String; + final user = User.fromJson(res['data']['user']); + await _client.saveToken(token); + state = AuthState(isLoggedIn: true, user: user, token: token); + } else { + state = state.copyWith(isLoading: false, error: res['error'] as String?); + } + } catch (e) { + state = state.copyWith(isLoading: false, error: '网络错误: $e'); + } + } + + Future logout() async { + await _client.clearToken(); + state = const AuthState(); + } + + Future restoreSession() async { + final token = await _client.getToken(); + if (token == null) return; + try { + final res = await _api.getProfile(); + if (res['success'] == true) { + state = AuthState(isLoggedIn: true, user: User.fromJson(res['data']), token: token); + } else { + await _client.clearToken(); + } + } catch (_) { + // offline - keep token for retry + } + } + + void clearError() { + state = state.copyWith(error: null); + } +} + +final authProvider = StateNotifierProvider((ref) { + final api = ref.read(authApiProvider); + final client = ref.read(apiClientProvider); + return AuthNotifier(api, client); +}); \ No newline at end of file diff --git a/frontend/lib/providers/bill_provider.dart b/frontend/lib/providers/bill_provider.dart new file mode 100644 index 0000000..9f7105e --- /dev/null +++ b/frontend/lib/providers/bill_provider.dart @@ -0,0 +1,45 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../api/bill_api.dart'; +import '../api/api_client.dart'; +import '../models/bill.dart'; + +final billApiProvider = Provider((ref) { + return BillApi(ref.read(apiClientProvider)); +}); + +class BillNotifier extends StateNotifier>> { + final BillApi _api; + String? _currentFamilyId; + + BillNotifier(this._api) : super(const AsyncValue.data([])); + + Future loadBills({String? familyId}) async { + _currentFamilyId = familyId; + state = const AsyncValue.loading(); + try { + final res = await _api.getBills(familyId: familyId); + if (res['success'] == true) { + state = AsyncValue.data((res['data'] as List).map((e) => Bill.fromJson(e)).toList()); + } else { + state = AsyncValue.error(res['error'] ?? '加载失败', StackTrace.current); + } + } catch (e, st) { + state = AsyncValue.error(e.toString(), st); + } + } + + Future addBill(Map data) async { + try { + final res = await _api.createBill(data); + if (res['success'] == true) { + await loadBills(familyId: _currentFamilyId); + return true; + } + } catch (_) {} + return false; + } +} + +final billProvider = StateNotifierProvider>>((ref) { + return BillNotifier(ref.read(billApiProvider)); +}); \ No newline at end of file diff --git a/frontend/lib/providers/family_provider.dart b/frontend/lib/providers/family_provider.dart new file mode 100644 index 0000000..71857df --- /dev/null +++ b/frontend/lib/providers/family_provider.dart @@ -0,0 +1,52 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../api/family_api.dart'; +import '../api/api_client.dart'; +import '../models/family.dart'; + +final familyApiProvider = Provider((ref) { + return FamilyApi(ref.read(apiClientProvider)); +}); + +class FamilyNotifier extends StateNotifier>> { + final FamilyApi _api; + + FamilyNotifier(this._api) : super(const AsyncValue.loading()); + + Future loadFamilies() async { + state = const AsyncValue.loading(); + try { + final res = await _api.getFamilies(); + if (res['success'] == true) { + state = AsyncValue.data((res['data'] as List).map((e) => Family.fromJson(e)).toList()); + } else { + state = AsyncValue.error(res['error'] ?? '加载失败', StackTrace.current); + } + } catch (e, st) { + state = AsyncValue.error(e.toString(), st); + } + } + + Future createFamily(String name) async { + try { + final res = await _api.createFamily(name); + if (res['success'] == true) { + return Family.fromJson(res['data']); + } + } catch (_) {} + return null; + } + + Future joinFamily(String inviteCode) async { + try { + final res = await _api.joinFamily(inviteCode); + if (res['success'] == true) { + return Family.fromJson(res['data']); + } + } catch (_) {} + return null; + } +} + +final familyProvider = StateNotifierProvider>>((ref) { + return FamilyNotifier(ref.read(familyApiProvider)); +}); \ No newline at end of file diff --git a/frontend/lib/screens/add_bill/add_bill_screen.dart b/frontend/lib/screens/add_bill/add_bill_screen.dart new file mode 100644 index 0000000..e43ab4d --- /dev/null +++ b/frontend/lib/screens/add_bill/add_bill_screen.dart @@ -0,0 +1,399 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../constants.dart'; +import '../../providers/bill_provider.dart'; +import '../../theme/colors.dart'; +import '../../widgets/number_pad.dart'; +import '../../widgets/category_grid.dart'; + +class AddBillScreen extends ConsumerStatefulWidget { + const AddBillScreen({super.key}); + + @override + ConsumerState createState() => _AddBillScreenState(); +} + +class _AddBillScreenState extends ConsumerState { + bool _isExpense = true; + String _amount = '0'; + String _category = '餐饮'; + String _note = ''; + DateTime _selectedDate = DateTime.now(); + bool _addToFamily = false; + + void _onKeyTap(String key) { + setState(() { + if (key == 'delete') { + if (_amount.length > 1) { + _amount = _amount.substring(0, _amount.length - 1); + } else { + _amount = '0'; + } + } else if (key == '.') { + if (!_amount.contains('.')) { + _amount += '.'; + } + } else { + if (_amount == '0') { + _amount = key; + } else { + // Limit to 2 decimal places + final parts = _amount.split('.'); + if (parts.length == 2 && parts[1].length >= 2) { + return; + } + // Limit total length + if (_amount.length >= 10) { + return; + } + _amount += key; + } + } + }); + } + + String get _displayAmount { + final value = double.tryParse(_amount) ?? 0; + if (value == value.roundToDouble() && !_amount.contains('.')) { + return value.toInt().toString(); + } + return _amount; + } + + Future _selectDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _selectedDate, + firstDate: DateTime(2020), + lastDate: DateTime.now().add(const Duration(days: 365)), + builder: (context, child) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: const ColorScheme.light( + primary: AppColors.brand, + onPrimary: Colors.white, + surface: Colors.white, + onSurface: AppColors.textPrimary, + ), + ), + child: child!, + ); + }, + ); + if (picked != null) { + setState(() { + _selectedDate = picked; + }); + } + } + + Future _saveBill() async { + final amountValue = double.tryParse(_amount); + if (amountValue == null || amountValue <= 0) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('请输入有效金额')), + ); + return; + } + + final data = { + 'type': _isExpense ? 'expense' : 'income', + 'amount': amountValue, + 'category': _category, + 'note': _note, + 'emoji': AppConstants.categoryEmoji(_category), + 'date': '${_selectedDate.year}-${_selectedDate.month.toString().padLeft(2, '0')}-${_selectedDate.day.toString().padLeft(2, '0')}', + 'time': '${DateTime.now().hour.toString().padLeft(2, '0')}:${DateTime.now().minute.toString().padLeft(2, '0')}', + 'familyId': _addToFamily ? 'default' : null, + }; + + final success = await ref.read(billProvider.notifier).addBill(data); + if (mounted) { + if (success) { + Navigator.of(context).pop(true); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('保存失败')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final categories = _isExpense + ? AppConstants.expenseCategories + : AppConstants.incomeCategories; + + // Set default category based on type + if (_isExpense && _category == '工资') { + _category = '餐饮'; + } else if (!_isExpense && _category == '餐饮') { + _category = '工资'; + } + + return Scaffold( + backgroundColor: AppColors.pageBg, + body: SafeArea( + child: Column( + children: [ + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, size: 28), + color: AppColors.textPrimary, + ), + const Text( + '添加账单', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + TextButton( + onPressed: _saveBill, + child: const Text( + '保存', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: AppColors.brand, + ), + ), + ), + ], + ), + ), + + // iOS Segment Control + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Container( + height: 36, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.7), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () => setState(() { + _isExpense = true; + _category = '餐饮'; + }), + child: Container( + decoration: BoxDecoration( + color: _isExpense ? AppColors.expense : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Text( + '支出', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: _isExpense ? Colors.white : AppColors.textSecondary, + ), + ), + ), + ), + ), + ), + Expanded( + child: GestureDetector( + onTap: () => setState(() { + _isExpense = false; + _category = '工资'; + }), + child: Container( + decoration: BoxDecoration( + color: !_isExpense ? AppColors.income : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Text( + '收入', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: !_isExpense ? Colors.white : AppColors.textSecondary, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + + // Amount Display + Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + const Text( + '¥', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + ), + const SizedBox(width: 4), + Text( + _displayAmount, + style: const TextStyle( + fontSize: 48, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + ), + + // Category Grid + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: CategoryGrid( + categories: categories, + selected: _category, + onSelect: (cat) => setState(() => _category = cat), + ), + ), + + const SizedBox(height: 16), + + // Note Input + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.65), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const Icon(Icons.edit_note, color: AppColors.textSecondary, size: 20), + const SizedBox(width: 8), + Expanded( + child: TextField( + onChanged: (v) => _note = v, + style: const TextStyle( + fontSize: 15, + color: AppColors.textPrimary, + ), + decoration: const InputDecoration( + hintText: '添加备注', + hintStyle: TextStyle( + color: AppColors.textSecondary, + fontSize: 15, + ), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + ], + ), + ), + ), + + const SizedBox(height: 12), + + // Date Picker & Family Toggle + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + // Date Picker + Expanded( + child: GestureDetector( + onTap: _selectDate, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.65), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const Icon(Icons.calendar_today, color: AppColors.textSecondary, size: 18), + const SizedBox(width: 8), + Text( + '${_selectedDate.year}-${_selectedDate.month.toString().padLeft(2, '0')}-${_selectedDate.day.toString().padLeft(2, '0')}', + style: const TextStyle( + fontSize: 15, + color: AppColors.textPrimary, + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 12), + // Family Toggle + GestureDetector( + onTap: () => setState(() => _addToFamily = !_addToFamily), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: _addToFamily + ? AppColors.brand.withOpacity(0.1) + : Colors.white.withOpacity(0.65), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _addToFamily ? AppColors.brand : Colors.white.withOpacity(0.5), + width: 1, + ), + ), + child: Row( + children: [ + Icon( + Icons.family_restroom, + color: _addToFamily ? AppColors.brand : AppColors.textSecondary, + size: 18, + ), + const SizedBox(width: 6), + Text( + '家庭账本', + style: TextStyle( + fontSize: 14, + fontWeight: _addToFamily ? FontWeight.w600 : FontWeight.w500, + color: _addToFamily ? AppColors.brand : AppColors.textSecondary, + ), + ), + ], + ), + ), + ), + ], + ), + ), + + const Spacer(), + + // Number Pad + NumberPad(onKeyTap: _onKeyTap), + const SizedBox(height: 20), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/lib/screens/auth/bind_email_screen.dart b/frontend/lib/screens/auth/bind_email_screen.dart new file mode 100644 index 0000000..786fa2d --- /dev/null +++ b/frontend/lib/screens/auth/bind_email_screen.dart @@ -0,0 +1,204 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../providers/auth_provider.dart'; +import '../../theme/colors.dart'; + +class BindEmailScreen extends ConsumerStatefulWidget { + const BindEmailScreen({super.key}); + + @override + ConsumerState createState() => _BindEmailScreenState(); +} + +class _BindEmailScreenState extends ConsumerState { + final _emailController = TextEditingController(); + final _formKey = GlobalKey(); + bool _isLoading = false; + + @override + void dispose() { + _emailController.dispose(); + super.dispose(); + } + + String? _validateEmail(String? value) { + if (value == null || value.isEmpty) { + return '请输入邮箱'; + } + final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'); + if (!emailRegex.hasMatch(value)) { + return '请输入有效的邮箱地址'; + } + return null; + } + + Future _handleBind() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isLoading = true); + + try { + final authApi = ref.read(authApiProvider); + final result = await authApi.bindEmail(_emailController.text); + + if (result['success'] == true) { + if (mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('邮箱绑定成功'), + backgroundColor: AppColors.income, + ), + ); + } + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result['error'] ?? '绑定失败'), + backgroundColor: AppColors.expense, + ), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('网络错误: $e'), + backgroundColor: AppColors.expense, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color(0xFFFF6B35), + Color(0xFFFF9F5B), + ], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + '绑定邮箱', + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 8), + const Text( + '请输入您的邮箱地址', + style: TextStyle( + fontSize: 16, + color: Colors.white70, + ), + ), + const SizedBox(height: 48), + _buildGlassInput( + child: TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + style: const TextStyle(color: AppColors.textPrimary), + decoration: const InputDecoration( + hintText: '请输入邮箱', + hintStyle: TextStyle(color: AppColors.textSecondary), + border: InputBorder.none, + prefixIcon: Icon(Icons.email_outlined, color: AppColors.brand), + ), + validator: _validateEmail, + ), + ), + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton( + onPressed: _isLoading ? null : _handleBind, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: AppColors.brand, + disabledBackgroundColor: Colors.white.withOpacity(0.6), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 0, + ), + child: _isLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(AppColors.brand), + ), + ) + : const Text( + '绑定', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildGlassInput({required Widget child}) { + return Container( + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.8), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white.withOpacity(0.8)), + boxShadow: [ + BoxShadow( + color: AppColors.brand.withOpacity(0.06), + blurRadius: 32, + offset: const Offset(0, 8), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: child, + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/lib/screens/auth/login_screen.dart b/frontend/lib/screens/auth/login_screen.dart new file mode 100644 index 0000000..72028ed --- /dev/null +++ b/frontend/lib/screens/auth/login_screen.dart @@ -0,0 +1,215 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../providers/auth_provider.dart'; +import '../../theme/colors.dart'; + +class LoginScreen extends ConsumerStatefulWidget { + const LoginScreen({super.key}); + + @override + ConsumerState createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState { + final _phoneController = TextEditingController(); + final _passwordController = TextEditingController(); + final _formKey = GlobalKey(); + + @override + void dispose() { + _phoneController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + String? _validatePhone(String? value) { + if (value == null || value.isEmpty) { + return '请输入手机号'; + } + if (value.length != 11) { + return '手机号必须为11位'; + } + return null; + } + + String? _validatePassword(String? value) { + if (value == null || value.isEmpty) { + return '请输入密码'; + } + if (value.length < 6) { + return '密码至少6位'; + } + return null; + } + + Future _handleLogin() async { + if (!_formKey.currentState!.validate()) return; + + final phone = _phoneController.text; + final password = _passwordController.text; + + await ref.read(authProvider.notifier).login(phone, password); + + final authState = ref.read(authProvider); + if (authState.isLoggedIn && mounted) { + Navigator.pop(context); + } + } + + @override + Widget build(BuildContext context) { + final authState = ref.watch(authProvider); + final isLoading = authState.isLoading; + + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color(0xFFFF6B35), + Color(0xFFFF9F5B), + ], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + '登录', + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 8), + const Text( + '欢迎回来', + style: TextStyle( + fontSize: 16, + color: Colors.white70, + ), + ), + const SizedBox(height: 48), + _buildGlassInput( + child: TextFormField( + controller: _phoneController, + keyboardType: TextInputType.phone, + style: const TextStyle(color: AppColors.textPrimary), + decoration: const InputDecoration( + hintText: '请输入手机号', + hintStyle: TextStyle(color: AppColors.textSecondary), + border: InputBorder.none, + prefixIcon: Icon(Icons.phone_android, color: AppColors.brand), + ), + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(11), + ], + validator: _validatePhone, + ), + ), + const SizedBox(height: 16), + _buildGlassInput( + child: TextFormField( + controller: _passwordController, + obscureText: true, + style: const TextStyle(color: AppColors.textPrimary), + decoration: const InputDecoration( + hintText: '请输入密码', + hintStyle: TextStyle(color: AppColors.textSecondary), + border: InputBorder.none, + prefixIcon: Icon(Icons.lock_outline, color: AppColors.brand), + ), + validator: _validatePassword, + ), + ), + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton( + onPressed: isLoading ? null : _handleLogin, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: AppColors.brand, + disabledBackgroundColor: Colors.white.withOpacity(0.6), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 0, + ), + child: isLoading + ? const Text( + '登录中...', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ) + : const Text( + '登录', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + if (authState.error != null) ...[ + const SizedBox(height: 16), + Text( + authState.error!, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + ], + ], + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildGlassInput({required Widget child}) { + return Container( + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.8), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white.withOpacity(0.8)), + boxShadow: [ + BoxShadow( + color: AppColors.brand.withOpacity(0.06), + blurRadius: 32, + offset: const Offset(0, 8), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: child, + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/lib/screens/auth/register_screen.dart b/frontend/lib/screens/auth/register_screen.dart new file mode 100644 index 0000000..a0ff034 --- /dev/null +++ b/frontend/lib/screens/auth/register_screen.dart @@ -0,0 +1,254 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../providers/auth_provider.dart'; +import '../../theme/colors.dart'; + +class RegisterScreen extends ConsumerStatefulWidget { + const RegisterScreen({super.key}); + + @override + ConsumerState createState() => _RegisterScreenState(); +} + +class _RegisterScreenState extends ConsumerState { + final _nicknameController = TextEditingController(); + final _phoneController = TextEditingController(); + final _passwordController = TextEditingController(); + final _formKey = GlobalKey(); + + @override + void dispose() { + _nicknameController.dispose(); + _phoneController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + String? _validateNickname(String? value) { + if (value == null || value.isEmpty) { + return '请输入昵称'; + } + if (value.length > 20) { + return '昵称最多20个字符'; + } + return null; + } + + String? _validatePhone(String? value) { + if (value == null || value.isEmpty) { + return '请输入手机号'; + } + if (value.length != 11) { + return '手机号必须为11位'; + } + return null; + } + + String? _validatePassword(String? value) { + if (value == null || value.isEmpty) { + return '请输入密码'; + } + if (value.length < 6) { + return '密码至少6位'; + } + return null; + } + + Future _handleRegister() async { + if (!_formKey.currentState!.validate()) return; + + final phone = _phoneController.text; + final password = _passwordController.text; + final nickname = _nicknameController.text; + + await ref.read(authProvider.notifier).register(phone, password, nickname); + + final authState = ref.read(authProvider); + if (authState.isLoggedIn && mounted) { + Navigator.pop(context); + } + } + + @override + Widget build(BuildContext context) { + final authState = ref.watch(authProvider); + final isLoading = authState.isLoading; + + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color(0xFFFF6B35), + Color(0xFFFF9F5B), + ], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + '创建你的账号', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 8), + const Text( + '加入家庭记账,轻松管理财务', + style: TextStyle( + fontSize: 14, + color: Colors.white70, + ), + ), + const SizedBox(height: 48), + _buildGlassInput( + child: TextFormField( + controller: _nicknameController, + style: const TextStyle(color: AppColors.textPrimary), + decoration: const InputDecoration( + hintText: '请输入昵称', + hintStyle: TextStyle(color: AppColors.textSecondary), + border: InputBorder.none, + prefixIcon: Icon(Icons.person_outline, color: AppColors.brand), + ), + validator: _validateNickname, + ), + ), + const SizedBox(height: 16), + _buildGlassInput( + child: TextFormField( + controller: _phoneController, + keyboardType: TextInputType.phone, + style: const TextStyle(color: AppColors.textPrimary), + decoration: const InputDecoration( + hintText: '请输入手机号', + hintStyle: TextStyle(color: AppColors.textSecondary), + border: InputBorder.none, + prefixIcon: Icon(Icons.phone_android, color: AppColors.brand), + ), + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(11), + ], + validator: _validatePhone, + ), + ), + const SizedBox(height: 16), + _buildGlassInput( + child: TextFormField( + controller: _passwordController, + obscureText: true, + style: const TextStyle(color: AppColors.textPrimary), + decoration: const InputDecoration( + hintText: '请输入密码', + hintStyle: TextStyle(color: AppColors.textSecondary), + border: InputBorder.none, + prefixIcon: Icon(Icons.lock_outline, color: AppColors.brand), + ), + validator: _validatePassword, + ), + ), + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton( + onPressed: isLoading ? null : _handleRegister, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.brand, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.brand.withOpacity(0.6), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 0, + ), + child: isLoading + ? const Text( + '注册中...', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ) + : const Text( + '注册', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + if (authState.error != null) ...[ + const SizedBox(height: 16), + Text( + authState.error!, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + ], + const SizedBox(height: 24), + GestureDetector( + onTap: () => Navigator.pop(context), + child: const Text( + '已有账号?立即登录', + style: TextStyle( + fontSize: 14, + color: Colors.white, + decoration: TextDecoration.underline, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildGlassInput({required Widget child}) { + return Container( + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.8), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white.withOpacity(0.8)), + boxShadow: [ + BoxShadow( + color: AppColors.brand.withOpacity(0.06), + blurRadius: 32, + offset: const Offset(0, 8), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: child, + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/lib/screens/auth/user_profile_screen.dart b/frontend/lib/screens/auth/user_profile_screen.dart new file mode 100644 index 0000000..78adf1e --- /dev/null +++ b/frontend/lib/screens/auth/user_profile_screen.dart @@ -0,0 +1,389 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../providers/auth_provider.dart'; +import '../../theme/colors.dart'; +import 'bind_email_screen.dart'; + +class UserProfileScreen extends ConsumerStatefulWidget { + const UserProfileScreen({super.key}); + + @override + ConsumerState createState() => _UserProfileScreenState(); +} + +class _UserProfileScreenState extends ConsumerState { + @override + Widget build(BuildContext context) { + final authState = ref.watch(authProvider); + final user = authState.user; + + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color(0xFFFF6B35), + Color(0xFFFF9F5B), + ], + ), + ), + child: SafeArea( + child: Column( + children: [ + const SizedBox(height: 40), + // Header + const Text( + '个人资料', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 40), + // Avatar and User Info + _buildUserInfoCard(user), + const SizedBox(height: 24), + // Menu Items + Expanded( + child: _buildMenuItems(user), + ), + ], + ), + ), + ), + ); + } + + Widget _buildUserInfoCard(user) { + final nickname = user?.nickname ?? '用户'; + final phone = user?.phone ?? ''; + final firstLetter = nickname.isNotEmpty ? nickname[0].toUpperCase() : '?'; + final joinDate = user?.joinDate ?? ''; + final email = user?.email; + final hasEmail = email != null && email.isNotEmpty; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 24), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.9), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + // Avatar with orange border + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: AppColors.brand, + width: 3, + ), + color: Colors.white, + ), + child: Center( + child: Text( + firstLetter, + style: const TextStyle( + fontSize: 36, + fontWeight: FontWeight.bold, + color: AppColors.brand, + ), + ), + ), + ), + const SizedBox(height: 16), + // Nickname + Text( + nickname, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + // Phone + Text( + phone, + style: const TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 20), + // Info Row + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildInfoItem('加入时间', _formatDate(joinDate)), + Container( + width: 1, + height: 30, + color: AppColors.textSecondary.withOpacity(0.3), + ), + _buildInfoItem( + '邮箱', + hasEmail ? '已绑定' : '未绑定', + valueColor: hasEmail ? AppColors.income : AppColors.expense, + ), + Container( + width: 1, + height: 30, + color: AppColors.textSecondary.withOpacity(0.3), + ), + _buildInfoItem('活跃家庭', user?.activeFamilyId ?? '无'), + ], + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildInfoItem(String label, String value, {Color? valueColor}) { + return Column( + children: [ + Text( + label, + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 4), + Text( + value, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: valueColor ?? AppColors.textPrimary, + ), + ), + ], + ); + } + + Widget _buildMenuItems(user) { + final hasEmail = user?.email != null && user!.email.isNotEmpty; + final familyIds = user?.familyIds ?? []; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 24), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.9), + borderRadius: BorderRadius.circular(20), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Column( + children: [ + // Bind Email (if not bound) + _buildMenuItem( + icon: Icons.email_outlined, + title: '绑定邮箱', + subtitle: hasEmail ? user.email : '未绑定邮箱', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const BindEmailScreen(), + ), + ); + }, + ), + _buildDivider(), + // Switch Family (if has families) + _buildMenuItem( + icon: Icons.family_restroom_outlined, + title: '切换家庭', + subtitle: familyIds.isNotEmpty ? '${familyIds.length} 个家庭' : '暂无家庭', + onTap: familyIds.isNotEmpty + ? () { + _showFamilySwitchDialog(); + } + : null, + ), + _buildDivider(), + // Logout + _buildMenuItem( + icon: Icons.logout_outlined, + title: '退出登录', + subtitle: '安全退出当前账号', + onTap: () { + _showLogoutDialog(); + }, + isLast: true, + ), + ], + ), + ), + ), + ); + } + + Widget _buildMenuItem({ + required IconData icon, + required String title, + required String subtitle, + required VoidCallback? onTap, + bool isLast = false, + }) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.brand.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + icon, + color: AppColors.brand, + size: 20, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: AppColors.textSecondary.withOpacity(0.5), + ), + ], + ), + ), + ); + } + + Widget _buildDivider() { + return Container( + margin: const EdgeInsets.only(left: 76), + height: 1, + color: AppColors.textSecondary.withOpacity(0.2), + ); + } + + String _formatDate(String dateStr) { + if (dateStr.isEmpty) return '未知'; + try { + // Assuming dateStr is in format like "2024-01-15" or ISO format + final date = DateTime.parse(dateStr); + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } catch (e) { + return dateStr; + } + } + + void _showFamilySwitchDialog() { + final user = ref.read(authProvider).user; + final familyIds = user?.familyIds ?? []; + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('切换家庭'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: familyIds.map((familyId) { + final isActive = familyId == user?.activeFamilyId; + return ListTile( + title: Text(familyId), + trailing: isActive + ? const Icon(Icons.check, color: AppColors.brand) + : null, + onTap: () { + // TODO: Implement family switch + Navigator.pop(context); + }, + ); + }).toList(), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + ], + ), + ); + } + + void _showLogoutDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('退出登录'), + content: const Text('确定要退出当前账号吗?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + TextButton( + onPressed: () async { + Navigator.pop(context); + await ref.read(authProvider.notifier).logout(); + if (mounted) { + // Navigate to login or pop to root + Navigator.of(context).popUntil((route) => route.isFirst); + } + }, + child: const Text( + '确定', + style: TextStyle(color: AppColors.expense), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/frontend/lib/screens/family/family_management_screen.dart b/frontend/lib/screens/family/family_management_screen.dart new file mode 100644 index 0000000..6bdea05 --- /dev/null +++ b/frontend/lib/screens/family/family_management_screen.dart @@ -0,0 +1,425 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../providers/family_provider.dart'; +import '../../providers/auth_provider.dart'; +import '../../theme/colors.dart'; +import '../../models/family.dart'; +import '../../models/user.dart'; + +class FamilyManagementScreen extends ConsumerStatefulWidget { + const FamilyManagementScreen({super.key}); + + @override + ConsumerState createState() => _FamilyManagementScreenState(); +} + +class _FamilyManagementScreenState extends ConsumerState { + @override + void initState() { + super.initState(); + // Load families when screen opens + Future.microtask(() => ref.read(familyProvider.notifier).loadFamilies()); + } + + @override + Widget build(BuildContext context) { + final familiesAsync = ref.watch(familyProvider); + final authState = ref.watch(authProvider); + final activeFamilyId = authState.user?.activeFamilyId; + + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color(0xFFFF6B35), + Color(0xFFFF9F5B), + ], + ), + ), + child: SafeArea( + child: Column( + children: [ + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + const Expanded( + child: Text( + '家庭管理', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 48), + ], + ), + ), + const SizedBox(height: 20), + // Family List + Expanded( + child: familiesAsync.when( + loading: () => const Center( + child: CircularProgressIndicator(color: Colors.white), + ), + error: (err, _) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, color: Colors.white, size: 48), + const SizedBox(height: 16), + Text( + '加载失败: $err', + style: const TextStyle(color: Colors.white), + ), + const SizedBox(height: 16), + TextButton( + onPressed: () => ref.read(familyProvider.notifier).loadFamilies(), + child: const Text('重试', style: TextStyle(color: Colors.white)), + ), + ], + ), + ), + data: (families) => _buildFamilyList(families, activeFamilyId), + ), + ), + // Action Buttons + _buildActionButtons(), + const SizedBox(height: 24), + ], + ), + ), + ), + ); + } + + Widget _buildFamilyList(List families, String? activeFamilyId) { + if (families.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.home_outlined, + size: 64, + color: Colors.white.withOpacity(0.7), + ), + const SizedBox(height: 16), + Text( + '暂无家庭', + style: TextStyle( + fontSize: 18, + color: Colors.white.withOpacity(0.8), + ), + ), + const SizedBox(height: 8), + Text( + '创建或加入一个家庭开始使用', + style: TextStyle( + fontSize: 14, + color: Colors.white.withOpacity(0.6), + ), + ), + ], + ), + ); + } + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 24), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.9), + borderRadius: BorderRadius.circular(20), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: families.length, + separatorBuilder: (_, __) => Container( + margin: const EdgeInsets.only(left: 60), + height: 1, + color: AppColors.textSecondary.withOpacity(0.2), + ), + itemBuilder: (context, index) { + final family = families[index]; + final isActive = family.id == activeFamilyId; + return _buildFamilyItem(family, isActive); + }, + ), + ), + ), + ); + } + + Widget _buildFamilyItem(Family family, bool isActive) { + return InkWell( + onTap: () => _switchActiveFamily(family.id), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.brand.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.home, + color: AppColors.brand, + size: 20, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + family.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + ), + if (isActive) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.brand, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + '当前', + style: TextStyle( + fontSize: 10, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 2), + Text( + '${family.members.length} 位成员', + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: AppColors.textSecondary.withOpacity(0.5), + ), + ], + ), + ), + ); + } + + Widget _buildActionButtons() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + children: [ + Expanded( + child: _buildActionButton( + icon: Icons.add, + label: '创建家庭', + onTap: _showCreateFamilyDialog, + ), + ), + const SizedBox(width: 16), + Expanded( + child: _buildActionButton( + icon: Icons.link, + label: '加入家庭', + onTap: _showJoinFamilyDialog, + ), + ), + ], + ), + ); + } + + Widget _buildActionButton({ + required IconData icon, + required String label, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.white.withOpacity(0.3), + width: 1, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: Colors.white, size: 20), + const SizedBox(width: 8), + Text( + label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ), + ], + ), + ), + ); + } + + void _showCreateFamilyDialog() { + final controller = TextEditingController(); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('创建家庭'), + content: TextField( + controller: controller, + decoration: const InputDecoration( + hintText: '请输入家庭名称', + border: OutlineInputBorder(), + ), + autofocus: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + TextButton( + onPressed: () async { + final name = controller.text.trim(); + if (name.isEmpty) return; + Navigator.pop(context); + await _createFamily(name); + }, + child: const Text('创建'), + ), + ], + ), + ); + } + + void _showJoinFamilyDialog() { + final controller = TextEditingController(); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('加入家庭'), + content: TextField( + controller: controller, + decoration: const InputDecoration( + hintText: '请输入邀请码', + border: OutlineInputBorder(), + ), + autofocus: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + TextButton( + onPressed: () async { + final code = controller.text.trim(); + if (code.isEmpty) return; + Navigator.pop(context); + await _joinFamily(code); + }, + child: const Text('加入'), + ), + ], + ), + ); + } + + Future _createFamily(String name) async { + final family = await ref.read(familyProvider.notifier).createFamily(name); + if (family != null && mounted) { + // Reload families to show the new one + await ref.read(familyProvider.notifier).loadFamilies(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('家庭 "${family.name}" 创建成功')), + ); + } else if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('创建失败,请重试')), + ); + } + } + + Future _joinFamily(String inviteCode) async { + final family = await ref.read(familyProvider.notifier).joinFamily(inviteCode); + if (family != null && mounted) { + // Reload families to show the new one + await ref.read(familyProvider.notifier).loadFamilies(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('成功加入家庭 "${family.name}"')), + ); + } else if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('加入失败,请检查邀请码')), + ); + } + } + + Future _switchActiveFamily(String familyId) async { + // Update the user's activeFamilyId through auth provider + final currentUser = ref.read(authProvider).user; + if (currentUser != null) { + final updatedUser = User( + id: currentUser.id, + phone: currentUser.phone, + nickname: currentUser.nickname, + email: currentUser.email, + familyIds: currentUser.familyIds, + activeFamilyId: familyId, + joinDate: currentUser.joinDate, + createdAt: currentUser.createdAt, + ); + ref.read(authProvider.notifier).state = ref.read(authProvider).copyWith( + user: updatedUser, + ); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('已切换家庭')), + ); + } + } +} \ No newline at end of file diff --git a/frontend/lib/screens/home/home_screen.dart b/frontend/lib/screens/home/home_screen.dart new file mode 100644 index 0000000..30e2870 --- /dev/null +++ b/frontend/lib/screens/home/home_screen.dart @@ -0,0 +1,541 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../providers/auth_provider.dart'; +import '../../providers/bill_provider.dart'; +import '../../providers/family_provider.dart'; +import '../../models/bill.dart'; +import '../../models/family.dart'; +import '../../theme/colors.dart'; +import '../../widgets/glass_card.dart'; +import '../../widgets/category_icon.dart'; +import '../../widgets/stat_card.dart'; + +class HomeScreen extends ConsumerStatefulWidget { + const HomeScreen({super.key}); + + @override + ConsumerState createState() => _HomeScreenState(); +} + +class _HomeScreenState extends ConsumerState { + int _selectedTab = 0; + + @override + void initState() { + super.initState(); + Future.microtask(() { + ref.read(billProvider.notifier).loadBills(); + ref.read(familyProvider.notifier).loadFamilies(); + }); + } + + String _getCurrentMonth() { + final now = DateTime.now(); + final months = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']; + return '${now.year}年${months[now.month - 1]}'; + } + + List _calculateMonthlyStats(List bills) { + final now = DateTime.now(); + final currentMonth = now.month; + final currentYear = now.year; + + double totalExpense = 0; + double totalIncome = 0; + + for (final bill in bills) { + try { + final billDate = DateTime.parse(bill.date); + if (billDate.month == currentMonth && billDate.year == currentYear) { + if (bill.isExpense) { + totalExpense += bill.amount; + } else { + totalIncome += bill.amount; + } + } + } catch (_) {} + } + + final balance = totalIncome - totalExpense; + + return [ + StatCardData(label: '本月支出', value: '¥${totalExpense.toStringAsFixed(0)}', color: AppColors.expense), + StatCardData(label: '本月收入', value: '¥${totalIncome.toStringAsFixed(0)}', color: AppColors.income), + StatCardData(label: '本月结余', value: '¥${balance.toStringAsFixed(0)}', color: AppColors.brand), + ]; + } + + @override + Widget build(BuildContext context) { + final authState = ref.watch(authProvider); + final billsAsync = ref.watch(billProvider); + final familiesAsync = ref.watch(familyProvider); + + final user = authState.user; + final nickname = user?.nickname ?? '用户'; + final firstLetter = nickname.isNotEmpty ? nickname[0] : 'U'; + + final personalBills = billsAsync.valueOrNull ?? []; + final stats = _calculateMonthlyStats(personalBills); + + return Scaffold( + backgroundColor: AppColors.pageBg, + body: Column( + children: [ + // OrangeHeader + Container( + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + 16, + left: 20, + right: 20, + bottom: 20, + ), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.brand, AppColors.brandLight], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // User info row + Row( + children: [ + // Avatar with first letter + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + shape: BoxShape.circle, + border: Border.all(color: Colors.white.withOpacity(0.5), width: 2), + ), + child: Center( + child: Text( + firstLetter, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + const SizedBox(width: 14), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '你好,$nickname', + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 4), + Text( + _getCurrentMonth(), + style: TextStyle( + fontSize: 14, + color: Colors.white.withOpacity(0.85), + ), + ), + ], + ), + ], + ), + const SizedBox(height: 20), + // 3 StatCards + Row( + children: [ + Expanded(child: StatCard(label: stats[0].label, value: stats[0].value, color: stats[0].color)), + const SizedBox(width: 10), + Expanded(child: StatCard(label: stats[1].label, value: stats[1].value, color: stats[1].color)), + const SizedBox(width: 10), + Expanded(child: StatCard(label: stats[2].label, value: stats[2].value, color: stats[2].color)), + ], + ), + ], + ), + ), + + // TabSwitcher + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), + child: Container( + height: 50, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.7), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white.withOpacity(0.8)), + ), + child: Row( + children: [ + Expanded( + child: _TabButton( + label: '个人账本', + isSelected: _selectedTab == 0, + onTap: () => setState(() => _selectedTab = 0), + ), + ), + Expanded( + child: _TabButton( + label: '家庭账本', + isSelected: _selectedTab == 1, + onTap: () => setState(() => _selectedTab = 1), + ), + ), + ], + ), + ), + ), + ), + ), + + // Tab content + Expanded( + child: _selectedTab == 0 + ? _PersonalTab(bills: personalBills) + : _FamilyTab(familiesAsync: familiesAsync, bills: personalBills), + ), + ], + ), + ); + } +} + +class StatCardData { + final String label; + final String value; + final Color color; + StatCardData({required this.label, required this.value, required this.color}); +} + +class _TabButton extends StatelessWidget { + final String label; + final bool isSelected; + final VoidCallback onTap; + + const _TabButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.all(4), + decoration: BoxDecoration( + gradient: isSelected + ? const LinearGradient(colors: [AppColors.brand, AppColors.brandLight]) + : null, + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Text( + label, + style: TextStyle( + fontSize: 15, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + color: isSelected ? Colors.white : AppColors.textSecondary, + ), + ), + ), + ), + ); + } +} + +class _PersonalTab extends StatelessWidget { + final List bills; + + const _PersonalTab({required this.bills}); + + @override + Widget build(BuildContext context) { + if (bills.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.receipt_long_outlined, size: 64, color: AppColors.textSecondary.withOpacity(0.5)), + const SizedBox(height: 16), + Text( + '暂无账单记录', + style: TextStyle(fontSize: 16, color: AppColors.textSecondary.withOpacity(0.7)), + ), + ], + ), + ); + } + + // Sort by date descending + final sortedBills = List.from(bills) + ..sort((a, b) => b.date.compareTo(a.date)); + + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 20), + itemCount: sortedBills.length, + itemBuilder: (context, index) { + final bill = sortedBills[index]; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: GlassCard( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + CategoryIcon(emoji: bill.emoji, category: bill.category), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + bill.category, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + if (bill.note.isNotEmpty) + Text( + bill.note, + style: TextStyle( + fontSize: 13, + color: AppColors.textSecondary.withOpacity(0.8), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${bill.isExpense ? '-' : '+'}¥${bill.amount.toStringAsFixed(2)}', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: bill.isExpense ? AppColors.expense : AppColors.income, + ), + ), + Text( + bill.time, + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary.withOpacity(0.7), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } +} + +class _FamilyTab extends StatelessWidget { + final AsyncValue> familiesAsync; + final List bills; + + const _FamilyTab({required this.familiesAsync, required this.bills}); + + @override + Widget build(BuildContext context) { + return familiesAsync.when( + loading: () => const Center(child: CircularProgressIndicator(color: AppColors.brand)), + error: (e, _) => Center(child: Text('加载失败: $e')), + data: (families) { + if (families.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.family_restroom_outlined, size: 64, color: AppColors.textSecondary.withOpacity(0.5)), + const SizedBox(height: 16), + Text( + '暂无家庭', + style: TextStyle(fontSize: 16, color: AppColors.textSecondary.withOpacity(0.7)), + ), + const SizedBox(height: 8), + Text( + '创建或加入家庭后即可查看', + style: TextStyle(fontSize: 14, color: AppColors.textSecondary.withOpacity(0.5)), + ), + ], + ), + ); + } + + // Filter family bills + final familyBills = bills.where((b) => b.familyId != null).toList(); + final sortedFamilyBills = List.from(familyBills) + ..sort((a, b) => b.date.compareTo(a.date)); + + // Calculate member rankings (mock data based on members) + final family = families.first; + final memberStats = >[]; + for (final member in family.members) { + double total = 0; + for (final bill in familyBills) { + if (bill.userId == member.userId) { + total += bill.amount; + } + } + memberStats.add({'member': member, 'total': total}); + } + memberStats.sort((a, b) => (b['total'] as double).compareTo(a['total'] as double)); + + return ListView( + padding: const EdgeInsets.symmetric(horizontal: 20), + children: [ + // Family bills section + if (familyBills.isNotEmpty) ...[ + const Padding( + padding: EdgeInsets.only(bottom: 12), + child: Text( + '家庭账单', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ), + ...sortedFamilyBills.take(5).map((bill) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: GlassCard( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + CategoryIcon(emoji: bill.emoji, category: bill.category), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + bill.category, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + if (bill.note.isNotEmpty) + Text( + bill.note, + style: TextStyle( + fontSize: 13, + color: AppColors.textSecondary.withOpacity(0.8), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Text( + '${bill.isExpense ? '-' : '+'}¥${bill.amount.toStringAsFixed(2)}', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: bill.isExpense ? AppColors.expense : AppColors.income, + ), + ), + ], + ), + ), + )), + const SizedBox(height: 16), + ], + + // Member rankings section + if (memberStats.isNotEmpty) ...[ + const Padding( + padding: EdgeInsets.only(bottom: 12), + child: Text( + '成员排行', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ), + ...memberStats.asMap().entries().map((entry) { + final idx = entry.key; + final data = entry.value; + final member = data['member'] as FamilyMember; + final total = data['total'] as double; + final medals = ['🥇', '🥈', '🥉']; + + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: GlassCard( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: idx < 3 ? AppColors.brand.withOpacity(0.1) : AppColors.textSecondary.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Center( + child: Text( + idx < 3 ? medals[idx] : '${idx + 1}', + style: const TextStyle(fontSize: 14), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + member.nickname, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ), + Text( + '¥${total.toStringAsFixed(0)}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.brand, + ), + ), + ], + ), + ), + ); + }), + ], + ], + ); + }, + ); + } +} \ No newline at end of file diff --git a/frontend/lib/screens/profile/profile_screen.dart b/frontend/lib/screens/profile/profile_screen.dart new file mode 100644 index 0000000..eaf0b45 --- /dev/null +++ b/frontend/lib/screens/profile/profile_screen.dart @@ -0,0 +1,435 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../providers/auth_provider.dart'; +import '../../providers/family_provider.dart'; +import '../../theme/colors.dart'; +import '../../widgets/glass_card.dart'; + +class ProfileScreen extends ConsumerWidget { + const ProfileScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final authState = ref.watch(authProvider); + final isLoggedIn = authState.isLoggedIn; + final user = authState.user; + + return Scaffold( + backgroundColor: AppColors.pageBg, + body: isLoggedIn && user != null + ? _LoggedInContent(user: user) + : const _LoggedOutContent(), + ); + } +} + +class _LoggedOutContent extends StatelessWidget { + const _LoggedOutContent(); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + const Spacer(), + // Glass card with login prompt + GlassCard( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.brand, AppColors.brandLight], + ), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.person_outline, + size: 40, + color: Colors.white, + ), + ), + const SizedBox(height: 20), + const Text( + '登录后可同步数据', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + '管理家庭账本', + style: TextStyle( + fontSize: 14, + color: AppColors.textSecondary.withOpacity(0.8), + ), + ), + const SizedBox(height: 24), + // Login button + GestureDetector( + onTap: () { + Navigator.pushNamed(context, '/login'); + }, + child: Container( + width: double.infinity, + height: 50, + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.brand, AppColors.brandLight], + ), + borderRadius: BorderRadius.circular(25), + ), + child: const Center( + child: Text( + '立即登录', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), + ), + const Spacer(flex: 2), + ], + ), + ), + ); + } +} + +class _LoggedInContent extends ConsumerWidget { + final dynamic user; + + const _LoggedInContent({required this.user}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final familiesAsync = ref.watch(familyProvider); + final nickname = user.nickname ?? '用户'; + final firstLetter = nickname.isNotEmpty ? nickname[0].toUpperCase() : 'U'; + + return SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // User card with orange gradient avatar + GlassCard( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + Row( + children: [ + // Orange gradient circular avatar with first letter + Container( + width: 64, + height: 64, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.brand, AppColors.brandLight], + ), + shape: BoxShape.circle, + ), + child: Center( + child: Text( + firstLetter, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + nickname, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'ID: ${user.id ?? ""}', + style: TextStyle( + fontSize: 13, + color: AppColors.textSecondary.withOpacity(0.8), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + // Email status and family info + Row( + children: [ + Icon( + user.email != null && user.email!.isNotEmpty + ? Icons.check_circle + : Icons.warning_amber_outlined, + size: 16, + color: user.email != null && user.email!.isNotEmpty + ? AppColors.income + : AppColors.expense, + ), + const SizedBox(width: 6), + Text( + user.email != null && user.email!.isNotEmpty + ? '邮箱已绑定' + : '未绑定邮箱', + style: TextStyle( + fontSize: 13, + color: AppColors.textSecondary.withOpacity(0.8), + ), + ), + const Spacer(), + familiesAsync.when( + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + data: (families) { + if (families.isEmpty) { + return const SizedBox.shrink(); + } + return Row( + children: [ + const Icon( + Icons.home_outlined, + size: 16, + color: AppColors.brand, + ), + const SizedBox(width: 6), + Text( + families.first.name, + style: const TextStyle( + fontSize: 13, + color: AppColors.brand, + ), + ), + ], + ); + }, + ), + ], + ), + ], + ), + ), + const SizedBox(height: 24), + + // Settings groups with glassmorphism + _SettingsGroup( + title: '账本管理', + items: const [ + _SettingsItem(icon: '🏠', label: '我的家庭', route: '/family'), + _SettingsItem(icon: '📒', label: '账本管理', route: '/books'), + _SettingsItem(icon: '💰', label: '预算设置', route: '/budget'), + _SettingsItem(icon: '🏷️', label: '分类管理', route: '/categories'), + ], + ), + const SizedBox(height: 16), + + _SettingsGroup( + title: '系统设置', + items: const [ + _SettingsItem(icon: '🤖', label: 'AI设置', route: '/ai-settings'), + _SettingsItem(icon: '📤', label: '数据导出', route: '/export'), + _SettingsItem(icon: '🔔', label: '提醒设置', route: '/notifications'), + ], + ), + const SizedBox(height: 16), + + _SettingsGroup( + title: '关于', + items: const [ + _SettingsItem(icon: 'ℹ️', label: '关于', route: '/about'), + _SettingsItem(icon: '💬', label: '帮助与反馈', route: '/feedback'), + ], + ), + const SizedBox(height: 24), + + // Logout button with red border + GestureDetector( + onTap: () async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('确认退出'), + content: const Text('确定要退出登录吗?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('取消'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text( + '退出', + style: TextStyle(color: AppColors.expense), + ), + ), + ], + ), + ); + if (confirmed == true) { + await ref.read(authProvider.notifier).logout(); + } + }, + child: Container( + width: double.infinity, + height: 50, + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(25), + border: Border.all(color: AppColors.expense, width: 1.5), + ), + child: const Center( + child: Text( + '退出登录', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.expense, + ), + ), + ), + ), + ), + const SizedBox(height: 20), + ], + ), + ), + ); + } +} + +class _SettingsGroup extends StatelessWidget { + final String title; + final List<_SettingsItem> items; + + const _SettingsGroup({ + required this.title, + required this.items, + }); + + @override + Widget build(BuildContext context) { + return GlassCard( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.brand, + ), + ), + ), + ...items.asMap().entries.map((entry) { + final index = entry.key; + final item = entry.value; + final isLast = index == items.length - 1; + return Column( + children: [ + _SettingsTile(item: item), + if (!isLast) + Divider( + height: 1, + indent: 56, + endIndent: 16, + color: AppColors.textSecondary.withOpacity(0.1), + ), + ], + ); + }), + const SizedBox(height: 8), + ], + ), + ); + } +} + +class _SettingsItem { + final String icon; + final String label; + final String route; + + const _SettingsItem({ + required this.icon, + required this.label, + required this.route, + }); +} + +class _SettingsTile extends StatelessWidget { + final _SettingsItem item; + + const _SettingsTile({required this.item}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + Navigator.pushNamed(context, item.route); + }, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Text( + item.icon, + style: const TextStyle(fontSize: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + item.label, + style: const TextStyle( + fontSize: 15, + color: AppColors.textPrimary, + ), + ), + ), + Icon( + Icons.chevron_right, + size: 20, + color: AppColors.textSecondary.withOpacity(0.5), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/lib/screens/statistics/statistics_screen.dart b/frontend/lib/screens/statistics/statistics_screen.dart new file mode 100644 index 0000000..7d55de4 --- /dev/null +++ b/frontend/lib/screens/statistics/statistics_screen.dart @@ -0,0 +1,696 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fl_chart/fl_chart.dart'; +import '../../providers/bill_provider.dart'; +import '../../widgets/stat_card.dart'; +import '../../constants.dart'; +import '../../theme/colors.dart'; + +class StatisticsScreen extends ConsumerStatefulWidget { + const StatisticsScreen({super.key}); + + @override + ConsumerState createState() => _StatisticsScreenState(); +} + +class _StatisticsScreenState extends ConsumerState { + int _monthOffset = 0; + + DateTime get _selectedDate { + final now = DateTime.now(); + return DateTime(now.year, now.month + _monthOffset, 1); + } + + String get _displayMonth { + return '${_selectedDate.year}年${_selectedDate.month.toString().padLeft(2, '0')}月'; + } + + List _filterBillsByMonth(List bills) { + return bills.where((bill) { + final billDate = DateTime.tryParse(bill.date); + if (billDate == null) return false; + return billDate.year == _selectedDate.year && billDate.month == _selectedDate.month; + }).toList(); + } + + Map _getCategoryTotals(List bills, bool isExpense) { + final Map totals = {}; + for (final bill in bills) { + if (isExpense ? bill.isExpense : bill.isIncome) { + totals[bill.category] = (totals[bill.category] ?? 0) + bill.amount; + } + } + return totals; + } + + Map _getDailyTotals(List bills) { + final Map totals = {}; + for (final bill in bills) { + if (bill.isExpense) { + final billDate = DateTime.tryParse(bill.date); + if (billDate != null) { + totals[billDate.day] = (totals[billDate.day] ?? 0) + bill.amount; + } + } + } + return totals; + } + + List> _getMonthlyComparison(List bills) { + final now = DateTime.now(); + final List> result = []; + + for (int i = 5; i >= 0; i--) { + final date = DateTime(now.year, now.month - i, 1); + final monthBills = bills.where((bill) { + final billDate = DateTime.tryParse(bill.date); + return billDate != null && billDate.year == date.year && billDate.month == date.month; + }).toList(); + + double income = 0; + double expense = 0; + for (final bill in monthBills) { + if (bill.isIncome) income += bill.amount; + else expense += bill.amount; + } + + result.add({ + 'month': '${date.year}-${date.month.toString().padLeft(2, '0')}', + 'income': income, + 'expense': expense, + }); + } + return result; + } + + @override + Widget build(BuildContext context) { + final billsAsync = ref.watch(billProvider); + + return Scaffold( + backgroundColor: AppColors.pageBg, + body: billsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (bills) { + final monthBills = _filterBillsByMonth(bills); + + double totalIncome = 0; + double totalExpense = 0; + for (final bill in monthBills) { + if (bill.isIncome) totalIncome += bill.amount; + else totalExpense += bill.amount; + } + final balance = totalIncome - totalExpense; + + final expenseCategories = _getCategoryTotals(monthBills, true); + final incomeCategories = _getCategoryTotals(monthBills, false); + final dailyTotals = _getDailyTotals(monthBills); + final monthlyComparison = _getMonthlyComparison(bills); + + return CustomScrollView( + slivers: [ + _buildHeader(), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + _buildMonthSelector(), + const SizedBox(height: 16), + _buildStatCards(totalIncome, totalExpense, balance), + const SizedBox(height: 20), + _buildPieChart(totalExpense, balance), + const SizedBox(height: 20), + _buildCategoryRanking(expenseCategories, true), + const SizedBox(height: 20), + _buildCategoryRanking(incomeCategories, false), + const SizedBox(height: 20), + _buildDailyBarChart(dailyTotals), + const SizedBox(height: 20), + _buildMonthlyComparison(monthlyComparison), + const SizedBox(height: 40), + ], + ), + ), + ), + ], + ); + }, + ), + ); + } + + Widget _buildHeader() { + return SliverAppBar( + expandedHeight: 100, + floating: false, + pinned: true, + backgroundColor: AppColors.brand, + flexibleSpace: FlexibleSpaceBar( + title: const Text( + '统计', + style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + ), + background: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.brand, AppColors.brandLight], + ), + ), + ), + ), + ); + } + + Widget _buildMonthSelector() { + return Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + decoration: BoxDecoration( + color: AppColors.cardBg, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white.withOpacity(0.8)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon(Icons.chevron_left, color: AppColors.textPrimary), + onPressed: () => setState(() => _monthOffset--), + ), + Text( + _displayMonth, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + IconButton( + icon: const Icon(Icons.chevron_right, color: AppColors.textPrimary), + onPressed: _monthOffset < 0 ? () => setState(() => _monthOffset++) : null, + ), + ], + ), + ); + } + + Widget _buildStatCards(double income, double expense, double balance) { + return Row( + children: [ + Expanded( + child: StatCard( + label: '收入', + value: '¥${income.toStringAsFixed(2)}', + color: AppColors.income, + ), + ), + const SizedBox(width: 8), + Expanded( + child: StatCard( + label: '支出', + value: '¥${expense.toStringAsFixed(2)}', + color: AppColors.expense, + ), + ), + const SizedBox(width: 8), + Expanded( + child: StatCard( + label: '结余', + value: '¥${balance.toStringAsFixed(2)}', + color: balance >= 0 ? AppColors.income : AppColors.expense, + ), + ), + ], + ); + } + + Widget _buildPieChart(double expense, double balance) { + final total = expense + balance; + final usedPercent = total > 0 ? (expense / total * 100) : 0.0; + final remainingPercent = total > 0 ? (balance / total * 100) : 0.0; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.cardBg, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.white.withOpacity(0.8)), + boxShadow: [ + BoxShadow( + color: AppColors.brand.withOpacity(0.1), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '预算使用', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 16), + SizedBox( + height: 180, + child: total > 0 + ? PieChart( + PieChartData( + sectionsSpace: 2, + centerSpaceRadius: 40, + sections: [ + PieChartSectionData( + value: expense, + color: AppColors.expense, + title: '${usedPercent.toStringAsFixed(1)}%', + titleStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + radius: 50, + ), + PieChartSectionData( + value: balance, + color: AppColors.income, + title: '${remainingPercent.toStringAsFixed(1)}%', + titleStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + radius: 50, + ), + ], + ), + ) + : const Center( + child: Text( + '暂无数据', + style: TextStyle(color: AppColors.textSecondary), + ), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildLegendItem('已支出', AppColors.expense), + const SizedBox(width: 24), + _buildLegendItem('剩余', AppColors.income), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildLegendItem(String label, Color color) { + return Row( + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(3), + ), + ), + const SizedBox(width: 6), + Text( + label, + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ); + } + + Widget _buildCategoryRanking(Map categories, bool isExpense) { + if (categories.isEmpty) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.cardBg, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.white.withOpacity(0.8)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isExpense ? '支出分类' : '收入分类', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 16), + const Center( + child: Text( + '暂无数据', + style: TextStyle(color: AppColors.textSecondary), + ), + ), + ], + ), + ); + } + + final sortedEntries = categories.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + final maxAmount = sortedEntries.first.value; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.cardBg, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.white.withOpacity(0.8)), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isExpense ? '支出分类' : '收入分类', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 16), + ...sortedEntries.take(6).map((entry) { + final emoji = AppConstants.categoryEmoji(entry.key); + final progress = maxAmount > 0 ? entry.value / maxAmount : 0.0; + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(emoji, style: const TextStyle(fontSize: 16)), + const SizedBox(width: 8), + Expanded( + child: Text( + entry.key, + style: const TextStyle( + fontSize: 13, + color: AppColors.textPrimary, + ), + ), + ), + Text( + '¥${entry.value.toStringAsFixed(2)}', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isExpense ? AppColors.expense : AppColors.income, + ), + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: progress, + backgroundColor: Colors.grey.shade200, + valueColor: AlwaysStoppedAnimation( + isExpense ? AppColors.expense : AppColors.income, + ), + minHeight: 6, + ), + ), + ], + ), + ); + }), + ], + ), + ), + ), + ); + } + + Widget _buildDailyBarChart(Map dailyTotals) { + final spots = []; + for (int day = 1; day <= 31; day++) { + spots.add(FlSpot(day.toDouble(), dailyTotals[day] ?? 0)); + } + + final maxY = dailyTotals.values.isEmpty + ? 100.0 + : dailyTotals.values.reduce((a, b) => a > b ? a : b) * 1.2; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.cardBg, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.white.withOpacity(0.8)), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '日消费趋势', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 16), + SizedBox( + height: 180, + child: BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + maxY: maxY, + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + getTooltipItem: (group, groupIndex, rod, rodIndex) { + return BarTooltipItem( + '¥${rod.toY.toStringAsFixed(2)}', + const TextStyle(color: Colors.white, fontSize: 12), + ); + }, + ), + ), + titlesData: FlTitlesData( + show: true, + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + if (value.toInt() % 5 == 0) { + return Text( + '${value.toInt()}日', + style: const TextStyle( + fontSize: 10, + color: AppColors.textSecondary, + ), + ); + } + return const SizedBox.shrink(); + }, + reservedSize: 20, + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + return Text( + '¥${value.toInt()}', + style: const TextStyle( + fontSize: 10, + color: AppColors.textSecondary, + ), + ); + }, + reservedSize: 40, + ), + ), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + ), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: maxY / 4, + getDrawingHorizontalLine: (value) { + return FlLine( + color: Colors.grey.shade200, + strokeWidth: 1, + ); + }, + ), + borderData: FlBorderData(show: false), + barGroups: List.generate(31, (index) { + final day = index + 1; + final value = dailyTotals[day] ?? 0; + return BarChartGroupData( + x: day, + barRods: [ + BarChartRodData( + toY: value, + color: AppColors.brand, + width: 8, + borderRadius: const BorderRadius.vertical(top: Radius.circular(4)), + ), + ], + ); + }), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildMonthlyComparison(List> months) { + double maxValue = 0; + for (final month in months) { + final income = month['income'] as double; + final expense = month['expense'] as double; + if (income > maxValue) maxValue = income; + if (expense > maxValue) maxValue = expense; + } + if (maxValue == 0) maxValue = 1000; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.cardBg, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.white.withOpacity(0.8)), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '月度对比', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 16), + ...months.map((month) { + final monthStr = month['month'] as String; + final income = month['income'] as double; + final expense = month['expense'] as double; + final monthLabel = monthStr.substring(5); + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '$monthLabel月', + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + Row( + children: [ + Text( + '收¥${income.toStringAsFixed(0)}', + style: const TextStyle( + fontSize: 11, + color: AppColors.income, + ), + ), + const SizedBox(width: 8), + Text( + '支¥${expense.toStringAsFixed(0)}', + style: const TextStyle( + fontSize: 11, + color: AppColors.expense, + ), + ), + ], + ), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(3), + child: LinearProgressIndicator( + value: maxValue > 0 ? income / maxValue : 0, + backgroundColor: Colors.grey.shade200, + valueColor: const AlwaysStoppedAnimation(AppColors.income), + minHeight: 8, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(3), + child: LinearProgressIndicator( + value: maxValue > 0 ? expense / maxValue : 0, + backgroundColor: Colors.grey.shade200, + valueColor: const AlwaysStoppedAnimation(AppColors.expense), + minHeight: 8, + ), + ), + ), + ], + ), + ], + ), + ); + }), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/lib/theme/app_theme.dart b/frontend/lib/theme/app_theme.dart new file mode 100644 index 0000000..57d8f9c --- /dev/null +++ b/frontend/lib/theme/app_theme.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'colors.dart'; + +class AppTheme { + static ThemeData get light => ThemeData( + useMaterial3: true, + scaffoldBackgroundColor: AppColors.pageBg, + colorScheme: ColorScheme.light( + primary: AppColors.brand, + secondary: AppColors.brandLight, + ), + appBarTheme: const AppBarTheme( + backgroundColor: Colors.transparent, + elevation: 0, + centerTitle: true, + ), + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + backgroundColor: Colors.transparent, + elevation: 0, + ), + ); +} \ No newline at end of file diff --git a/frontend/lib/theme/colors.dart b/frontend/lib/theme/colors.dart new file mode 100644 index 0000000..e0124da --- /dev/null +++ b/frontend/lib/theme/colors.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +class AppColors { + static const brand = Color(0xFFFF6B35); + static const brandLight = Color(0xFFFF9F5B); + static const brandBg = Color(0xFFFFF0E8); + static const income = Color(0xFF34C759); + static const expense = Color(0xFFFF3B30); + static const textPrimary = Color(0xFF1C1C1E); + static const textSecondary = Color(0xFF8E8E93); + static const cardBg = Color(0xA6FFFFFF); + static const pageBg = Color(0xFFF2F2F7); +} \ No newline at end of file diff --git a/frontend/lib/widgets/category_grid.dart b/frontend/lib/widgets/category_grid.dart new file mode 100644 index 0000000..600e18e --- /dev/null +++ b/frontend/lib/widgets/category_grid.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import '../theme/colors.dart'; + +class CategoryGrid extends StatelessWidget { + final List> categories; + final String selected; + final ValueChanged onSelect; + + const CategoryGrid({ + super.key, + required this.categories, + required this.selected, + required this.onSelect, + }); + + @override + Widget build(BuildContext context) { + return GridView.count( + crossAxisCount: 4, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 1.1, + children: categories.map((cat) { + final isSelected = selected == cat['name']; + return GestureDetector( + onTap: () => onSelect(cat['name']!), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 6), + decoration: BoxDecoration( + color: isSelected ? AppColors.brand.withOpacity(0.1) : Colors.white.withOpacity(0.65), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? AppColors.brand : Colors.white.withOpacity(0.5), + width: isSelected ? 1.5 : 1, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(cat['emoji'] ?? '', style: const TextStyle(fontSize: 22)), + const SizedBox(height: 2), + Text( + cat['name'] ?? '', + style: TextStyle( + fontSize: 11, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + color: isSelected ? AppColors.brand : const Color(0xFF8E8E93), + ), + ), + ], + ), + ), + ); + }).toList(), + ); + } +} \ No newline at end of file diff --git a/frontend/lib/widgets/category_icon.dart b/frontend/lib/widgets/category_icon.dart new file mode 100644 index 0000000..9a50b0a --- /dev/null +++ b/frontend/lib/widgets/category_icon.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import '../theme/colors.dart'; + +class CategoryIcon extends StatelessWidget { + final String emoji; + final String category; + final double size; + + const CategoryIcon({ + super.key, + required this.emoji, + required this.category, + this.size = 40, + }); + + Color _bgColor() { + switch (category) { + case '餐饮': return AppColors.brand.withOpacity(0.08); + case '交通': return AppColors.brandLight.withOpacity(0.08); + case '购物': return AppColors.brand.withOpacity(0.06); + case '医疗': return AppColors.expense.withOpacity(0.06); + case '工资': return AppColors.income.withOpacity(0.08); + default: return AppColors.textSecondary.withOpacity(0.08); + } + } + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: _bgColor(), + shape: BoxShape.circle, + boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 4, offset: Offset(0, 2))], + ), + child: Center(child: Text(emoji, style: TextStyle(fontSize: size * 0.45))), + ); + } +} \ No newline at end of file diff --git a/frontend/lib/widgets/glass_card.dart b/frontend/lib/widgets/glass_card.dart new file mode 100644 index 0000000..b7c18e7 --- /dev/null +++ b/frontend/lib/widgets/glass_card.dart @@ -0,0 +1,55 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import '../theme/colors.dart'; + +class GlassCard extends StatelessWidget { + final Widget child; + final EdgeInsetsGeometry? padding; + final EdgeInsetsGeometry? margin; + final double borderRadius; + final double? width; + final double? height; + final Color? borderColor; + final List? boxShadow; + + const GlassCard({ + super.key, + required this.child, + this.padding, + this.margin, + this.borderRadius = 16, + this.width, + this.height, + this.borderColor, + this.boxShadow, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + margin: margin, + padding: padding ?? const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.cardBg, + borderRadius: BorderRadius.circular(borderRadius), + border: Border.all(color: borderColor ?? Colors.white.withOpacity(0.8)), + boxShadow: boxShadow ?? [ + BoxShadow( + color: AppColors.brand.withOpacity(0.06), + blurRadius: 32, + offset: const Offset(0, 8), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(borderRadius), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), + child: child, + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/lib/widgets/number_pad.dart b/frontend/lib/widgets/number_pad.dart new file mode 100644 index 0000000..4bc2c14 --- /dev/null +++ b/frontend/lib/widgets/number_pad.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import '../theme/colors.dart'; + +class NumberPad extends StatelessWidget { + final void Function(String key) onKeyTap; + final String? Function()? onDelete; + + const NumberPad({super.key, required this.onKeyTap, this.onDelete}); + + @override + Widget build(BuildContext context) { + final keys = ['1','2','3','4','5','6','7','8','9','.', '0', 'delete']; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: GridView.count( + crossAxisCount: 4, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 1.5, + children: keys.map((key) { + final isDelete = key == 'delete'; + return GestureDetector( + onTap: () => onKeyTap(key), + child: Container( + height: 48, + decoration: BoxDecoration( + color: isDelete ? AppColors.brand.withOpacity(0.08) : Colors.white.withOpacity(0.7), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Text( + isDelete ? '⌫' : key, + style: TextStyle( + fontSize: isDelete ? 20 : 22, + fontWeight: isDelete ? FontWeight.w400 : FontWeight.w500, + color: isDelete ? AppColors.brand : const Color(0xFF1C1C1E), + ), + ), + ), + ), + ); + }).toList(), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/lib/widgets/stat_card.dart b/frontend/lib/widgets/stat_card.dart new file mode 100644 index 0000000..1f3c1b0 --- /dev/null +++ b/frontend/lib/widgets/stat_card.dart @@ -0,0 +1,52 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; + +class StatCard extends StatelessWidget { + final String label; + final String value; + final Color color; + final double? fontSize; + + const StatCard({ + super.key, + required this.label, + required this.value, + required this.color, + this.fontSize, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + decoration: BoxDecoration( + color: const Color(0xA6FFFFFF), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white.withOpacity(0.8)), + boxShadow: const [ + BoxShadow(color: Color(0x0DFF6B35), blurRadius: 32, offset: Offset(0, 8)), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), + child: Column( + children: [ + Text(label, style: const TextStyle(fontSize: 11, color: Color(0xFF8E8E93), fontWeight: FontWeight.w500)), + const SizedBox(height: 4), + Text( + value, + style: TextStyle( + fontSize: fontSize ?? (value.length > 10 ? 13 : 15), + fontWeight: FontWeight.w700, + color: color, + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml new file mode 100644 index 0000000..3a77dd4 --- /dev/null +++ b/frontend/pubspec.yaml @@ -0,0 +1,28 @@ +name: family_accounting +description: Family Accounting App +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: '>=3.2.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + flutter_riverpod: ^2.5.0 + riverpod_annotation: ^2.3.0 + dio: ^5.4.0 + flutter_secure_storage: ^9.0.0 + go_router: ^14.0.0 + fl_chart: ^0.68.0 + intl: ^0.19.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^4.0.0 + build_runner: ^2.4.0 + riverpod_generator: ^2.4.0 + +flutter: + uses-material-design: true \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..0205389 --- /dev/null +++ b/index.html @@ -0,0 +1,3113 @@ + + + + + +家庭记账 · 液态玻璃设计 + + + + + + +
家庭记账 · 液态玻璃设计
+
简约白 · 时尚橙 · iOS 26 Liquid Glass
+
+
iOS 26 · Liquid Glass Concept
+ + + +