commit 67d4dd5b7cdc2a5db665d7efd53b7b4df0b8ef42 Author: handsomeAq Date: Mon Sep 21 17:37:53 2026 +0800 first commit diff --git a/.codebuddy/.gitignore b/.codebuddy/.gitignore new file mode 100644 index 0000000..9f4c740 --- /dev/null +++ b/.codebuddy/.gitignore @@ -0,0 +1 @@ +db/ \ No newline at end of file diff --git a/.codebuddy/memory/2026-09-21.md b/.codebuddy/memory/2026-09-21.md new file mode 100644 index 0000000..d525fb4 --- /dev/null +++ b/.codebuddy/memory/2026-09-21.md @@ -0,0 +1,25 @@ +# 2026-09-21 + +## 代码讲解(Flutter 入门答疑,未改动业务代码) +围绕项目已有文件做概念讲解,帮助用户理解 Flutter/Riverpod 基础: + +- `lib/app.dart`:讲解根组件职责 = 全局 `navigatorKey` + M3 主题 + 登录态路由分发(`auth.isLoggedIn ? MainShell : LoginPage`)。 +- `GlobalKey`:讲解其"全局可寻址"特性;本项目 `navigatorKey` 属于第①类用法(获取 `NavigatorState` 以在无 `BuildContext` 的网络层执行跳转)。 +- `ConsumerWidget`:讲解它是「带 `ref` 的 StatelessWidget」;对照本项目的三种用法分布——`ConsumerWidget`(`app.dart`、`ocr_tab.dart`、`ocr_result_card.dart`)、`ConsumerStatefulWidget`(`main_shell.dart`、`login_page.dart`、`manual_entry_tab.dart`)、`Consumer` 局部订阅(`manual_entry_tab.dart` 的 `categoriesProvider`)。 +- `main_shell.dart` 全文件:`ConsumerStatefulWidget` 选型理由、`_index` 本地状态、`initState` 兜底跳转(`addPostFrameCallback` + `pushAndRemoveUntil` 的用意)、`IndexedStack` 保留 Tab 状态、M3 `NavigationBar` 参数(`indicatorColor: 0x33C8956E` 的 `33` 是透明度)。 +- `main_shell.dart` 中 `ref` 的来源:`ConsumerState` 的继承成员(非普通变量),`initState` 内只能用 `ref.read` 不能 `watch`。 +- `login_page.dart` 的 `Stack`(L132-152):层叠布局——先画 4 个 `Positioned` 装饰圆球,再画 `SafeArea → Center → SingleChildScrollView → Column` 内容层;`..._buildBackgroundBalls()` 是 Dart 展开运算符。 + +## 环境确认 +- 执行 `flutter --version` 确认:**Flutter 3.47.4 stable / Dart 3.13.3**。这正好是 Material & Cupertino 从 SDK 解耦(拆为 `material_ui` / `cupertino_ui` 独立包)的版本,现有 `package:flutter/material.dart` 导入仍兼容。 + +## 学习资料指引(已提供给用户) +- 设计规范:`m3.material.io`(尤其 `/components`)、`/develop/flutter`、Theme Builder。 +- Flutter 文档:`docs.flutter.dev/ui/design/material`、Widget Catalog、`api.flutter.dev` 的 `material-library` / `ThemeData` / `ColorScheme`。 +- 中文:`docs.flutter.cn`。示例:`flutter.github.io/samples`。在线试验:`dartpad.dev`。 +- 多端与自适应:`docs.flutter.cn/ui/adaptive-responsive/`、`.adaptive` 构造(`Switch.adaptive` 等)、窗口尺寸类别(Compact <600 / Medium 600-839 / Expanded ≥840)、`flutter_adaptive_scaffold`。 + +## 结论性判断 +- 项目已是 Material 3,无需开启 `useMaterial3`。 +- 项目"多端"现状:仅 Android/iOS/Web,纯 M3、未做平台自适应与响应式布局。 +- 向用户提出了三个可选后续任务:① `MainShell` 响应式改造(小屏 `NavigationBar` / 宽屏 `NavigationRail`)② 关键组件加 `.adaptive` ③ 补桌面端平台目录。用户尚未选择。 diff --git a/.codebuddy/memory/MEMORY.md b/.codebuddy/memory/MEMORY.md new file mode 100644 index 0000000..21f1d67 --- /dev/null +++ b/.codebuddy/memory/MEMORY.md @@ -0,0 +1,37 @@ +# 项目长期记忆:强宝爱记账 + +## 项目概况 +- 名称:`qiangbao_accounting`(强宝爱记账),一个 Flutter 记账类 App。 +- 定位:由 uniApp 项目 `pages/login/login.vue` 复刻迁移而来(见 `login_page.dart` 顶部注释)。 +- 环境:Flutter **3.47.4** stable / Dart **3.13.3**(Windows 开发)。 +- 目标平台:仅 `android/`、`ios/`、`web/`,未配置桌面端。 + +## 技术栈与约定 +- 状态管理:**flutter_riverpod ^3.0.0**(`Notifier` / `NotifierProvider`,非旧版 StateNotifier)。 +- 网络:**dio ^5.8.0**,API 层按模块拆分在 `lib/api/`,每个模块导出 `xxxApiProvider = Provider(...)`。 +- 本地存储:`shared_preferences`,统一封装在 `lib/core/storage/local_storage.dart`。 +- 其他依赖:`image_picker`(OCR 选图)、`intl`、`cupertino_icons`。 +- UI:Material 3(Flutter 3.16+ 默认启用),未显式设置 `useMaterial3`。 + +## 架构与关键文件 +- `lib/app.dart`:根组件 `QiangbaoApp extends ConsumerWidget`。定义全局 `navigatorKey`(供网络层 401 跳转登录页),配置主题,用 `auth.isLoggedIn` 在 `MainShell` / `LoginPage` 之间分发。 +- `lib/pages/shell/main_shell.dart`:主框架,4 个 Tab(首页/记账/统计/我的),用 `IndexedStack` 保留各页状态,`NavigationBar`(M3)承载导航,默认 `_index = 1`(记账)。`initState` 里有登录态兜底跳转。 +- `lib/pages/login/login_page.dart`:登录/注册页,含毛玻璃卡片(`BackdropFilter`)、动画背景圆球(`AnimationController` + `Stack` + `Positioned` + `Transform.translate`)、记住密码(`utils/crypto.dart` 加解密)。 +- 目前只有「记账」页(`AddPage`,含 `manual_entry_tab.dart`、`ocr_tab.dart`、`ocr_result_card.dart`)是真实实现,首页/统计/我的仍是 `_PlaceholderPage`。 + +## 设计规范(配色) +- 品牌主色:`#C8956E`(驼色/焦糖色),在 `app.dart` 中作为 `ColorScheme.fromSeed` 的 seedColor。 +- 深棕文字色:`#5D4037`;辅助文字:`#8D6E63`;页面底色:`#F8F8F8`。 +- 登录页另用暖橙渐变 `#FFE5CC → #FFD4A8 → #FFC08A`,点缀金色 `#FFD700`、粉色 `#FFB6C1`。 +- 注意:项目中颜色多为**硬编码字面量**,尚未统一走 `Theme.of(context).colorScheme`(有优化空间)。 + +## 待办 / 可优化点 +- 硬编码颜色可迁移为 M3 角色色(`colorScheme.primary` 等),并补暗色模式。 +- `MainShell` 未做响应式:宽屏(≥600dp/840dp)应切换 `NavigationRail` / `NavigationDrawer`。 +- `MainShell.initState` 的 `addPostFrameCallback` 回调里可加 `if (!mounted) return;`。 +- Flutter 3.47 起 Material/Cupertino 已解耦为 `material_ui` / `cupertino_ui` 包,`package:flutter/material.dart` 仍可用,暂无需迁移。 + +## 用户画像与偏好 +- 用户正在**系统学习 Flutter 基础概念**,常就单个文件/代码片段提问(如 `GlobalKey`、`ConsumerWidget`、`ref`、`Stack`)。 +- 沟通偏好:希望用**中文**解释,讲清「是什么 + 为什么这么写 + 在本项目中的上下文」,并配合结构化的表格/分层说明。 +- 用户对 Material 3、多端适配、官方文档出处感兴趣,回答时应附**官方文档链接**。 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..79f7eca --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Widget Preview related +.widget_preview/ diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..328148f --- /dev/null +++ b/.metadata @@ -0,0 +1,36 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "9584c6713b324636289d067944a46fd6b49df14b" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 9584c6713b324636289d067944a46fd6b49df14b + base_revision: 9584c6713b324636289d067944a46fd6b49df14b + - platform: android + create_revision: 9584c6713b324636289d067944a46fd6b49df14b + base_revision: 9584c6713b324636289d067944a46fd6b49df14b + - platform: ios + create_revision: 9584c6713b324636289d067944a46fd6b49df14b + base_revision: 9584c6713b324636289d067944a46fd6b49df14b + - platform: web + create_revision: 9584c6713b324636289d067944a46fd6b49df14b + base_revision: 9584c6713b324636289d067944a46fd6b49df14b + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/README.md b/README.md new file mode 100644 index 0000000..8c974f2 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# qiangbao_accounting + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0619dcf --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,35 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..5d7764b --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,49 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.qiangbao.qiangbao_accounting" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.qiangbao.qiangbao_accounting" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION + // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions) + // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true` + // flag during build. + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a35d624 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/qiangbao/qiangbao_accounting/MainActivity.kt b/android/app/src/main/kotlin/com/qiangbao/qiangbao_accounting/MainActivity.kt new file mode 100644 index 0000000..e7490d2 --- /dev/null +++ b/android/app/src/main/kotlin/com/qiangbao/qiangbao_accounting/MainActivity.kt @@ -0,0 +1,5 @@ +package com.qiangbao.qiangbao_accounting + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..cb1a0e3 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,10 @@ +# 内存配置:本机总内存 16G,Flutter 模板默认 -Xmx8G 过大, +# 叠加 Kotlin 编译守护进程 / IDEA / 后端进程会导致系统内存耗尽假死。 +org.gradle.jvmargs=-Xmx3G -XX:MaxMetaspaceSize=1G -XX:+HeapDumpOnOutOfMemoryError +# Kotlin 编译守护进程同样限制堆内存 +kotlin.daemon.jvmargs=-Xmx2G +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a354bd2 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-9.3.1-bin.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..b28021a --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.1.0" apply false + id("org.jetbrains.kotlin.android") version "2.4.0" apply false +} + +include(":app") diff --git a/assets/images/logo.png b/assets/images/logo.png new file mode 100644 index 0000000..3ab05a1 Binary files /dev/null and b/assets/images/logo.png differ diff --git a/docs/Flutter打包与真机安装指南.md b/docs/Flutter打包与真机安装指南.md new file mode 100644 index 0000000..3a153d0 --- /dev/null +++ b/docs/Flutter打包与真机安装指南.md @@ -0,0 +1,411 @@ +# Flutter 打包 APK 与真机安装指南 + +> 项目:强宝爱记账(qiangbao_accounting) +> 环境:Windows 11 + Flutter 3.47.4 / Dart 3.13.3 +> 测试机:小米 M2007J22C(Android 12 / MIUI 14,设备号 `9dr4gyqgivl7tw5l`) + +--- + +## 0. 快速开始(最常用的三条命令) + +```bash +# ① 开发调试:热重载,改代码存盘即生效 +flutter run -d 9dr4gyqgivl7tw5l + +# ② 打包正式包 +flutter build apk --release + +# ③ 装到手机 +flutter install -d 9dr4gyqgivl7tw5l +``` + +--- + +## 1. 前置检查 + +```bash +# 环境是否齐全(应全绿) +flutter doctor + +# 手机是否连上(应看到设备号,状态为 device 而不是 unauthorized) +adb devices + +# 依赖是否安装 +flutter pub get +``` + +如果 `adb devices` 显示 `unauthorized`,在手机上确认「允许 USB 调试」弹窗。 +如果是 `offline`,拔插数据线或执行 `adb kill-server && adb start-server`。 + +--- + +## 2. 场景一:开发调试(日常用这个) + +```bash +cd /d/nzy/workspace_nzy/qiangbao_accounting +flutter run -d 9dr4gyqgivl7tw5l +``` + +启动后终端会进入交互模式,这些键最常用: + +| 按键 | 作用 | +|---|---| +| `r` | **热重载** —— 改完代码存盘后按一下,约 0.5 秒看到效果(不重启 App) | +| `R` | 热重启 —— 状态重置,但比重装快得多 | +| `q` | 退出并断开 | +| `p` | 显示 Widget 布局网格(调样式时很有用) | +| `o` | 切换 iOS/Android 平台预览模式 | + +**默认是 debug 模式**,特点: +- 启动慢、体积大(本项目 debug APK 81MB),但支持热重载 +- 后端地址:Android 真机走 `http://localhost:12345/api`(依赖 adb reverse,见第 6 节) + +想跑 release 模式验证性能(无热重载): +```bash +flutter run --release -d 9dr4gyqgivl7tw5l +``` + +--- + +## 3. 场景二:打包 APK + +### 3.1 通用包(最省事,能装到任何 Android 手机) + +```bash +flutter build apk --release +``` +产物:`build/app/outputs/flutter-apk/app-release.apk` + +### 3.2 按 CPU 架构拆分(体积更小,推荐) + +```bash +flutter build apk --release --split-per-abi +``` +产物(三个文件):`build/app/outputs/flutter-apk/` +- `app-arm64-v8a-release.apk` —— **现代手机装这个**(小米 M2007J22C 就是 arm64) +- `app-armeabi-v7a-release.apk` —— 老设备 +- `app-x86_64-release.apk` —— 模拟器 + +体积对比:通用包约为单架构包的 **2~3 倍**(因为把所有架构的原生库都塞进去了)。 + +### 3.3 只出指定架构 + +```bash +flutter build apk --release --target-platform android-arm64 +``` + +### 3.4 其他选项 + +```bash +flutter build apk --release -v # 显示详细日志(排查构建失败用) +flutter clean && flutter pub get # 构建异常时清缓存重来 +``` + +--- + +## 4. 场景三:安装到手机 + +### 方式 A:Flutter 命令(推荐) + +```bash +flutter install -d 9dr4gyqgivl7tw5l +``` +会自动找最近构建的 APK 并安装。想指定安装 debug 包: +```bash +flutter install --debug -d 9dr4gyqgivl7tw5l +``` + +### 方式 B:adb 直接安装 + +```bash +adb install -r build/app/outputs/flutter-apk/app-release.apk +``` +- `-r` = 覆盖安装(保留数据) +- `-d` = 允许降级安装 +- 指定手机:`adb -s 9dr4gyqgivl7tw5l install -r xxx.apk` + +### 方式 C:文件管理器手动安装(绕开 USB 限制) + +```bash +# 先把 APK 推到手机存储 +MSYS_NO_PATHCONV=1 adb push build/app/outputs/flutter-apk/app-release.apk /sdcard/Download/qiangbao.apk +``` +然后在手机上: +1. 打开「**文件管理**」 +2. 进入 **手机存储 → Download** +3. 点击 `qiangbao.apk` +4. 若提示「禁止安装未知来源应用」→ 点提示里的**设置**,允许「文件管理」安装应用 +5. MIUI 安全检测后选「**继续安装**」 + +> ⚠️ Windows 上用 Git Bash 执行 `adb push` 必须加 `MSYS_NO_PATHCONV=1`,否则 `/sdcard/...` 会被错误转换成 `C:/Program Files/Git/sdcard/...`。 + +### 卸载 + +```bash +adb uninstall com.qiangbao.qiangbao_accounting +``` + +--- + +## 5. 小米 MIUI 特殊设置(必读) + +如果没有开启下面这个开关,安装会被拦截并报: + +``` +Failure [INSTALL_FAILED_USER_RESTRICTED: Install canceled by user] +``` + +**开启路径**: +``` +设置 → 更多设置 → 开发者选项 → 「USB 安装」→ 打开 +``` +在「调试」分组里,位于「USB 调试」下方。 + +注意: +- MIUI 通常要求**登录小米账号 + 插入 SIM 卡**才能开启该开关(防刷机策略) +- 同时看到「**USB 调试(安全设置)**」也建议打开(模拟点击需要) +- 看到 `USER_RESTRICTED` 就是厂商安全策略拦截,**不是代码或命令的问题** + +--- + +## 6. 开发环境的后端连接(重要) + +App 里的 API 地址会**按构建模式自动切换**(见 `lib/core/network/api_env.dart`): + +| 构建模式 | 使用的后端地址 | +|---|---| +| debug | `http://localhost:12345/api`(本地后端) | +| release | `https://accounting.aqroid.cn/api`(**生产环境**) | + +### 6.1 真机 debug 连本地后端:用 adb reverse + +真机的 `localhost` 指向手机自己,不是你的电脑。用端口转发让手机的 localhost 映射到电脑: + +```bash +adb reverse tcp:12345 tcp:12345 # 每次插拔设备/重启 adb 后都要重新执行 +adb reverse --list # 确认映射存在 +``` + +好处:不受 Windows 防火墙影响,也不要求手机和电脑在同一 Wi-Fi。 + +### 6.2 不想用 adb reverse 时:改用局域网 IP + +```bash +# 查电脑局域网 IP(本项目实测为 192.168.50.182) +ipconfig | grep -A2 IPv4 + +flutter run -d 9dr4gyqgivl7tw5l --dart-define=API_BASE_URL=http://192.168.50.182:12345/api +``` + +`--dart-define` 在 debug 和 release 下都生效,优先级最高: + +```bash +flutter build apk --release --dart-define=API_BASE_URL=http://192.168.50.182:12345/api +``` + +> 服务端 Spring Boot 需监听 `0.0.0.0`(默认即是),且 Windows 防火墙需放行 java 的入站。 + +--- + +## 7. 正式发布前要做的事 + +### 7.1 配置签名(自己测试可跳过,上架必须) + +当前 `android/app/build.gradle.kts` 用的是 **debug 签名**(模板默认,仅供自测,应用商店会拒收): + +```kotlin +buildTypes { + release { + // TODO: Add your own signing config for the release build. + signingConfig = signingConfigs.getByName("debug") + } +} +``` + +**① 生成密钥库(一次性,务必备份 + 记住密码)** +```bash +keytool -genkey -v -keystore D:/keys/qiangbao-release.jks \ + -storetype JKS -keyalg RSA -keysize 2048 -validity 10000 -alias qiangbao +``` + +**② 新建 `android/key.properties`(⚠️ 加入 .gitignore,不要提交)** +```properties +storePassword=你的密码 +keyPassword=你的密码 +keyAlias=qiangbao +storeFile=D:/keys/qiangbao-release.jks +``` + +**③ 修改 `android/app/build.gradle.kts`** +```kotlin +import java.util.Properties +import java.io.FileInputStream + +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + +android { + signingConfigs { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + } + } + buildTypes { + release { + signingConfig = signingConfigs.getByName("release") + } + } +} +``` + +**④ 打包** +```bash +flutter build apk --release # 国内应用商店用 APK +flutter build appbundle --release # Google Play 用 AAB(产物在 build/app/outputs/bundle/release/) +``` + +### 7.2 版本号 + +`pubspec.yaml`: +```yaml +version: 1.0.0+1 +# ↑ ↑ +# 版本名 版本号(versionCode) +``` +**每次发版必须让 `+` 后面的数字递增**(否则应用商店/手机认为不是新版本,无法覆盖安装)。 + +产物中的体现:`versionName=1.0.0`、`versionCode=1`。 + +### 7.3 应用名称与图标 + +| 项 | 位置 | 当前值 | +|---|---|---| +| 应用名 | `android/app/src/main/AndroidManifest.xml` 的 `android:label` | `qiangbao_accounting` | +| 图标 | `android/app/src/main/res/mipmap-*/ic_launcher.png` | Flutter 默认图标 | +| 包名 | `android/app/build.gradle.kts` 的 `applicationId` | `com.qiangbao.qiangbao_accounting`(**上架后不可改**) | + +图标推荐用 `flutter_launcher_icons` 一键生成: +```yaml +# pubspec.yaml +dev_dependencies: + flutter_launcher_icons: ^0.14.0 +flutter_launcher_icons: + android: true + ios: true + image_path: "assets/images/logo.png" +``` +```bash +dart run flutter_launcher_icons +``` + +--- + +## 8. 本项目已做的环境配置(请勿改回) + +这些是本机踩坑后落地的配置,改动前请先了解原因。 + +### 8.1 依赖缓存已迁到 D 盘 + +| 缓存 | 位置 | 配置方式 | +|---|---|---| +| pub 依赖 | `D:\dev-cache\pub` | 用户环境变量 `PUB_CACHE` | +| Gradle | `D:\dev-cache\gradle` | 用户环境变量 `GRADLE_USER_HOME` | + +**为什么**:原先 pub 缓存在 C 盘、项目在 D 盘,Kotlin 增量编译无法处理跨盘符路径,构建报 +`Could not close incremental caches ... different roots`。 +迁移后同时释放了 C 盘约 5.4GB 空间。 + +> 若在别的机器上遇到同类报错,处理方式二选一:把缓存挪到与项目同盘,或在 `android/gradle.properties` 加 `kotlin.incremental=false`。 + +### 8.2 内存限额(防系统假死) + +`android/gradle.properties`: +```properties +org.gradle.jvmargs=-Xmx3G -XX:MaxMetaspaceSize=1G -XX:+HeapDumpOnOutOfMemoryError +kotlin.daemon.jvmargs=-Xmx2G +``` +**为什么**:Flutter 模板默认 `-Xmx8G`,本机总内存 16GB,叠加 Kotlin 守护进程 / IDEA / Spring Boot 会耗尽内存导致系统卡死(曾把后端进程挤死)。 + +### 8.3 Gradle 发行包走国内镜像 + +`android/gradle/wrapper/gradle-wrapper.properties`: +```properties +distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-9.3.1-bin.zip +``` +**为什么**:官方源国内下载约 470KB/s,约 150MB 要十几分钟。用镜像 + `-bin` 包快很多。 + +### 8.4 Android 明文流量 + +`android/app/src/main/AndroidManifest.xml`: +```xml + + +``` +用于 debug 时访问 `http://` 的本地后端(生产是 HTTPS,其实可以不开)。 + +--- + +## 9. 常见错误速查表 + +| 报错 / 现象 | 原因 | 解决 | +|---|---|---| +| `INSTALL_FAILED_USER_RESTRICTED` | MIUI 未开 USB 安装 | 开发者选项 → USB 安装(见第 5 节) | +| `Could not close incremental caches` / `different roots` | pub 缓存与项目跨盘符 | 缓存迁到同盘(本项目已做,见 8.1) | +| `Timeout of 120000 waiting for exclusive access to gradle-*.zip` | 另一个 Gradle 进程占着下载锁 | 关掉其它构建窗口;必要时结束残留的 `gradlew` 进程 | +| 构建卡住不动 | 依赖从 Google Maven 慢速下载 | 已用镜像;首次构建最慢,缓存热了就快 | +| 系统假死、进程被杀 | 构建占用内存过高 | 已限制堆内存(见 8.2) | +| 应用登录报 **403** | ⚠️ 后端连不上数据库(如 JDBC URL 写错),Spring Security 用 403 兜底 | **去后端日志找根因**,别被 403 误导;URL 必须是 `jdbc:mysql://host:3306/db` | +| `Permission denied: getsockopt` | 代理/TUN 驱动拦截 Java socket | 给数据库 IP 加直连规则,或开发时关闭 TUN | +| 应用请求失败、后端却没收到 | 真机没做端口转发 | `adb reverse tcp:12345 tcp:12345` | +| `adb push` 报路径错误 | Git Bash 路径转换 | 命令前加 `MSYS_NO_PATHCONV=1` | +| 手机装了旧版覆盖不了 | 签名不一致或 versionCode 未递增 | 先卸载旧包,或递增 `pubspec.yaml` 的版本号 | + +--- + +## 10. 命令速查 + +```bash +# ---------- 开发 ---------- +flutter doctor # 环境检查 +flutter devices # 列出可用设备 +flutter pub get # 安装依赖 +flutter run -d <设备号> # debug 运行(热重载) +flutter run --release -d <设备号> # release 运行 +flutter analyze # 静态检查(提交前必跑) + +# ---------- 构建 ---------- +flutter clean # 清构建产物 +flutter build apk --release # 通用 APK +flutter build apk --release --split-per-abi # 按架构拆分 +flutter build appbundle --release # AAB(Google Play) + +# ---------- 安装 ---------- +flutter install -d <设备号> # 安装 +adb install -r # adb 覆盖安装 +adb uninstall com.qiangbao.qiangbao_accounting + +# ---------- 调试辅助 ---------- +adb devices # 设备列表 +adb reverse tcp:12345 tcp:12345 # 端口转发(真机连本地后端) +adb reverse --list # 查看转发规则 +adb logcat | grep flutter # 看应用日志 +adb shell pm list packages | grep qiangbao # 确认是否已安装 +adb shell screencap -p /sdcard/s.png && adb pull /sdcard/s.png # 截图 +``` + +--- + +## 11. 首次构建 vs 后续构建 + +| | 首次 | 后续(缓存热) | +|---|---|---| +| 耗时 | 5~20 分钟(下载 Gradle + AGP 依赖) | 30 秒~2 分钟 | +| 主要瓶颈 | 依赖下载 | Dart 编译 | + +**故:第一次构建慢是正常的,不要中断。** 之后改代码用 `r` 热重载,几乎无等待。 diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..d7ee30d --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,647 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.qiangbao.qiangbaoAccounting; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.qiangbao.qiangbaoAccounting.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.qiangbao.qiangbaoAccounting.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.qiangbao.qiangbaoAccounting.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.qiangbao.qiangbaoAccounting; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.qiangbao.qiangbaoAccounting; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..c8236cb --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,74 @@ + + + + + NSCameraUsageDescription + 用于拍摄账单照片进行OCR识别 + NSPhotoLibraryUsageDescription + 用于选择账单截图进行OCR识别 + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Qiangbao Accounting + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + qiangbao_accounting + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/api/account_api.dart b/lib/api/account_api.dart new file mode 100644 index 0000000..5031d33 --- /dev/null +++ b/lib/api/account_api.dart @@ -0,0 +1,54 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/network/api_exception.dart'; +import '../core/network/dio_client.dart'; +import '../models/account.dart'; + +final accountApiProvider = Provider((ref) => AccountApi(ref.watch(dioProvider))); + +/// 账户接口(后端每个用户只有一个默认账户) +class AccountApi { + final Dio _dio; + + AccountApi(this._dio); + + /// 账户信息:余额、总收入、总支出(收支为全部账单汇总,只读) + Future getAccount() async { + try { + final res = await _dio.get('/accounts'); + return AccountInfo.fromJson(_parseObject(res.data)); + } on DioException catch (e) { + throw _unwrap(e); + } + } + + /// 更新余额。 + /// + /// 注意:后端这个接口写入的是**期初余额**,传入前需用 + /// `initialBalanceFor()` 把目标余额换算成期初余额。 + Future updateBalance(double initialBalance) async { + try { + final res = await _dio.put('/accounts/balance', data: { + 'initialBalance': initialBalance, + }); + return AccountInfo.fromJson(_parseObject(res.data)); + } on DioException catch (e) { + throw _unwrap(e); + } + } + + Map _parseObject(Object? data) { + if (data is Map) return data; + if (data is Map && data['data'] is Map) { + return Map.from(data['data'] as Map); + } + throw ApiException(-1, '账户响应格式异常'); + } + + Never _unwrap(DioException e) { + final err = e.error; + if (err is ApiException) throw err; + throw ApiException(-1, '账户操作失败'); + } +} diff --git a/lib/api/auth_api.dart b/lib/api/auth_api.dart new file mode 100644 index 0000000..8ad2698 --- /dev/null +++ b/lib/api/auth_api.dart @@ -0,0 +1,52 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/network/api_exception.dart'; +import '../core/network/dio_client.dart'; +import '../models/auth_response.dart'; + +final authApiProvider = Provider((ref) => AuthApi(ref.watch(dioProvider))); + +class AuthApi { + final Dio _dio; + + AuthApi(this._dio); + + /// 登录 → {token, username, nickname} + Future login(String username, String password) async { + try { + final res = await _dio.post('/auth/login', data: { + 'username': username, + 'password': password, + }); + return AuthResponse.fromJson(_map(res.data)); + } on DioException catch (e) { + throw _unwrap(e); + } + } + + /// 注册 → {token, username, nickname} + Future register(String username, String password, String nickname) async { + try { + final res = await _dio.post('/auth/register', data: { + 'username': username, + 'password': password, + 'nickname': nickname, + }); + return AuthResponse.fromJson(_map(res.data)); + } on DioException catch (e) { + throw _unwrap(e); + } + } + + Map _map(Object? data) { + if (data is Map) return data; + throw ApiException(-1, '响应格式异常'); + } + + Never _unwrap(DioException e) { + final err = e.error; + if (err is ApiException) throw err; + throw ApiException(-1, '请求失败'); + } +} diff --git a/lib/api/bill_api.dart b/lib/api/bill_api.dart new file mode 100644 index 0000000..a2ad9f9 --- /dev/null +++ b/lib/api/bill_api.dart @@ -0,0 +1,104 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/network/api_exception.dart'; +import '../core/network/dio_client.dart'; +import '../models/bill.dart'; +import '../models/bill_request.dart'; + +final billApiProvider = Provider((ref) => BillApi(ref.watch(dioProvider))); + +class BillApi { + final Dio _dio; + + BillApi(this._dio); + + /// 账单列表,可选日期区间(yyyy-MM-dd)。 + /// 后端返回裸数组,无分页。 + Future> getBills({String? startDate, String? endDate}) async { + try { + final params = {}; + if (startDate != null) params['startDate'] = startDate; + if (endDate != null) params['endDate'] = endDate; + final res = await _dio.get('/bills', queryParameters: params); + return _parseList(res.data).map(Bill.fromJson).toList(); + } on DioException catch (e) { + throw _unwrap(e); + } + } + + /// 账单详情 + Future getBill(int id) async { + try { + final res = await _dio.get('/bills/$id'); + return Bill.fromJson(_parseObject(res.data)); + } on DioException catch (e) { + throw _unwrap(e); + } + } + + /// 创建账单 + Future createBill(BillRequest bill) async { + try { + await _dio.post('/bills', data: bill.toJson()); + } on DioException catch (e) { + throw _unwrap(e); + } + } + + /// 批量创建账单(OCR 直接存储) + Future createBillsBatch(List bills) async { + try { + await _dio.post('/bills/batch', data: { + 'bills': bills.map((b) => b.toJson()).toList(), + }); + } on DioException catch (e) { + throw _unwrap(e); + } + } + + /// 更新账单 + /// + /// 注意:后端对 description 为 null 会覆盖成 null,所以必须回传全部字段 + /// (BillRequest.toJson 不含 imageUrl,后端按「null 保留原值」处理,正好保留原图)。 + Future updateBill(int id, BillRequest bill) async { + try { + final res = await _dio.put('/bills/$id', data: bill.toJson()); + return Bill.fromJson(_parseObject(res.data)); + } on DioException catch (e) { + throw _unwrap(e); + } + } + + /// 删除账单(后端为物理删除,不可恢复) + Future deleteBill(int id) async { + try { + await _dio.delete('/bills/$id'); + } on DioException catch (e) { + throw _unwrap(e); + } + } + + /// 兼容裸数组与 {code,data:[...]} 两种形态 + List> _parseList(Object? data) { + if (data is List) { + return data.whereType>().toList(); + } + if (data is Map && data['data'] is List) { + return (data['data'] as List).whereType>().toList(); + } + throw ApiException(-1, '响应格式异常'); + } + + Map _parseObject(Object? data) { + if (data is Map) return data; + if (data is Map && data['data'] is Map) return Map.from(data['data'] as Map); + throw ApiException(-1, '响应格式异常'); + } + + Never _unwrap(DioException e) { + final err = e.error; + if (err is ApiException) throw err; + throw ApiException(-1, '请求失败'); + } +} diff --git a/lib/api/budget_api.dart b/lib/api/budget_api.dart new file mode 100644 index 0000000..2a26485 --- /dev/null +++ b/lib/api/budget_api.dart @@ -0,0 +1,50 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/network/api_exception.dart'; +import '../core/network/dio_client.dart'; +import '../models/budget.dart'; + +final budgetApiProvider = Provider((ref) => BudgetApi(ref.watch(dioProvider))); + +/// 预算接口(后端只提供当月预算) +class BudgetApi { + final Dio _dio; + + BudgetApi(this._dio); + + /// 获取当月预算。 + /// + /// 未设置预算时后端可能返回 204 / 空 body,此时返回 [BudgetInfo.empty] + /// 而不是抛错——否则首页会因为"没设预算"而进入错误态。 + Future getBudget() async { + try { + final res = await _dio.get('/budgets'); + return _parse(res.data); + } on DioException catch (e) { + final err = e.error; + throw err is ApiException ? err : ApiException(-1, '预算加载失败'); + } + } + + /// 设置/修改当月预算(每月生效,可随时修改)。 + /// 后端自动填充年月,amount 必须 >= 0。 + Future setCurrentBudget(double amount) async { + try { + final res = await _dio.put('/budgets/current', data: {'amount': amount}); + return _parse(res.data); + } on DioException catch (e) { + final err = e.error; + throw err is ApiException ? err : ApiException(-1, '预算保存失败'); + } + } + + BudgetInfo _parse(Object? data) { + if (data == null) return BudgetInfo.empty; + if (data is Map) return BudgetInfo.fromJson(data); + if (data is Map && data['data'] is Map) { + return BudgetInfo.fromJson(Map.from(data['data'] as Map)); + } + return BudgetInfo.empty; + } +} diff --git a/lib/api/category_api.dart b/lib/api/category_api.dart new file mode 100644 index 0000000..510b416 --- /dev/null +++ b/lib/api/category_api.dart @@ -0,0 +1,34 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/network/api_exception.dart'; +import '../core/network/dio_client.dart'; +import '../models/category.dart'; + +final categoryApiProvider = Provider((ref) => CategoryApi(ref.watch(dioProvider))); + +class CategoryApi { + final Dio _dio; + + CategoryApi(this._dio); + + /// 获取分类列表,type: 1=支出 2=收入(后端可能返回裸数组或 {code,data}) + Future> getCategories(int type) async { + try { + final res = await _dio.get('/categories', queryParameters: {'type': type}); + return _parseList(res.data) + .whereType>() + .map(Category.fromJson) + .toList(); + } on DioException catch (e) { + final err = e.error; + throw err is ApiException ? err : ApiException(-1, '请求失败'); + } + } + + List _parseList(Object? data) { + if (data is List) return data; + if (data is Map && data['data'] is List) return data['data'] as List; + throw ApiException(-1, '分类响应格式异常'); + } +} diff --git a/lib/api/ocr_api.dart b/lib/api/ocr_api.dart new file mode 100644 index 0000000..77bc806 --- /dev/null +++ b/lib/api/ocr_api.dart @@ -0,0 +1,41 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/network/api_exception.dart'; +import '../core/network/dio_client.dart'; +import '../models/ocr_result.dart'; + +final ocrApiProvider = Provider((ref) => OcrApi(ref.watch(dioProvider))); + +class OcrApi { + final Dio _dio; + + OcrApi(this._dio); + + /// 上传图片识别账单,字段名固定为 file(与 uniApp upload 对齐)。 + /// 后端可能返回裸数组或 {code, data:[...]},两种都兼容。 + Future> recognize(String filePath) async { + try { + final formData = FormData.fromMap({ + 'file': await MultipartFile.fromFile(filePath, filename: 'receipt.jpg'), + }); + final res = await _dio.post('/ocr/recognize', data: formData); + final data = res.data; + List list; + if (data is List) { + list = data; + } else if (data is Map && data['data'] is List) { + list = data['data'] as List; + } else { + throw ApiException(-1, '未识别到账单信息'); + } + return list + .whereType>() + .map(OcrResult.fromJson) + .toList(); + } on DioException catch (e) { + final err = e.error; + throw err is ApiException ? err : ApiException(-1, '识别失败,请重试'); + } + } +} diff --git a/lib/api/statistics_api.dart b/lib/api/statistics_api.dart new file mode 100644 index 0000000..963e6d9 --- /dev/null +++ b/lib/api/statistics_api.dart @@ -0,0 +1,56 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/network/api_exception.dart'; +import '../core/network/dio_client.dart'; +import '../models/statistics_response.dart'; + +final statisticsApiProvider = + Provider((ref) => StatisticsApi(ref.watch(dioProvider))); + +/// 统计接口。 +/// +/// 注意后端三个接口实际都委托给同一段按日聚合逻辑,区别只在参数: +/// daily 传日期区间,weekly 传周一,monthly 传年月。 +class StatisticsApi { + final Dio _dio; + + StatisticsApi(this._dio); + + /// 按日期区间统计(yyyy-MM-dd) + Future getDaily({ + required String startDate, + required String endDate, + }) async { + return _request('/statistics/daily', { + 'startDate': startDate, + 'endDate': endDate, + }); + } + + /// 按周统计,weekStart 为本周一(后端自行算到周内其余日期) + Future getWeekly({required String weekStart}) async { + return _request('/statistics/weekly', {'weekStart': weekStart}); + } + + /// 按月统计 + Future getMonthly({required int year, required int month}) async { + return _request('/statistics/monthly', {'year': year, 'month': month}); + } + + Future _request(String path, Map params) async { + try { + final res = await _dio.get(path, queryParameters: params); + final data = res.data; + if (data is Map) return StatisticsResponse.fromJson(data); + if (data is Map && data['data'] is Map) { + return StatisticsResponse.fromJson(Map.from(data['data'] as Map)); + } + // 空响应(无数据)按空统计处理,不视为错误 + return StatisticsResponse.empty; + } on DioException catch (e) { + final err = e.error; + throw err is ApiException ? err : ApiException(-1, '统计加载失败'); + } + } +} diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000..b8dc6f0 --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'core/navigation/navigator_key.dart'; +import 'pages/login/login_page.dart'; +import 'pages/shell/main_shell.dart'; +import 'providers/auth_provider.dart'; + +class QiangbaoApp extends ConsumerWidget { + const QiangbaoApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final auth = ref.watch(authProvider); + return MaterialApp( + title: '强宝爱记账', + navigatorKey: navigatorKey, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFFC8956E), + primary: const Color(0xFFC8956E), + ), + scaffoldBackgroundColor: const Color(0xFFF8F8F8), + appBarTheme: const AppBarTheme( + backgroundColor: Color(0xFFF8F8F8), + foregroundColor: Color(0xFF5D4037), + elevation: 0, + ), + ), + home: auth.isLoggedIn ? const MainShell() : const LoginPage(), + ); + } +} diff --git a/lib/core/navigation/navigator_key.dart b/lib/core/navigation/navigator_key.dart new file mode 100644 index 0000000..a273d84 --- /dev/null +++ b/lib/core/navigation/navigator_key.dart @@ -0,0 +1,7 @@ +import 'package:flutter/material.dart'; + +/// 全局 navigatorKey +/// +/// 供没有 BuildContext 的地方使用(网络层 401/403 跳转、全局 toast)。 +/// 独立成文件是为了避免 app.dart ↔ 网络层 之间的循环依赖。 +final GlobalKey navigatorKey = GlobalKey(); diff --git a/lib/core/network/api_env.dart b/lib/core/network/api_env.dart new file mode 100644 index 0000000..7d95408 --- /dev/null +++ b/lib/core/network/api_env.dart @@ -0,0 +1,31 @@ +import 'package:flutter/foundation.dart'; + +/// API 环境配置 +/// +/// ## 开发环境(按平台自动选择) +/// - **Android 真机/模拟器**:`http://localhost:12345/api` +/// 需要先做端口转发,把手机的 localhost 映射到电脑的 localhost: +/// ``` +/// adb reverse tcp:12345 tcp:12345 # 每次插拔设备后需重新执行 +/// adb reverse --list # 确认映射存在 +/// ``` +/// 好处:不受 Windows 防火墙影响,也不要求手机与电脑在同一 Wi-Fi。 +/// 若不想用 adb reverse(仅模拟器可行),可用电脑局域网 IP 覆盖: +/// `flutter run --dart-define=API_BASE_URL=http://192.168.x.x:12345/api` +/// (模拟器也可直接用固定地址 `10.0.2.2:12345`) +/// - **Windows / Chrome / iOS 模拟器**:`http://localhost:12345/api` +/// +/// ## 生产环境 +/// HTTPS,无需关心明文流量问题。 +const String kProdBaseUrl = 'https://accounting.aqroid.cn/api'; +const String kLocalBaseUrl = 'http://localhost:12345/api'; + +/// 手动覆盖地址(优先级最高): +/// `flutter run --dart-define=API_BASE_URL=http://:12345/api` +const String _override = String.fromEnvironment('API_BASE_URL'); + +String get baseUrl { + if (_override.isNotEmpty) return _override; + if (!kDebugMode) return kProdBaseUrl; + return kLocalBaseUrl; +} diff --git a/lib/core/network/api_exception.dart b/lib/core/network/api_exception.dart new file mode 100644 index 0000000..00fb2a0 --- /dev/null +++ b/lib/core/network/api_exception.dart @@ -0,0 +1,16 @@ +/// 统一 API 异常(对齐 uniApp request.js 的错误语义) +class ApiException implements Exception { + final int code; + final String message; + + ApiException(this.code, this.message); + + @override + String toString() => 'ApiException($code, $message)'; +} + +/// 从任意抛出对象中提取 ApiException(供 UI/Provider 捕获使用) +ApiException asApiException(Object error) { + if (error is ApiException) return error; + return ApiException(-1, error.toString()); +} diff --git a/lib/core/network/auth_interceptor.dart b/lib/core/network/auth_interceptor.dart new file mode 100644 index 0000000..b744e55 --- /dev/null +++ b/lib/core/network/auth_interceptor.dart @@ -0,0 +1,15 @@ +import 'package:dio/dio.dart'; + +import '../storage/local_storage.dart'; + +/// 请求拦截器:注入 Bearer token(对齐 uniApp request.js 的 header 逻辑) +class AuthInterceptor extends Interceptor { + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + final token = LocalStorage.instance.getToken(); + if (token != null && token.isNotEmpty) { + options.headers['Authorization'] = 'Bearer $token'; + } + handler.next(options); + } +} diff --git a/lib/core/network/auth_redirect.dart b/lib/core/network/auth_redirect.dart new file mode 100644 index 0000000..5fea17a --- /dev/null +++ b/lib/core/network/auth_redirect.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/navigation/navigator_key.dart'; +import '../../pages/login/login_page.dart'; +import '../../providers/auth_provider.dart'; + +/// 认证失效处理桥接:网络层(无 Riverpod 上下文)通过此模块 +/// 调用 authProvider.logout 并跳转登录页。 +ProviderContainer? _container; + +void configureAuthRedirect(ProviderContainer container) { + _container = container; +} + +/// 主动退出登录(用户在「我的」页面点击) +Future logoutAndGoLogin() async { + await _container?.read(authProvider.notifier).logout(); + _goLoginPage(); +} + +/// 登录已失效(token 过期/无效):清本地登录态并跳登录页,给出提示 +Future forceLogout({String reason = '登录已过期,请重新登录'}) async { + await _container?.read(authProvider.notifier).logout(); + _goLoginPage(); + _showTip(reason); +} + +void _goLoginPage() { + final nav = navigatorKey.currentState; + if (nav == null) return; + nav.pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const LoginPage()), + (route) => false, + ); +} + +void _showTip(String reason) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final ctx = navigatorKey.currentContext; + if (ctx == null) return; + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar( + content: Text(reason), + backgroundColor: const Color(0xFFB71C1C), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 3), + ), + ); + }); +} diff --git a/lib/core/network/dio_client.dart b/lib/core/network/dio_client.dart new file mode 100644 index 0000000..fdba7c9 --- /dev/null +++ b/lib/core/network/dio_client.dart @@ -0,0 +1,20 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'api_env.dart'; +import 'auth_interceptor.dart'; +import 'error_interceptor.dart'; + +/// 全局 Dio 实例 +final dioProvider = Provider((ref) { + final dio = Dio(BaseOptions( + baseUrl: baseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), + )); + dio.interceptors.addAll([ + AuthInterceptor(), + ErrorInterceptor(), + ]); + return dio; +}); diff --git a/lib/core/network/error_interceptor.dart b/lib/core/network/error_interceptor.dart new file mode 100644 index 0000000..7b48455 --- /dev/null +++ b/lib/core/network/error_interceptor.dart @@ -0,0 +1,108 @@ +import 'package:dio/dio.dart'; + +import 'api_exception.dart'; +import 'auth_redirect.dart'; + +/// 统一响应处理:业务 code 解包、登录失效跳转、错误映射为 ApiException。 +/// +/// dio 默认 validateStatus(<400) 下:HTTP 2xx → onResponse(解包业务 code), +/// HTTP 4xx/5xx → onError(提取 message / 处理认证失效)。 +/// +/// ## 关于「登录失效」的判定(重要) +/// 后端 `SecurityConfig` 未配置 authenticationEntryPoint,Spring Security 6 +/// 默认用 Http403ForbiddenEntryPoint,因此 **token 缺失/过期时返回的是 403 而非 401**。 +/// 所以这里两种都要处理: +/// - 401:标准未授权(后端以后改对了也兼容) +/// - 403:仅当请求的是需要鉴权的接口(非 permitAll 路径)时才算登录失效 +/// +/// permitAll 路径(见后端 SecurityConfig):`/auth/**`、`/gold-price/**`。 +/// 这些接口的 403 属于服务端错误(例如登录时数据库连不上),绝不能当成过期—— +/// 否则会把用户无故踢下线。 +class ErrorInterceptor extends Interceptor { + bool _redirecting = false; + + @override + void onResponse(Response response, ResponseInterceptorHandler handler) { + final data = response.data; + // body.code == 200 或不存在 code 字段视为成功(与 uniApp request.js 一致) + if (data is Map && data['code'] != null && data['code'] != 200) { + final code = data['code'] is int ? data['code'] as int : -1; + final message = data['message']?.toString() ?? '请求失败'; + if (code == 401) { + _handleAuthFailure(); + handler.reject(_dioException(response.requestOptions, ApiException(401, message))); + return; + } + handler.reject(_dioException(response.requestOptions, ApiException(code, message))); + return; + } + handler.next(response); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + // 已包装为 ApiException 的直接透传 + if (err.error is ApiException) { + handler.next(err); + return; + } + + final status = err.response?.statusCode; + final path = err.response?.requestOptions.path ?? err.requestOptions.path; + + // 401:标准未授权 → 视为登录失效 + if (status == 401) { + _handleAuthFailure(); + handler.next(err.copyWith(error: ApiException(401, '登录已过期,请重新登录'))); + return; + } + + // 403:本后端未认证时的兜底状态码 → 受保护接口的 403 视为登录失效 + if (status == 403 && !_isPermitAllPath(path)) { + _handleAuthFailure(); + handler.next(err.copyWith(error: ApiException(403, '登录已过期,请重新登录'))); + return; + } + + // HTTP 4xx:尽量提取后端返回的 message(含 permitAll 接口的真实服务端错误) + final data = err.response?.data; + if (data is Map && data['message'] != null) { + handler.next(err.copyWith(error: ApiException(-1, data['message'].toString()))); + return; + } + // 403 且无 message:多为 Spring Security 兜底(空响应体),给出可读提示 + if (status == 403) { + handler.next(err.copyWith(error: ApiException(403, '请求被拒绝(403),请查看后端日志'))); + return; + } + // 网络错误等统一映射 + final message = switch (err.type) { + DioExceptionType.connectionTimeout || + DioExceptionType.sendTimeout || + DioExceptionType.receiveTimeout => + '连接超时,请检查网络', + DioExceptionType.connectionError => '网络错误,请检查网络连接', + _ => '请求失败(${err.response?.statusCode ?? '无响应'})', + }; + handler.next(err.copyWith(error: ApiException(-1, message))); + } + + /// 后端 SecurityConfig 中 permitAll 的路径前缀,其 403 不代表登录失效 + bool _isPermitAllPath(String path) => + path.startsWith('/auth') || path.startsWith('/gold-price'); + + void _handleAuthFailure() { + if (_redirecting) return; + _redirecting = true; + Future(() async { + try { + await forceLogout(); + } finally { + Future.delayed(const Duration(seconds: 1), () => _redirecting = false); + } + }); + } + + DioException _dioException(RequestOptions options, ApiException error) => + DioException(requestOptions: options, error: error); +} diff --git a/lib/core/storage/local_storage.dart b/lib/core/storage/local_storage.dart new file mode 100644 index 0000000..cf254a5 --- /dev/null +++ b/lib/core/storage/local_storage.dart @@ -0,0 +1,73 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +/// 本地存储封装:token / userInfo / 记住密码 +/// (对齐 uniApp 的 uni.setStorageSync / getStorageSync 用法) +class LocalStorage { + LocalStorage._(); + + static final LocalStorage instance = LocalStorage._(); + + SharedPreferences? _prefs; + + /// App 启动时调用一次,确保后续同步读取可用 + Future init() async { + _prefs ??= await SharedPreferences.getInstance(); + } + + Future _getPrefs() async { + return _prefs ??= await SharedPreferences.getInstance(); + } + + // ---- 认证 ---- + static const _keyToken = 'token'; + static const _keyUserInfo = 'userInfo'; + + Future setAuth({required String token, required Map userInfo}) async { + final prefs = await _getPrefs(); + await prefs.setString(_keyToken, token); + await prefs.setString(_keyUserInfo, jsonEncode(userInfo)); + } + + Future clearAuth() async { + final prefs = await _getPrefs(); + await prefs.remove(_keyToken); + await prefs.remove(_keyUserInfo); + } + + String? getToken() => _prefs?.getString(_keyToken); + + Map? getUserInfo() { + final raw = _prefs?.getString(_keyUserInfo); + if (raw == null) return null; + try { + return jsonDecode(raw) as Map; + } catch (_) { + return null; + } + } + + // ---- 记住密码 ---- + static const _keySavedUsername = 'saved_username'; + static const _keySavedPassword = 'saved_password'; + + Future saveRememberedPassword(String username, String encryptedPassword) async { + final prefs = await _getPrefs(); + await prefs.setString(_keySavedUsername, username); + await prefs.setString(_keySavedPassword, encryptedPassword); + } + + Future clearRememberedPassword() async { + final prefs = await _getPrefs(); + await prefs.remove(_keySavedUsername); + await prefs.remove(_keySavedPassword); + } + + ({String username, String password})? getRememberedAccount() { + final username = _prefs?.getString(_keySavedUsername); + final password = _prefs?.getString(_keySavedPassword); + if (username == null || password == null) return null; + return (username: username, password: password); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..f594b82 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'app.dart'; +import 'core/network/auth_redirect.dart'; +import 'core/storage/local_storage.dart'; +import 'providers/auth_provider.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await LocalStorage.instance.init(); + final container = ProviderContainer(); + // 401 过期处理桥接(网络层 → authProvider + navigatorKey) + configureAuthRedirect(container); + // 启动时先读取持久化的登录态,避免首屏闪跳 + await container.read(authProvider.notifier).load(); + runApp( + UncontrolledProviderScope( + container: container, + child: const QiangbaoApp(), + ), + ); +} diff --git a/lib/models/account.dart b/lib/models/account.dart new file mode 100644 index 0000000..cffc206 --- /dev/null +++ b/lib/models/account.dart @@ -0,0 +1,63 @@ +import 'json_util.dart'; + +/// 账户信息(对应后端 AccountResponse) +/// +/// 语义说明(重要): +/// - [balance] 是后端计算出来的:`initialBalance + totalIncome - totalExpense` +/// - [initialBalance] 才是可写入数据库的字段(后端 PUT /accounts/balance 改的是它) +/// - [totalIncome] / [totalExpense] 是**全部账单**的汇总,只读 +class AccountInfo { + final int? id; + final String name; + final double initialBalance; + final double balance; + final double totalIncome; + final double totalExpense; + + const AccountInfo({ + this.id, + this.name = '默认账户', + required this.initialBalance, + required this.balance, + required this.totalIncome, + required this.totalExpense, + }); + + factory AccountInfo.fromJson(Map json) { + final initial = asDouble(json['initialBalance']); + final income = asDouble(json['totalIncome']); + final expense = asDouble(json['totalExpense']); + return AccountInfo( + id: json['id'] == null ? null : asInt(json['id']), + name: asNonEmpty(json['name'], '默认账户'), + initialBalance: initial, + // 后端没给 balance 时本地按同一公式兜底 + balance: json['balance'] == null + ? initial + income - expense + : asDouble(json['balance']), + totalIncome: income, + totalExpense: expense, + ); + } + + static const empty = AccountInfo( + initialBalance: 0, + balance: 0, + totalIncome: 0, + totalExpense: 0, + ); +} + +/// 把"用户想要的目标余额"反算成后端可写入的期初余额。 +/// +/// 因为 balance = initialBalance + totalIncome - totalExpense, +/// 所以要让余额显示为 target,期初余额必须设为: +/// `target - totalIncome + totalExpense` +double initialBalanceFor({ + required double targetBalance, + required double totalIncome, + required double totalExpense, +}) { + if (!targetBalance.isFinite) return 0; + return targetBalance - totalIncome + totalExpense; +} diff --git a/lib/models/auth_response.dart b/lib/models/auth_response.dart new file mode 100644 index 0000000..2026449 --- /dev/null +++ b/lib/models/auth_response.dart @@ -0,0 +1,29 @@ +class AuthResponse { + final String token; + final String? username; + final String? nickname; + + const AuthResponse({required this.token, this.username, this.nickname}); + + factory AuthResponse.fromJson(Map json) => AuthResponse( + token: json['token']?.toString() ?? '', + username: json['username']?.toString(), + nickname: json['nickname']?.toString(), + ); +} + +class UserInfo { + final String? id; + final String username; + final String nickname; + + const UserInfo({this.id, required this.username, required this.nickname}); + + Map toJson() => {'id': id, 'username': username, 'nickname': nickname}; + + factory UserInfo.fromJson(Map json) => UserInfo( + id: json['id']?.toString(), + username: json['username']?.toString() ?? '', + nickname: json['nickname']?.toString() ?? '', + ); +} diff --git a/lib/models/bill.dart b/lib/models/bill.dart new file mode 100644 index 0000000..14bb020 --- /dev/null +++ b/lib/models/bill.dart @@ -0,0 +1,52 @@ +import '../utils/date_util.dart'; +import 'json_util.dart'; + +/// 账单(对应后端 BillResponse) +/// +/// 注意:amount 后端恒返回正数,收支方向看 [type]。 +class Bill { + final int id; + final int categoryId; + final String categoryName; + final String categoryIcon; + final double amount; + final String description; + final String billDate; // yyyy-MM-dd + final String? imageUrl; + final int type; // 1=支出 2=收入 + final String? createTime; + final String? updateTime; + + const Bill({ + required this.id, + required this.categoryId, + required this.categoryName, + required this.categoryIcon, + required this.amount, + required this.description, + required this.billDate, + this.imageUrl, + required this.type, + this.createTime, + this.updateTime, + }); + + bool get isExpense => type == 1; + + double get absAmount => amount.abs(); + + factory Bill.fromJson(Map json) => Bill( + id: asInt(json['id']), + categoryId: asInt(json['categoryId']), + categoryName: asNonEmpty(json['categoryName'], '未分类'), + categoryIcon: asNonEmpty(json['categoryIcon'], '📦'), + amount: asDouble(json['amount']), + description: asString(json['description']), + billDate: parseBillDate(asString(json['billDate'])), + imageUrl: json['imageUrl'] == null ? null : asString(json['imageUrl']), + // 后端可能返回 null,缺省按支出处理 + type: asInt(json['type'], fallback: 1) == 2 ? 2 : 1, + createTime: json['createTime'] == null ? null : asString(json['createTime']), + updateTime: json['updateTime'] == null ? null : asString(json['updateTime']), + ); +} diff --git a/lib/models/bill_request.dart b/lib/models/bill_request.dart new file mode 100644 index 0000000..89a388e --- /dev/null +++ b/lib/models/bill_request.dart @@ -0,0 +1,24 @@ +/// 账单创建/更新请求体 +class BillRequest { + final int categoryId; + final double amount; + final String description; + final String billDate; // yyyy-MM-dd + final int type; // 1=支出,2=收入 + + const BillRequest({ + required this.categoryId, + required this.amount, + this.description = '', + required this.billDate, + required this.type, + }); + + Map toJson() => { + 'categoryId': categoryId, + 'amount': amount, + 'description': description, + 'billDate': billDate, + 'type': type, + }; +} diff --git a/lib/models/budget.dart b/lib/models/budget.dart new file mode 100644 index 0000000..2c1a0ea --- /dev/null +++ b/lib/models/budget.dart @@ -0,0 +1,54 @@ +import 'json_util.dart'; + +/// 预算信息(对应后端 BudgetResponse,仅当月有效) +class BudgetInfo { + final int? id; + final int? year; + final int? month; + final double amount; // 预算总额 + final double usedAmount; // 已用 + final double remainingAmount; // 剩余 + final double remainingDaily; // 日均可用 + + const BudgetInfo({ + this.id, + this.year, + this.month, + required this.amount, + required this.usedAmount, + required this.remainingAmount, + required this.remainingDaily, + }); + + bool get hasBudget => amount > 0; + + bool get isOverBudget => amount > 0 && usedAmount > amount; + + /// 已用占比(0~1),未设预算时为 0 + double get usedRatio { + if (amount <= 0 || !usedAmount.isFinite) return 0; + return (usedAmount / amount).clamp(0.0, 1.0); + } + + factory BudgetInfo.fromJson(Map json) { + final amount = asDouble(json['amount']); + final used = asDouble(json['usedAmount']); + return BudgetInfo( + id: json['id'] == null ? null : asInt(json['id']), + year: json['year'] == null ? null : asInt(json['year']), + month: json['month'] == null ? null : asInt(json['month']), + amount: amount, + usedAmount: used, + remainingAmount: + json['remainingAmount'] == null ? amount - used : asDouble(json['remainingAmount']), + remainingDaily: asDouble(json['remainingDaily']), + ); + } + + static const empty = BudgetInfo( + amount: 0, + usedAmount: 0, + remainingAmount: 0, + remainingDaily: 0, + ); +} diff --git a/lib/models/category.dart b/lib/models/category.dart new file mode 100644 index 0000000..a0505c7 --- /dev/null +++ b/lib/models/category.dart @@ -0,0 +1,24 @@ +/// 分类(type: 1=支出,2=收入) +class Category { + final int id; + final String name; + final String icon; // emoji + final int type; + final int? sortOrder; + + const Category({ + required this.id, + required this.name, + required this.icon, + required this.type, + this.sortOrder, + }); + + factory Category.fromJson(Map json) => Category( + id: (json['id'] as num?)?.toInt() ?? 0, + name: json['name']?.toString() ?? '', + icon: json['icon']?.toString() ?? '📦', + type: (json['type'] as num?)?.toInt() ?? 1, + sortOrder: (json['sortOrder'] as num?)?.toInt(), + ); +} diff --git a/lib/models/json_util.dart b/lib/models/json_util.dart new file mode 100644 index 0000000..5a0bf1e --- /dev/null +++ b/lib/models/json_util.dart @@ -0,0 +1,36 @@ +/// 后端字段的防御式取值工具 +/// +/// 后端没有统一的响应包装,字段类型也可能与预期不符(例如金额返回字符串、 +/// 数组返回 null)。所有 fromJson 都走这里取值,保证解析绝不抛异常。 +library; + +int asInt(Object? value, {int fallback = 0}) { + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value) ?? fallback; + return fallback; +} + +double asDouble(Object? value, {double fallback = 0}) { + if (value is double) return value; + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value) ?? fallback; + return fallback; +} + +String asString(Object? value, {String fallback = ''}) { + if (value == null) return fallback; + return value.toString(); +} + +/// 取字符串,空串时回退(用于分类名/图标这类必须有值的展示字段) +String asNonEmpty(Object? value, String fallback) { + final text = asString(value); + return text.isEmpty ? fallback : text; +} + +/// 取对象数组,非 Map 的元素直接丢弃 +List> asMapList(Object? value) { + if (value is! List) return const []; + return value.whereType>().toList(); +} diff --git a/lib/models/ocr_result.dart b/lib/models/ocr_result.dart new file mode 100644 index 0000000..3d549ca --- /dev/null +++ b/lib/models/ocr_result.dart @@ -0,0 +1,25 @@ +/// OCR 识别出的单条账单 +class OcrResult { + final double amount; // 负数=支出,正数=收入(与 uniApp 语义一致) + final String? merchant; + final String? date; + final double? confidence; + + const OcrResult({ + required this.amount, + this.merchant, + this.date, + this.confidence, + }); + + bool get isExpense => amount < 0; + + double get absAmount => amount.abs(); + + factory OcrResult.fromJson(Map json) => OcrResult( + amount: (json['amount'] as num?)?.toDouble() ?? 0, + merchant: json['merchant']?.toString(), + date: json['date']?.toString(), + confidence: (json['confidence'] as num?)?.toDouble(), + ); +} diff --git a/lib/models/statistics_response.dart b/lib/models/statistics_response.dart new file mode 100644 index 0000000..1ce0b05 --- /dev/null +++ b/lib/models/statistics_response.dart @@ -0,0 +1,90 @@ +import 'json_util.dart'; + +/// 统计响应(对应后端 StatisticsResponse) +/// +/// 口径说明:后端的 totalIncome / totalExpense / categoryStatistics 是按 +/// **分类的 type** 判定收支的,而 /bills 列表是按 **账单的 type** 判定。 +/// 两者可能不一致——前端不做交叉校验,汇总/饼图一律以此接口为准。 +class StatisticsResponse { + final double totalIncome; + final double totalExpense; + final double balance; + final List categoryStatistics; + final List dailyStatistics; + + const StatisticsResponse({ + required this.totalIncome, + required this.totalExpense, + required this.balance, + required this.categoryStatistics, + required this.dailyStatistics, + }); + + bool get isEmpty => categoryStatistics.isEmpty && totalIncome == 0 && totalExpense == 0; + + factory StatisticsResponse.fromJson(Map json) { + final income = asDouble(json['totalIncome']); + final expense = asDouble(json['totalExpense']); + final balanceRaw = json['balance']; + return StatisticsResponse( + totalIncome: income, + totalExpense: expense, + // 后端没给 balance 时本地兜底计算 + balance: balanceRaw == null ? income - expense : asDouble(balanceRaw), + categoryStatistics: + asMapList(json['categoryStatistics']).map(CategoryStat.fromJson).toList(), + dailyStatistics: + asMapList(json['dailyStatistics']).map(DailyStat.fromJson).toList(), + ); + } + + static const empty = StatisticsResponse( + totalIncome: 0, + totalExpense: 0, + balance: 0, + categoryStatistics: [], + dailyStatistics: [], + ); +} + +/// 分类维度的统计项 +class CategoryStat { + final int categoryId; + final String categoryName; + final String categoryIcon; + final double amount; + final int type; // 1=支出 2=收入 + + const CategoryStat({ + required this.categoryId, + required this.categoryName, + required this.categoryIcon, + required this.amount, + required this.type, + }); + + bool get isExpense => type != 2; + + factory CategoryStat.fromJson(Map json) => CategoryStat( + categoryId: asInt(json['categoryId']), + categoryName: asNonEmpty(json['categoryName'], '未分类'), + categoryIcon: asNonEmpty(json['categoryIcon'], '📦'), + amount: asDouble(json['amount']), + type: asInt(json['type'], fallback: 1) == 2 ? 2 : 1, + ); +} + +/// 按日趋势项(本期统计页不画折线图,保留以便后续扩展) +class DailyStat { + final String date; // yyyy-MM-dd + final double income; + final double expense; + + const DailyStat({required this.date, required this.income, required this.expense}); + + factory DailyStat.fromJson(Map json) => DailyStat( + date: asString(json['date']), + income: asDouble(json['income']), + expense: asDouble(json['expense']), + ); +} diff --git a/lib/pages/add/add_page.dart b/lib/pages/add/add_page.dart new file mode 100644 index 0000000..a17c93d --- /dev/null +++ b/lib/pages/add/add_page.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'widgets/manual_entry_tab.dart'; +import 'widgets/ocr_tab.dart'; + +/// 记账页内部 Tab:0=手动记账 1=OCR识别 +class AddTabNotifier extends Notifier { + @override + int build() => 0; + + void set(int index) => state = index; +} + +final addTabProvider = NotifierProvider(AddTabNotifier.new); + +/// 记账页:手动记账 / OCR识别 双 Tab(复刻 uniApp pages/add/add.vue) +class AddPage extends ConsumerWidget { + const AddPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tabIndex = ref.watch(addTabProvider); + return Scaffold( + appBar: AppBar(title: const Text('添加账单')), + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8), Color(0xFFFFC08A)], + ), + ), + child: Column( + children: [ + const SizedBox(height: 12), + _buildTabs(tabIndex, ref), + Expanded( + child: tabIndex == 0 ? const ManualEntryTab() : const OcrTab(), + ), + ], + ), + ), + ); + } + + Widget _buildTabs(int tabIndex, WidgetRef ref) { + void setTab(int index) => ref.read(addTabProvider.notifier).set(index); + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFC8956E)), + ), + child: Row( + children: [ + _tab('手动记账', 0, tabIndex, setTab), + _tab('OCR识别', 1, tabIndex, setTab), + ], + ), + ); + } + + Widget _tab(String text, int index, int tabIndex, void Function(int) setTab) { + final active = tabIndex == index; + return Expanded( + child: GestureDetector( + onTap: () => setTab(index), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + gradient: active + ? const LinearGradient(colors: [Color(0xFFC8956E), Color(0xFFF5D59E)]) + : null, + ), + child: Center( + child: Text( + text, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: active ? const Color(0xFF5D4037) : const Color(0xFF8D6E63), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/add/widgets/manual_entry_tab.dart b/lib/pages/add/widgets/manual_entry_tab.dart new file mode 100644 index 0000000..a7c3bd5 --- /dev/null +++ b/lib/pages/add/widgets/manual_entry_tab.dart @@ -0,0 +1,247 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/network/api_exception.dart'; +import '../../../providers/add_bill_provider.dart'; +import '../../../providers/category_provider.dart'; +import '../../../utils/amount_input_formatter.dart'; +import '../../../utils/date_util.dart'; +import '../../../utils/toast.dart'; +import '../../../widgets/category_picker_grid.dart'; +import '../../../widgets/state_views.dart'; + +/// 手动记账表单(复刻 uniApp add.vue 手动 Tab) +class ManualEntryTab extends ConsumerStatefulWidget { + const ManualEntryTab({super.key}); + + @override + ConsumerState createState() => _ManualEntryTabState(); +} + +class _ManualEntryTabState extends ConsumerState { + late final TextEditingController _descriptionController = TextEditingController(); + late final TextEditingController _amountController = TextEditingController(); + + @override + void dispose() { + _descriptionController.dispose(); + _amountController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final form = ref.watch(addBillFormProvider); + final notifier = ref.read(addBillFormProvider.notifier); + + // OCR「选择此条」外部填充时,同步非受控输入框 + ref.listen(addBillFormProvider.select((s) => s.revision), (prev, next) { + if (prev == next) return; + final s = ref.read(addBillFormProvider); + _amountController.text = s.amount?.toStringAsFixed(2) ?? ''; + _descriptionController.text = s.description; + }); + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _sectionLabel('账单类型'), + _typeSelector(form, notifier), + const SizedBox(height: 16), + _sectionLabel('分类'), + _categoryGrid(form, notifier), + const SizedBox(height: 16), + _sectionLabel('金额'), + _amountField(form, notifier), + const SizedBox(height: 16), + _sectionLabel('日期'), + _dateField(context, form, notifier), + const SizedBox(height: 16), + _sectionLabel('备注'), + _descriptionField(form, notifier), + const SizedBox(height: 24), + _submitBtn(context, ref, form, notifier), + const SizedBox(height: 90), // 底部导航栏避让 + ], + ); + } + + Widget _sectionLabel(String text) => Padding( + padding: const EdgeInsets.only(bottom: 8, left: 4), + child: Text( + text, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)), + ), + ); + + // ---- 类型选择 ---- + Widget _typeSelector(AddBillFormState form, AddBillFormNotifier notifier) { + return Row( + children: [ + _typeCard('📤', '支出', 1, form, notifier), + const SizedBox(width: 10), + _typeCard('📥', '收入', 2, form, notifier), + ], + ); + } + + Widget _typeCard(String icon, String label, int type, AddBillFormState form, AddBillFormNotifier notifier) { + final active = form.type == type; + return Expanded( + child: GestureDetector( + onTap: () => notifier.setType(type), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: active ? Colors.white : Colors.white.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: active ? const Color(0xFFC8956E) : Colors.transparent, + width: 2, + ), + boxShadow: active + ? [BoxShadow(color: const Color(0x33C8956E), blurRadius: 8, offset: const Offset(0, 3))] + : null, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(icon, style: const TextStyle(fontSize: 20)), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: active ? const Color(0xFF5D4037) : const Color(0xFF8D6E63), + ), + ), + ], + ), + ), + ), + ); + } + + // ---- 分类宫格 ---- + Widget _categoryGrid(AddBillFormState form, AddBillFormNotifier notifier) { + return Consumer(builder: (context, ref, _) { + final categoriesAsync = ref.watch(categoriesProvider(form.type)); + return categoriesAsync.when( + loading: () => const LoadingView(padding: EdgeInsets.all(20)), + error: (e, _) => ErrorView( + message: '分类加载失败:${asApiException(e).message}', + onRetry: () => ref.invalidate(categoriesProvider), + ), + data: (categories) => CategoryPickerGrid( + categories: categories, + selectedId: form.categoryId, + onSelect: notifier.setCategory, + crossAxisCount: 5, + ), + ); + }); + } + + // ---- 金额 ---- + Widget _amountField(AddBillFormState form, AddBillFormNotifier notifier) { + return TextField( + controller: _amountController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [AmountInputFormatter()], + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Color(0xFF5D4037)), + decoration: InputDecoration( + hintText: '请输入金额', + prefixText: '¥ ', + prefixStyle: const TextStyle(fontSize: 18, color: Color(0xFFC8956E), fontWeight: FontWeight.bold), + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(14), borderSide: BorderSide.none), + ), + onChanged: (v) => notifier.setAmount(double.tryParse(v)), + ); + } + + // ---- 日期 ---- + Widget _dateField(BuildContext context, AddBillFormState form, AddBillFormNotifier notifier) { + return GestureDetector( + onTap: () async { + final initial = DateTime.tryParse(form.billDate) ?? DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: initial, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (picked != null) notifier.setDate(formatDate(picked)); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Text(form.billDate, style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037))), + const Spacer(), + const Icon(Icons.chevron_right, color: Color(0xFF8D6E63)), + ], + ), + ), + ); + } + + // ---- 备注 ---- + Widget _descriptionField(AddBillFormState form, AddBillFormNotifier notifier) { + return TextField( + maxLines: 3, + maxLength: 200, + controller: _descriptionController, + style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037)), + decoration: InputDecoration( + hintText: '请输入备注(可选)', + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(14), borderSide: BorderSide.none), + ), + onChanged: notifier.setDescription, + ); + } + + // ---- 保存 ---- + Widget _submitBtn(BuildContext context, WidgetRef ref, AddBillFormState form, AddBillFormNotifier notifier) { + return SizedBox( + width: double.infinity, + height: 50, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFFC8956E), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(25)), + ), + onPressed: form.submitting + ? null + : () async { + try { + await notifier.submit(); + _amountController.clear(); + _descriptionController.clear(); + if (context.mounted) showToast(context, '✅ 记账成功'); + } on ApiException catch (e) { + if (context.mounted) showToast(context, e.message, error: true); + } catch (_) { + if (context.mounted) showToast(context, '保存失败,请重试', error: true); + } + }, + child: form.submitting + ? const SizedBox( + width: 22, height: 22, + child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white), + ) + : const Text('保 存', style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold, letterSpacing: 4)), + ), + ); + } +} diff --git a/lib/pages/add/widgets/ocr_result_card.dart b/lib/pages/add/widgets/ocr_result_card.dart new file mode 100644 index 0000000..e10e6b5 --- /dev/null +++ b/lib/pages/add/widgets/ocr_result_card.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/add_bill_provider.dart'; +import '../../../providers/ocr_provider.dart'; +import '../../../utils/date_util.dart'; +import '../add_page.dart'; + +/// 单条 OCR 识别结果卡片:可折叠详情 + 「选择此条」填充手动表单 +class OcrResultCard extends ConsumerWidget { + final int index; + final bool expanded; + final VoidCallback onToggle; + + const OcrResultCard({ + super.key, + required this.index, + required this.expanded, + required this.onToggle, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(ocrProvider); + if (index >= state.results.length) return const SizedBox.shrink(); + final result = state.results[index]; + final isExpense = result.isExpense; + + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFE0C9A6)), + ), + child: Column( + children: [ + // 头部:序号 + 金额 + 置信度 + 展开 + InkWell( + onTap: onToggle, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + Text( + '#${index + 1}', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Color(0xFF8D6E63)), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + '¥${result.absAmount.toStringAsFixed(2)}', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.bold, + color: isExpense ? const Color(0xFFEF5350) : const Color(0xFF66BB6A), + ), + ), + ), + if (result.confidence != null) + Padding( + padding: const EdgeInsets.only(right: 8), + child: Text( + '置信度 ${(result.confidence! * 100).toStringAsFixed(1)}%', + style: const TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)), + ), + ), + AnimatedRotation( + turns: expanded ? 0.5 : 0, + duration: const Duration(milliseconds: 200), + child: const Icon(Icons.expand_more, color: Color(0xFF8D6E63)), + ), + ], + ), + ), + ), + // 详情 + AnimatedCrossFade( + duration: const Duration(milliseconds: 200), + crossFadeState: expanded ? CrossFadeState.showSecond : CrossFadeState.showFirst, + firstChild: const SizedBox(width: double.infinity), + secondChild: Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(height: 1, color: Color(0xFFF0E6D8)), + const SizedBox(height: 8), + _detailRow('类型', isExpense ? '支出' : '收入'), + if (result.merchant != null && result.merchant!.isNotEmpty) + _detailRow('商户', result.merchant!), + if (result.date != null && result.date!.isNotEmpty) + _detailRow('日期', parseBillDate(result.date)), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + height: 38, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFFC8956E), + side: const BorderSide(color: Color(0xFFC8956E)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(19)), + ), + onPressed: () { + ref.read(addBillFormProvider.notifier).fillFromOcr(result); + // 切回手动记账 Tab 并清空 OCR 状态 + ref.read(addTabProvider.notifier).set(0); + ref.read(ocrProvider.notifier).clearImage(); + }, + child: const Text('选择此条', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _detailRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 42, + child: Text(label, style: const TextStyle(fontSize: 13, color: Color(0xFF8D6E63))), + ), + Expanded( + child: Text(value, style: const TextStyle(fontSize: 13, color: Color(0xFF5D4037))), + ), + ], + ), + ); + } +} diff --git a/lib/pages/add/widgets/ocr_tab.dart b/lib/pages/add/widgets/ocr_tab.dart new file mode 100644 index 0000000..a9187ce --- /dev/null +++ b/lib/pages/add/widgets/ocr_tab.dart @@ -0,0 +1,208 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../../../core/network/api_exception.dart'; +import '../../../providers/ocr_provider.dart'; +import '../../../utils/toast.dart'; +import 'ocr_result_card.dart'; + +/// OCR 识别 Tab:选图 → 识别 → 结果列表(复刻 uniApp add.vue OCR Tab) +class OcrTab extends ConsumerWidget { + const OcrTab({super.key}); + + Future _chooseImage(BuildContext context, WidgetRef ref, ImageSource source) async { + final picker = ImagePicker(); + final xfile = await picker.pickImage(source: source, imageQuality: 80, maxWidth: 1600); + if (xfile != null) { + ref.read(ocrProvider.notifier).setImagePath(xfile.path); + } + } + + Future _recognize(BuildContext context, WidgetRef ref) async { + try { + await ref.read(ocrProvider.notifier).recognize(); + final count = ref.read(ocrProvider).results.length; + if (context.mounted) { + showToast(context, count > 0 ? '识别到 $count 条记录' : '未识别到账单信息', error: count == 0); + } + } on ApiException catch (e) { + if (context.mounted) showToast(context, e.message, error: true); + } catch (_) { + if (context.mounted) showToast(context, '识别失败,请重试', error: true); + } + } + + Future _saveDirectly(BuildContext context, WidgetRef ref) async { + try { + await ref.read(ocrProvider.notifier).saveDirectly(); + if (context.mounted) showToast(context, '✅ 已保存识别的账单'); + } on ApiException catch (e) { + if (context.mounted) showToast(context, e.message, error: true); + } catch (_) { + if (context.mounted) showToast(context, '保存失败,请重试', error: true); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(ocrProvider); + final notifier = ref.read(ocrProvider.notifier); + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + // 图片预览 + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('图片预览', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037))), + const SizedBox(height: 10), + GestureDetector( + onTap: () => _showSourceSheet(context, ref), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: state.imagePath != null + ? SizedBox( + height: 220, + width: double.infinity, + child: Image.file( + File(state.imagePath!), + fit: BoxFit.contain, + ), + ) + : Container( + height: 140, + width: double.infinity, + color: const Color(0xFFF5F5F5), + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('📷', style: TextStyle(fontSize: 36)), + SizedBox(height: 6), + Text('点击选择图片', style: TextStyle(color: Color(0xFF8D6E63))), + ], + ), + ), + ), + ), + if (state.imagePath != null) + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: notifier.clearImage, + child: const Text('清除图片', style: TextStyle(color: Color(0xFF8D6E63))), + ), + ), + ], + ), + ), + const SizedBox(height: 14), + + // 识别按钮 + if (state.imagePath != null && state.results.isEmpty) + SizedBox( + width: double.infinity, + height: 48, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFFC8956E), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + ), + onPressed: state.recognizing ? null : () => _recognize(context, ref), + child: state.recognizing + ? const SizedBox( + width: 22, height: 22, + child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white), + ) + : const Text('识别账单', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + ), + + // 识别结果 + if (state.results.isNotEmpty) ...[ + SizedBox( + width: double.infinity, + height: 44, + child: FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF667eea), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)), + ), + onPressed: state.saving ? null : () => _saveDirectly(context, ref), + icon: state.saving + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.save_outlined, size: 18), + label: const Text('直接存储', style: TextStyle(fontWeight: FontWeight.bold)), + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '识别到 ${state.results.length} 条账单记录', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)), + ), + ), + const SizedBox(height: 10), + for (var i = 0; i < state.results.length; i++) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: OcrResultCard( + index: i, + expanded: state.expandedIndexes.contains(i), + onToggle: () => notifier.toggleExpanded(i), + ), + ), + ], + const SizedBox(height: 90), // 底部导航栏避让 + ], + ); + } + + void _showSourceSheet(BuildContext context, WidgetRef ref) { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetCtx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.camera_alt_outlined), + title: const Text('拍照'), + onTap: () { + Navigator.pop(sheetCtx); + _chooseImage(context, ref, ImageSource.camera); + }, + ), + ListTile( + leading: const Icon(Icons.photo_library_outlined), + title: const Text('从相册选择'), + onTap: () { + Navigator.pop(sheetCtx); + _chooseImage(context, ref, ImageSource.gallery); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/bill/bill_edit_page.dart b/lib/pages/bill/bill_edit_page.dart new file mode 100644 index 0000000..a1fec44 --- /dev/null +++ b/lib/pages/bill/bill_edit_page.dart @@ -0,0 +1,358 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/network/api_exception.dart'; +import '../../providers/bill_edit_provider.dart'; +import '../../providers/category_provider.dart'; +import '../../utils/amount_input_formatter.dart'; +import '../../utils/date_util.dart'; +import '../../utils/toast.dart'; +import '../../widgets/category_picker_grid.dart'; +import '../../widgets/state_views.dart'; + +/// 账单编辑页(复刻 uniApp pages/bill/detail.vue 的编辑能力,并补上删除) +class BillEditPage extends ConsumerStatefulWidget { + const BillEditPage({super.key, required this.billId}); + + final int billId; + + @override + ConsumerState createState() => _BillEditPageState(); +} + +class _BillEditPageState extends ConsumerState { + final _amountController = TextEditingController(); + final _descriptionController = TextEditingController(); + + @override + void dispose() { + _amountController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(billEditProvider(widget.billId)); + final notifier = ref.read(billEditProvider(widget.billId).notifier); + + // 服务端数据回填时同步非受控输入框(与记账页同一套 revision 机制) + ref.listen( + billEditProvider(widget.billId).select((s) => s.revision), + (prev, next) { + if (prev == next) return; + final s = ref.read(billEditProvider(widget.billId)); + _amountController.text = s.amount?.toStringAsFixed(2) ?? ''; + _descriptionController.text = s.description; + }, + ); + + return Scaffold( + appBar: AppBar(title: const Text('编辑账单')), + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8), Color(0xFFFFC08A)], + ), + ), + child: _body(state, notifier), + ), + ); + } + + Widget _body(BillEditState state, BillEditNotifier notifier) { + if (state.loading) { + return const Center(child: LoadingView(tip: '账单加载中...')); + } + if (state.loadError != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: ErrorView(message: state.loadError!, onRetry: notifier.reload), + ), + ); + } + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _sectionLabel('账单类型'), + _typeSelector(state, notifier), + const SizedBox(height: 16), + _sectionLabel('分类'), + _categorySection(state, notifier), + const SizedBox(height: 16), + _sectionLabel('金额'), + _amountField(state, notifier), + const SizedBox(height: 16), + _sectionLabel('日期'), + _dateField(state, notifier), + const SizedBox(height: 16), + _sectionLabel('备注'), + _descriptionField(notifier), + const SizedBox(height: 28), + _footer(state, notifier), + const SizedBox(height: 40), + ], + ); + } + + Widget _sectionLabel(String text) => Padding( + padding: const EdgeInsets.only(bottom: 8, left: 4), + child: Text( + text, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + ); + + // ---- 类型 ---- + Widget _typeSelector(BillEditState state, BillEditNotifier notifier) { + return Row( + children: [ + _typeCard('📤', '支出', 1, state, notifier), + const SizedBox(width: 10), + _typeCard('📥', '收入', 2, state, notifier), + ], + ); + } + + Widget _typeCard( + String icon, + String label, + int type, + BillEditState state, + BillEditNotifier notifier, + ) { + final active = state.type == type; + return Expanded( + child: GestureDetector( + onTap: () => notifier.setType(type), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: active ? Colors.white : Colors.white.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: active ? const Color(0xFFC8956E) : Colors.transparent, + width: 2, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(icon, style: const TextStyle(fontSize: 20)), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: active ? const Color(0xFF5D4037) : const Color(0xFF8D6E63), + ), + ), + ], + ), + ), + ), + ); + } + + // ---- 分类 ---- + Widget _categorySection(BillEditState state, BillEditNotifier notifier) { + return Consumer( + builder: (context, ref, _) { + final categoriesAsync = ref.watch(categoriesProvider(state.type)); + return categoriesAsync.when( + loading: () => const LoadingView(padding: EdgeInsets.all(20)), + error: (e, _) => ErrorView(message: asApiException(e).message), + data: (categories) => CategoryPickerGrid( + categories: categories, + selectedId: state.categoryId, + onSelect: notifier.setCategory, + crossAxisCount: 4, + ), + ); + }, + ); + } + + // ---- 金额 ---- + Widget _amountField(BillEditState state, BillEditNotifier notifier) { + return TextField( + controller: _amountController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [AmountInputFormatter()], + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Color(0xFF5D4037)), + decoration: InputDecoration( + hintText: '请输入金额', + prefixText: '¥ ', + prefixStyle: const TextStyle( + fontSize: 18, + color: Color(0xFFC8956E), + fontWeight: FontWeight.bold, + ), + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + ), + onChanged: (v) => notifier.setAmount(double.tryParse(v)), + ); + } + + // ---- 日期 ---- + Widget _dateField(BillEditState state, BillEditNotifier notifier) { + return GestureDetector( + onTap: () async { + final initial = DateTime.tryParse(state.billDate) ?? DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: initial, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (picked != null) notifier.setDate(formatDate(picked)); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Text(state.billDate, style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037))), + const Spacer(), + const Icon(Icons.chevron_right, color: Color(0xFF8D6E63)), + ], + ), + ), + ); + } + + // ---- 备注 ---- + Widget _descriptionField(BillEditNotifier notifier) { + return TextField( + controller: _descriptionController, + maxLines: 3, + maxLength: 200, + style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037)), + decoration: InputDecoration( + hintText: '请输入备注(可选)', + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + ), + onChanged: notifier.setDescription, + ); + } + + // ---- 底部:删除 + 保存 ---- + Widget _footer(BillEditState state, BillEditNotifier notifier) { + final busy = state.submitting; + return Row( + children: [ + Expanded( + child: SizedBox( + height: 48, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: const Color(0xFFD32F2F), + side: const BorderSide(color: Color(0xFFE0C9A6)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + ), + onPressed: busy ? null : () => _confirmDelete(notifier), + child: const Text('删除', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: SizedBox( + height: 48, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFFC8956E), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + ), + onPressed: busy ? null : () => _save(notifier), + child: busy + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white), + ) + : const Text( + '保 存', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, letterSpacing: 3), + ), + ), + ), + ), + ], + ); + } + + Future _save(BillEditNotifier notifier) async { + try { + await notifier.save(); + if (!mounted) return; + showToast(context, '✅ 已保存'); + Navigator.of(context).pop(); + } on ApiException catch (e) { + if (mounted) showToast(context, e.message, error: true); + } catch (_) { + if (mounted) showToast(context, '保存失败,请重试', error: true); + } + } + + Future _confirmDelete(BillEditNotifier notifier) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('确认删除'), + content: const Text('确定要删除这笔账单吗?删除后无法恢复'), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('取消', style: TextStyle(color: Color(0xFF8D6E63))), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('删除', style: TextStyle(color: Color(0xFFD32F2F))), + ), + ], + ), + ); + if (confirmed != true) return; + + try { + await notifier.remove(); + if (!mounted) return; + showToast(context, '已删除'); + Navigator.of(context).pop(); + } on ApiException catch (e) { + if (mounted) showToast(context, e.message, error: true); + } catch (_) { + if (mounted) showToast(context, '删除失败,请重试', error: true); + } + } +} diff --git a/lib/pages/home/home_page.dart b/lib/pages/home/home_page.dart new file mode 100644 index 0000000..665eb50 --- /dev/null +++ b/lib/pages/home/home_page.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/network/api_exception.dart'; +import '../../models/bill.dart'; +import '../../models/statistics_response.dart'; +import '../../providers/bill_provider.dart'; +import '../../providers/bill_refresh.dart'; +import '../../providers/budget_provider.dart'; +import '../../utils/amount_util.dart'; +import '../../utils/bill_grouping.dart'; +import '../../utils/date_util.dart'; +import '../../widgets/state_views.dart'; +import '../bill/bill_edit_page.dart'; +import 'widgets/bill_group_card.dart'; +import 'widgets/budget_card.dart'; +import 'widgets/month_switcher.dart'; + +/// 首页:月份切换 + 本月汇总 + 预算环 + 按日期分组的账单列表 +class HomePage extends ConsumerWidget { + const HomePage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final anchor = ref.watch(monthAnchorProvider); + final billsAsync = ref.watch(billsProvider(anchor)); + final statsAsync = ref.watch(monthlyStatisticsProvider(anchor)); + final budgetAsync = ref.watch(budgetProvider); + final isCurrentMonth = isSameMonth(anchor, DateTime.now()); + + return Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8), Color(0xFFFFC08A)], + ), + ), + child: SafeArea( + child: RefreshIndicator( + color: const Color(0xFFC8956E), + onRefresh: () => _onRefresh(ref), + child: ListView( + // 内容不足一屏时也要能下拉刷新 + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + children: [ + _header(anchor), + const SizedBox(height: 14), + _summaryCard(anchor, statsAsync), + const SizedBox(height: 12), + // 后端预算接口只提供当月数据,历史月份不展示,避免串数据 + if (isCurrentMonth) + BudgetCard(budget: budgetAsync) + else + _budgetUnavailableTip(), + const SizedBox(height: 16), + ..._billSection(context, ref, billsAsync), + const SizedBox(height: 90), // 底部导航避让 + ], + ), + ), + ), + ), + ); + } + + // ---- 顶部标题 + 月份切换 ---- + Widget _header(DateTime anchor) { + return Row( + children: [ + const Text( + '我的账单', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Color(0xFF5D4037), + ), + ), + const Spacer(), + const MonthSwitcher(), + ], + ); + } + + // ---- 本月汇总 ---- + Widget _summaryCard(DateTime anchor, AsyncValue statsAsync) { + final stats = statsAsync.value; + return Container( + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + _summaryItem('${anchor.month}月收入', stats?.totalIncome, const Color(0xFF19BE6B)), + _vDivider(), + _summaryItem('${anchor.month}月支出', stats?.totalExpense, const Color(0xFFFA3534)), + _vDivider(), + _summaryItem('结余', stats?.balance, const Color(0xFF5D4037)), + ], + ), + ); + } + + Widget _summaryItem(String label, double? amount, Color color) { + return Expanded( + child: Column( + children: [ + Text(label, style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63))), + const SizedBox(height: 6), + Text( + // 统计未加载完时显示占位,避免整块转圈导致头部抖动 + amount == null ? '--' : '¥${formatAmount(amount)}', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: color), + ), + ], + ), + ); + } + + Widget _vDivider() => Container(width: 1, height: 28, color: const Color(0xFFF0E6D8)); + + Widget _budgetUnavailableTip() { + return Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(16), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('🎯 ', style: TextStyle(fontSize: 14)), + Text( + '仅可查看本月预算', + style: TextStyle(fontSize: 13, color: Color(0xFF8D6E63)), + ), + ], + ), + ); + } + + // ---- 账单列表 ---- + List _billSection( + BuildContext context, + WidgetRef ref, + AsyncValue> billsAsync, + ) { + return [ + billsAsync.when( + loading: () => const LoadingView(), + error: (e, _) => ErrorView( + message: asApiException(e).message, + onRetry: () => ref.invalidate(billsProvider), + ), + data: (bills) { + if (bills.isEmpty) { + return const EmptyView( + title: '本月暂无账单记录', + tip: '点击底部「记账」添加,或用 OCR 识别小票', + ); + } + return Column( + children: groupBillsByDate(bills) + .map((group) => BillGroupCard( + group: group, + onTapBill: (bill) => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => BillEditPage(billId: bill.id)), + ), + )) + .toList(), + ); + }, + ), + ]; + } + + // ---- 下拉刷新 ---- + Future _onRefresh(WidgetRef ref) async { + final anchor = ref.read(monthAnchorProvider); + invalidateBillDataFromWidget(ref); + // 等三个请求结束再收起下拉动画;异常吞掉,由各自的 AsyncValue 渲染错误态 + await Future.wait([ + _silent(ref.read(billsProvider(anchor).future)), + _silent(ref.read(monthlyStatisticsProvider(anchor).future)), + _silent(ref.read(budgetProvider.future)), + ]); + } + + Future _silent(Future future) => + future.then((_) {}, onError: (_, _) {}); +} diff --git a/lib/pages/home/widgets/bill_group_card.dart b/lib/pages/home/widgets/bill_group_card.dart new file mode 100644 index 0000000..a9d0ab4 --- /dev/null +++ b/lib/pages/home/widgets/bill_group_card.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; + +import '../../../models/bill.dart'; +import '../../../utils/amount_util.dart'; +import '../../../utils/bill_grouping.dart'; +import '../../../utils/date_util.dart'; + +/// 单日分组卡:组头(日期 + 分开的支出/收入小计)+ 账单条目 +class BillGroupCard extends StatelessWidget { + const BillGroupCard({super.key, required this.group, required this.onTapBill}); + + final BillDayGroup group; + final ValueChanged onTapBill; + + static const _expenseColor = Color(0xFFFA3534); + static const _incomeColor = Color(0xFF19BE6B); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 8), + child: Row( + children: [ + Text( + formatGroupDate(group.date), + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: Color(0xFF5D4037), + ), + ), + const Spacer(), + // 原版把当日收入与支出直接相加(无意义),这里分开展示 + if (group.expenseTotal > 0) + Text( + '支 ¥${formatAmount(group.expenseTotal)}', + style: const TextStyle(fontSize: 12, color: _expenseColor), + ), + if (group.expenseTotal > 0 && group.incomeTotal > 0) + const SizedBox(width: 10), + if (group.incomeTotal > 0) + Text( + '收 ¥${formatAmount(group.incomeTotal)}', + style: const TextStyle(fontSize: 12, color: _incomeColor), + ), + ], + ), + ), + const Divider(height: 1, color: Color(0xFFF5EEE4)), + for (var i = 0; i < group.bills.length; i++) ...[ + _BillTile(bill: group.bills[i], onTap: () => onTapBill(group.bills[i])), + if (i != group.bills.length - 1) + const Divider(height: 1, indent: 56, color: Color(0xFFF5EEE4)), + ], + ], + ), + ); + } +} + +class _BillTile extends StatelessWidget { + const _BillTile({required this.bill, required this.onTap}); + + final Bill bill; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + // 金额正负与颜色按 type 判断(后端 amount 恒为正数,不能用金额正负判色) + final color = bill.isExpense ? BillGroupCard._expenseColor : BillGroupCard._incomeColor; + + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + Container( + width: 38, + height: 38, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Color(0xFFFBEEDD), + ), + alignment: Alignment.center, + child: Text(bill.categoryIcon, style: const TextStyle(fontSize: 19)), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + bill.categoryName, + style: const TextStyle( + fontSize: 14.5, + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + if (bill.description.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + bill.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Text( + formatSignedAmount(bill.absAmount, isExpense: bill.isExpense), + style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: color), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/home/widgets/budget_card.dart b/lib/pages/home/widgets/budget_card.dart new file mode 100644 index 0000000..d66b197 --- /dev/null +++ b/lib/pages/home/widgets/budget_card.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../models/budget.dart'; +import '../../../utils/amount_util.dart'; +import '../../../widgets/donut_chart.dart'; +import '../../../widgets/state_views.dart'; + +/// 本月预算卡:环形图 + 预算 / 剩余 / 日均可用 +class BudgetCard extends StatelessWidget { + const BudgetCard({super.key, required this.budget}); + + final AsyncValue budget; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: budget.when( + loading: () => const LoadingView(tip: '预算加载中...', padding: EdgeInsets.all(20)), + error: (e, _) => ErrorView(message: '预算加载失败'), + data: (info) => info.hasBudget ? _content(info) : _noBudget(), + ), + ); + } + + Widget _noBudget() { + return const EmptyView( + icon: '🎯', + title: '本月未设置预算', + tip: '可在「我的 → 预算设置」中设置', + padding: EdgeInsets.symmetric(vertical: 20), + ); + } + + Widget _content(BudgetInfo info) { + final over = info.isOverBudget; + final ringColor = over ? const Color(0xFFE53935) : const Color(0xFFC8956E); + final usedRatio = info.usedRatio; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(left: 4, bottom: 12), + child: Text( + '本月预算', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)), + ), + ), + Row( + children: [ + DonutChart( + size: 116, + centerSpaceRadius: 38, + slices: [ + DonutSlice(value: usedRatio, color: ringColor), + DonutSlice(value: 1 - usedRatio, color: const Color(0xFFF0E6D8)), + ], + center: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + over ? '超支' : '${(usedRatio * 100).round()}%', + style: TextStyle( + fontSize: over ? 14 : 17, + fontWeight: FontWeight.bold, + color: over ? const Color(0xFFE53935) : const Color(0xFF5D4037), + ), + ), + if (!over) + const Text('已用', style: TextStyle(fontSize: 10, color: Color(0xFF8D6E63))), + ], + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _amountRow('预算总额', '¥${formatAmount(info.amount)}', const Color(0xFF5D4037)), + const SizedBox(height: 8), + _amountRow('已用', '¥${formatAmount(info.usedAmount)}', ringColor), + const SizedBox(height: 8), + _amountRow( + '剩余', + '¥${formatAmount(info.remainingAmount)}', + info.remainingAmount < 0 ? const Color(0xFFE53935) : const Color(0xFF19BE6B), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0x33C8956E), Color(0x33FFD700)], + ), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFC8956E)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('💰 日均可用 ', style: TextStyle(fontSize: 13, color: Color(0xFF6D4C41))), + Text( + '¥${formatAmount(info.remainingDaily)}', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Color(0xFF5D4037), + ), + ), + ], + ), + ), + ], + ); + } + + Widget _amountRow(String label, String value, Color valueColor) { + return Row( + children: [ + Text(label, style: const TextStyle(fontSize: 12.5, color: Color(0xFF8D6E63))), + const Spacer(), + Text( + value, + style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: valueColor), + ), + ], + ); + } +} diff --git a/lib/pages/home/widgets/month_switcher.dart b/lib/pages/home/widgets/month_switcher.dart new file mode 100644 index 0000000..f9e17e6 --- /dev/null +++ b/lib/pages/home/widgets/month_switcher.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/bill_provider.dart'; +import '../../../utils/date_util.dart'; + +/// 月份切换条:‹ 2026年9月 › +class MonthSwitcher extends ConsumerWidget { + const MonthSwitcher({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final anchor = ref.watch(monthAnchorProvider); + final notifier = ref.read(monthAnchorProvider.notifier); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _iconButton( + icon: Icons.chevron_left, + tooltip: '上一月', + onTap: notifier.prev, + ), + GestureDetector( + onTap: () => _pickMonth(context, ref), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + formatMonthTitle(anchor), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + const SizedBox(width: 4), + const Icon(Icons.arrow_drop_down, size: 18, color: Color(0xFF8D6E63)), + ], + ), + ), + ), + _iconButton( + icon: Icons.chevron_right, + tooltip: '下一月', + // 不允许超过当前月 + onTap: notifier.canGoNext ? notifier.next : null, + ), + ], + ), + ); + } + + Widget _iconButton({ + required IconData icon, + required String tooltip, + VoidCallback? onTap, + }) { + final enabled = onTap != null; + return IconButton( + icon: Icon(icon), + iconSize: 20, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + color: enabled ? const Color(0xFF5D4037) : const Color(0xFFBCAAA4), + tooltip: tooltip, + onPressed: onTap, + ); + } + + Future _pickMonth(BuildContext context, WidgetRef ref) async { + final anchor = ref.read(monthAnchorProvider); + final picked = await showDatePicker( + context: context, + initialDate: anchor, + initialDatePickerMode: DatePickerMode.year, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (picked != null) { + ref.read(monthAnchorProvider.notifier).pick(picked); + } + } +} diff --git a/lib/pages/login/login_page.dart b/lib/pages/login/login_page.dart new file mode 100644 index 0000000..490f1e9 --- /dev/null +++ b/lib/pages/login/login_page.dart @@ -0,0 +1,527 @@ +import 'dart:ui' show ImageFilter; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../api/auth_api.dart'; +import '../../core/network/api_exception.dart'; +import '../../core/storage/local_storage.dart'; +import '../../providers/auth_provider.dart'; +import '../../utils/crypto.dart'; +import '../../utils/toast.dart'; +import '../shell/main_shell.dart'; + +/// 登录/注册页(复刻 uniApp pages/login/login.vue) +class LoginPage extends ConsumerStatefulWidget { + const LoginPage({super.key}); + + @override + ConsumerState createState() => _LoginPageState(); +} + +class _LoginPageState extends ConsumerState + with SingleTickerProviderStateMixin { + bool _isRegister = false; + bool _showPassword = false; + bool _loading = false; + bool _agreeProtocol = false; + bool _rememberMe = false; + + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + final _nicknameController = TextEditingController(); + final _formKey = GlobalKey(); + + late final AnimationController _floatController = AnimationController( + vsync: this, + duration: const Duration(seconds: 6), + )..repeat(reverse: true); + + @override + void initState() { + super.initState(); + final saved = LocalStorage.instance.getRememberedAccount(); + if (saved != null) { + _usernameController.text = saved.username; + _passwordController.text = decryptPassword(saved.password); + _rememberMe = true; + } + } + + @override + void dispose() { + _floatController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _nicknameController.dispose(); + super.dispose(); + } + + Future _submit() async { + if (_loading) return; + final username = _usernameController.text.trim(); + final password = _passwordController.text; + + if (username.isEmpty || password.isEmpty) { + showToast(context, '请输入用户名和密码', error: true); + return; + } + if (_isRegister) { + if (password.length < 6) { + showToast(context, '密码至少6位', error: true); + return; + } + if (!_agreeProtocol) { + showToast(context, '请先阅读并同意用户协议'); + return; + } + } + + setState(() => _loading = true); + try { + final api = ref.read(authApiProvider); + final response = _isRegister + ? await api.register( + username, + password, + _nicknameController.text.trim().isEmpty + ? username + : _nicknameController.text.trim(), + ) + : await api.login(username, password); + + // 记住密码 + if (!_isRegister && _rememberMe) { + await LocalStorage.instance.saveRememberedPassword( + username, + encryptPassword(password), + ); + } else if (!_isRegister && !_rememberMe) { + await LocalStorage.instance.clearRememberedPassword(); + } + + await ref.read(authProvider.notifier).loginSuccess(response); + if (!mounted) return; + showToast(context, _isRegister ? '🎉 注册成功' : '🚀 登录成功'); + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const MainShell()), + (route) => false, + ); + } on ApiException catch (e) { + if (mounted) showToast(context, e.message, error: true); + } catch (_) { + if (mounted) showToast(context, '操作失败,请重试', error: true); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8), Color(0xFFFFC08A)], + ), + ), + child: Stack( + children: [ + ..._buildBackgroundBalls(), + SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildHeader(), + const SizedBox(height: 32), + _buildFormCard(), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } + + // ---- 背景装饰圆球 ---- + List _buildBackgroundBalls() { + final t = _floatController.value; + Offset offset(double phase) => + Offset(20 * _wave(t + phase), 30 * _wave(t + phase + 0.25)); + Widget ball({ + required double size, + required List colors, + required Offset pos, + required double phase, + }) { + return Positioned( + left: pos.dx, + top: pos.dy, + child: Opacity( + opacity: 0.3, + child: AnimatedBuilder( + animation: _floatController, + builder: (context, _) => Transform.translate( + offset: offset(phase), + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient(colors: colors), + ), + ), + ), + ), + ), + ); + } + + return [ + ball( + size: 280, + colors: const [Color(0xFFC8956E), Color(0xFFFFB6C1)], + pos: const Offset(-70, -70), + phase: 0, + ), + ball( + size: 200, + colors: const [Color(0xFFF5D59E), Color(0xFFFFD700)], + pos: const Offset(-40, 180), + phase: 0.25, + ), + ball( + size: 240, + colors: const [Color(0xFFFFB6C1), Color(0xFFC8956E)], + pos: const Offset(40, -180), + phase: 0.5, + ), + ball( + size: 170, + colors: const [Color(0xFFFFD700), Color(0xFFF5D59E)], + pos: const Offset(240, 420), + phase: 0.75, + ), + ]; + } + + double _wave(double x) { + final v = (x % 1) * 2; + return v <= 1 ? v : 2 - v; + } + + // ---- Logo + 标题 ---- + Widget _buildHeader() { + return Column( + children: [ + Container( + width: 120, + height: 120, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + boxShadow: [ + BoxShadow( + color: const Color(0xFF5D4037).withValues(alpha: 0.3), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + padding: const EdgeInsets.all(10), + child: Image.asset('assets/images/logo.png', fit: BoxFit.contain), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _decoLine(), + const SizedBox(width: 10), + const Text( + '强宝爱记账', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: Color(0xFF5D4037), + letterSpacing: 2, + shadows: [ + Shadow( + color: Color(0x66FFD700), + blurRadius: 10, + offset: Offset(0, 3), + ), + ], + ), + ), + const SizedBox(width: 10), + _decoLine(), + ], + ), + const SizedBox(height: 6), + const Text( + '智能记账,轻松管理', + style: TextStyle(fontSize: 14, color: Color(0xFF8D6E63)), + ), + ], + ); + } + + Widget _decoLine() => Container( + width: 40, + height: 4, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + gradient: const LinearGradient( + colors: [Color(0xFFC8956E), Color(0xFFFFD700)], + ), + ), + ); + + // ---- 表单卡片(毛玻璃) ---- + Widget _buildFormCard() { + return ClipRRect( + borderRadius: BorderRadius.circular(24), + child: BackdropFilter( + filter: _glassFilter, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.65), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: const Color(0xFFC8956E), width: 1.5), + ), + child: Form( + key: _formKey, + child: Column( + children: [ + _buildSegmentTabs(), + const SizedBox(height: 20), + _inputField( + icon: '👤', + controller: _usernameController, + hint: '请输入用户名', + maxLength: 20, + ), + const SizedBox(height: 14), + _inputField( + icon: '🔒', + controller: _passwordController, + hint: '请输入密码', + maxLength: 20, + obscure: !_showPassword, + suffix: IconButton( + icon: Text( + _showPassword ? '👁️' : '👁️‍🗨️', + style: const TextStyle(fontSize: 16), + ), + onPressed: () => + setState(() => _showPassword = !_showPassword), + ), + ), + if (_isRegister) ...[ + const SizedBox(height: 14), + _inputField( + icon: '✨', + controller: _nicknameController, + hint: '请输入昵称(可选)', + maxLength: 20, + ), + ], + if (!_isRegister) ...[ + const SizedBox(height: 8), + Row( + children: [ + SizedBox( + width: 22, + height: 22, + child: Checkbox( + value: _rememberMe, + activeColor: const Color(0xFFC8956E), + onChanged: (v) => + setState(() => _rememberMe = v ?? false), + ), + ), + const SizedBox(width: 6), + const Text( + '记住密码', + style: TextStyle( + fontSize: 13, + color: Color(0xFF8D6E63), + ), + ), + ], + ), + ], + if (_isRegister) ...[ + const SizedBox(height: 8), + Row( + children: [ + SizedBox( + width: 22, + height: 22, + child: Checkbox( + value: _agreeProtocol, + activeColor: const Color(0xFFC8956E), + onChanged: (v) => + setState(() => _agreeProtocol = v ?? false), + ), + ), + const Expanded( + child: Text( + '我已阅读并同意用户协议', + style: TextStyle( + fontSize: 13, + color: Color(0xFF8D6E63), + ), + ), + ), + ], + ), + ], + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + height: 48, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFFC8956E), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + ), + onPressed: _loading ? null : _submit, + child: _loading + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: Colors.white, + ), + ) + : Text( + _isRegister ? '注 册' : '登 录', + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.bold, + letterSpacing: 4, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + static final ImageFilter _glassFilter = ImageFilter.blur( + sigmaX: 10, + sigmaY: 10, + ); + + // ---- 登录/注册切换 ---- + Widget _buildSegmentTabs() { + return Container( + decoration: BoxDecoration( + color: const Color(0x33C8956E), + borderRadius: BorderRadius.circular(20), + ), + padding: const EdgeInsets.all(4), + child: Row( + children: [ + _segment( + '登录', + !_isRegister, + () => setState(() => _isRegister = false), + ), + _segment('注册', _isRegister, () => setState(() => _isRegister = true)), + ], + ), + ); + } + + Widget _segment(String text, bool active, VoidCallback onTap) { + return Expanded( + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: active ? const Color(0xFFC8956E) : Colors.transparent, + ), + child: Center( + child: Text( + text, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: active ? Colors.white : const Color(0xFF8D6E63), + ), + ), + ), + ), + ), + ); + } + + Widget _inputField({ + required String icon, + required TextEditingController controller, + required String hint, + int maxLength = 50, + bool obscure = false, + Widget? suffix, + }) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFE0C9A6)), + ), + child: Row( + children: [ + Text(icon, style: const TextStyle(fontSize: 18)), + const SizedBox(width: 10), + Expanded( + child: TextField( + controller: controller, + obscureText: obscure, + maxLength: maxLength, + style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037)), + decoration: InputDecoration( + hintText: hint, + hintStyle: const TextStyle( + color: Color(0xFFBCAAA4), + fontSize: 14, + ), + border: InputBorder.none, + counterText: '', + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 14), + ), + ), + ), + ?suffix, + ], + ), + ); + } +} diff --git a/lib/pages/mine/mine_page.dart b/lib/pages/mine/mine_page.dart new file mode 100644 index 0000000..bf6adb7 --- /dev/null +++ b/lib/pages/mine/mine_page.dart @@ -0,0 +1,524 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/network/api_exception.dart'; +import '../../core/network/auth_redirect.dart'; +import '../../models/account.dart'; +import '../../models/budget.dart'; +import '../../providers/account_provider.dart'; +import '../../providers/auth_provider.dart'; +import '../../providers/budget_provider.dart'; +import '../../utils/amount_input_formatter.dart'; +import '../../utils/amount_util.dart'; +import '../../utils/toast.dart'; +import '../../widgets/state_views.dart'; + +/// 「我的」页面 +/// +/// 账户余额可手动修改;总收入 / 总支出由账单汇总得出,只读; +/// 本月预算可设置与随时修改(按月生效)。 +class MinePage extends ConsumerWidget { + const MinePage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final user = ref.watch(authProvider).user; + final accountAsync = ref.watch(accountProvider); + final budgetAsync = ref.watch(budgetProvider); + + return Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8), Color(0xFFFFC08A)], + ), + ), + child: SafeArea( + child: RefreshIndicator( + color: const Color(0xFFC8956E), + onRefresh: () => _onRefresh(ref), + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + children: [ + _userCard(user?.nickname ?? '用户', user?.username ?? '-'), + const SizedBox(height: 14), + _accountCard(context, ref, accountAsync), + const SizedBox(height: 14), + _budgetCard(context, ref, budgetAsync), + const SizedBox(height: 24), + _logoutButton(context), + const SizedBox(height: 90), + ], + ), + ), + ), + ), + ); + } + + Future _onRefresh(WidgetRef ref) async { + ref.invalidate(accountProvider); + ref.invalidate(budgetProvider); + await Future.wait([ + _silent(ref.read(accountProvider.future)), + _silent(ref.read(budgetProvider.future)), + ]); + } + + Future _silent(Future future) => + future.then((_) {}, onError: (_, _) {}); + + // ---------------- 用户信息 ---------------- + Widget _userCard(String nickname, String username) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: const LinearGradient( + colors: [Color(0xFFC8956E), Color(0xFFF5D59E), Color(0xFFFFD700)], + ), + border: Border.all(color: const Color(0xFF5D4037), width: 1.5), + boxShadow: [ + BoxShadow( + color: const Color(0xFF5D4037).withValues(alpha: 0.2), + blurRadius: 12, + offset: const Offset(0, 5), + ), + ], + ), + child: Row( + children: [ + Container( + width: 60, + height: 60, + decoration: const BoxDecoration(shape: BoxShape.circle, color: Colors.white), + alignment: Alignment.center, + child: const Text('👤', style: TextStyle(fontSize: 30)), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + nickname.isEmpty ? '用户' : nickname, + style: const TextStyle( + fontSize: 19, + fontWeight: FontWeight.bold, + color: Color(0xFF5D4037), + ), + ), + const SizedBox(height: 4), + Text( + '账号:$username', + style: const TextStyle(fontSize: 13, color: Color(0xFF6D4C41)), + ), + ], + ), + ), + ], + ), + ); + } + + // ---------------- 账户卡 ---------------- + Widget _accountCard(BuildContext context, WidgetRef ref, AsyncValue accountAsync) { + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: accountAsync.when( + loading: () => const LoadingView(tip: '账户加载中...', padding: EdgeInsets.all(20)), + error: (e, _) => ErrorView( + message: asApiException(e).message, + onRetry: () => ref.invalidate(accountProvider), + ), + data: (account) => _accountContent(context, ref, account), + ), + ); + } + + Widget _accountContent(BuildContext context, WidgetRef ref, AccountInfo account) { + final negative = account.balance < 0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + '账户余额', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)), + ), + const Spacer(), + _editButton( + label: '修改', + onPressed: () => _editBalance(context, ref, account), + ), + ], + ), + const SizedBox(height: 6), + Text( + '¥${formatAmount(account.balance)}', + style: TextStyle( + fontSize: 30, + fontWeight: FontWeight.bold, + color: negative ? const Color(0xFFFA3534) : const Color(0xFF5D4037), + ), + ), + const SizedBox(height: 16), + const Divider(height: 1, color: Color(0xFFF5EEE4)), + const SizedBox(height: 14), + Row( + children: [ + _readOnlyStat('总收入', account.totalIncome, const Color(0xFF19BE6B)), + Container(width: 1, height: 26, color: const Color(0xFFF0E6D8)), + _readOnlyStat('总支出', account.totalExpense, const Color(0xFFFA3534)), + ], + ), + ], + ); + } + + /// 只读统计项:来自账单汇总,不可修改(用锁图标提示) + Widget _readOnlyStat(String label, double amount, Color color) { + return Expanded( + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(label, style: const TextStyle(fontSize: 12.5, color: Color(0xFF8D6E63))), + const SizedBox(width: 4), + const Icon(Icons.lock_outline, size: 11, color: Color(0xFFBCAAA4)), + ], + ), + const SizedBox(height: 6), + Text( + '¥${formatAmount(amount)}', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: color), + ), + ], + ), + ); + } + + // ---------------- 预算卡 ---------------- + Widget _budgetCard(BuildContext context, WidgetRef ref, AsyncValue budgetAsync) { + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: budgetAsync.when( + loading: () => const LoadingView(tip: '预算加载中...', padding: EdgeInsets.all(20)), + error: (e, _) => ErrorView( + message: asApiException(e).message, + onRetry: () => ref.invalidate(budgetProvider), + ), + data: (budget) => _budgetContent(context, ref, budget), + ), + ); + } + + Widget _budgetContent(BuildContext context, WidgetRef ref, BudgetInfo budget) { + final hasBudget = budget.hasBudget; + final over = budget.isOverBudget; + final ratio = budget.usedRatio; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + '本月预算', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)), + ), + const SizedBox(width: 6), + if (!hasBudget) + const Text( + '(未设置)', + style: TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)), + ), + const Spacer(), + _editButton( + label: hasBudget ? '修改' : '设置', + onPressed: () => _editBudget(context, ref, budget), + ), + ], + ), + const SizedBox(height: 10), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + '¥${formatAmount(budget.amount)}', + style: const TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + color: Color(0xFF5D4037), + ), + ), + const SizedBox(width: 8), + if (hasBudget) + Text( + over ? '已超支' : '每月生效,可随时修改', + style: TextStyle( + fontSize: 12, + color: over ? const Color(0xFFE53935) : const Color(0xFFBCAAA4), + ), + ), + ], + ), + if (hasBudget) ...[ + const SizedBox(height: 14), + ClipRRect( + borderRadius: BorderRadius.circular(5), + child: Stack( + children: [ + Container(height: 8, color: const Color(0xFFF5EEE4)), + FractionallySizedBox( + widthFactor: ratio, + child: Container( + height: 8, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: over + ? const [Color(0xFFE53935), Color(0xFFFF8A80)] + : const [Color(0xFFC8956E), Color(0xFFFFD700)], + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Text( + '已用 ${(ratio * 100).round()}%', + style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63)), + ), + const Spacer(), + Text( + '剩余 ¥${formatAmount(budget.remainingAmount)}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: budget.remainingAmount < 0 + ? const Color(0xFFE53935) + : const Color(0xFF19BE6B), + ), + ), + ], + ), + const SizedBox(height: 14), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + gradient: const LinearGradient(colors: [Color(0x33C8956E), Color(0x33FFD700)]), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFC8956E)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('💰 日均可用 ', style: TextStyle(fontSize: 13, color: Color(0xFF6D4C41))), + Text( + '¥${formatAmount(budget.remainingDaily)}', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Color(0xFF5D4037), + ), + ), + ], + ), + ), + ], + ], + ); + } + + Widget _editButton({required String label, required VoidCallback onPressed}) { + return TextButton.icon( + onPressed: onPressed, + icon: const Icon(Icons.edit_outlined, size: 16), + label: Text(label, style: const TextStyle(fontSize: 13)), + style: TextButton.styleFrom( + foregroundColor: const Color(0xFFC8956E), + padding: const EdgeInsets.symmetric(horizontal: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ); + } + + // ---------------- 修改余额 ---------------- + Future _editBalance(BuildContext context, WidgetRef ref, AccountInfo account) async { + final input = await _showAmountDialog( + context: context, + title: '修改账户余额', + initialValue: account.balance, + hint: '请输入当前账户余额', + note: '修改后的余额将作为新的基准,之后记账会在其基础上增减。', + allowNegative: true, // 余额可能为负 + ); + if (input == null) return; + + try { + await ref.read(accountProvider.notifier).setBalance(input); + if (context.mounted) showToast(context, '✅ 余额已更新'); + } on ApiException catch (e) { + if (context.mounted) showToast(context, e.message, error: true); + } catch (_) { + if (context.mounted) showToast(context, '余额更新失败,请重试', error: true); + } + } + + // ---------------- 修改预算 ---------------- + Future _editBudget(BuildContext context, WidgetRef ref, BudgetInfo budget) async { + final input = await _showAmountDialog( + context: context, + title: budget.hasBudget ? '修改本月预算' : '设置本月预算', + initialValue: budget.amount, + hint: '请输入预算金额', + note: '预算按月生效,可随时修改;本月账单支出会累计到「已用」。', + ); + if (input == null) return; + + try { + await ref.read(budgetProvider.notifier).setMonthBudget(input); + if (context.mounted) showToast(context, '✅ 预算已保存'); + } on ApiException catch (e) { + if (context.mounted) showToast(context, e.message, error: true); + } catch (_) { + if (context.mounted) showToast(context, '预算保存失败,请重试', error: true); + } + } + + /// 金额输入弹窗(修改余额 / 修改预算 共用) + /// + /// [allowNegative] 仅余额编辑开启——余额可能为负,预算与记账金额不会。 + Future _showAmountDialog({ + required BuildContext context, + required String title, + required double initialValue, + required String hint, + required String note, + bool allowNegative = false, + }) async { + final controller = TextEditingController(text: initialValue.toStringAsFixed(2)); + // 预填内容默认全选,输入即替换,省去手动清空 + controller.selection = TextSelection( + baseOffset: 0, + extentOffset: controller.text.length, + ); + try { + return await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(title), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: controller, + autofocus: true, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), + inputFormatters: [AmountInputFormatter(allowNegative: allowNegative)], + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Color(0xFF5D4037), + ), + decoration: InputDecoration( + hintText: hint, + prefixText: '¥ ', + prefixStyle: const TextStyle( + fontSize: 18, + color: Color(0xFFC8956E), + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: 12), + Text(note, style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63))), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('取消', style: TextStyle(color: Color(0xFF8D6E63))), + ), + TextButton( + onPressed: () => + Navigator.pop(dialogContext, double.tryParse(controller.text)), + child: const Text('保存', style: TextStyle(color: Color(0xFFC8956E))), + ), + ], + ), + ); + } finally { + controller.dispose(); + } + } + + // ---------------- 退出登录 ---------------- + Widget _logoutButton(BuildContext context) { + return SizedBox( + width: double.infinity, + height: 48, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: const Color(0xFFD32F2F), + side: const BorderSide(color: Color(0xFFE0C9A6)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + ), + onPressed: () => _confirmLogout(context), + child: const Text('退出登录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + ), + ); + } + + Future _confirmLogout(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('提示'), + content: const Text('确定要退出登录吗?'), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('取消', style: TextStyle(color: Color(0xFF8D6E63))), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('确定退出', style: TextStyle(color: Color(0xFFD32F2F))), + ), + ], + ), + ); + if (confirmed != true) return; + await logoutAndGoLogin(); + } +} diff --git a/lib/pages/shell/main_shell.dart b/lib/pages/shell/main_shell.dart new file mode 100644 index 0000000..25eb1b7 --- /dev/null +++ b/lib/pages/shell/main_shell.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../providers/auth_provider.dart'; +import '../add/add_page.dart'; +import '../home/home_page.dart'; +import '../login/login_page.dart'; +import '../mine/mine_page.dart'; +import '../statistics/statistics_page.dart'; + +class MainShell extends ConsumerStatefulWidget { + const MainShell({super.key}); + + @override + ConsumerState createState() => _MainShellState(); +} + +class _MainShellState extends ConsumerState { + int _index = 0; // 默认选中「首页」 + + @override + void initState() { + super.initState(); + // 兜底:未登录直接回登录页 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!ref.read(authProvider).isLoggedIn) { + _goLogin(); + } + }); + } + + void _goLogin() { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const LoginPage()), + (route) => false, + ); + } + + @override + Widget build(BuildContext context) { + // IndexedStack 保证四个页面常驻:写操作后首页/统计的数据失效能立即重取, + // 用户切回时看到的就是最新数据。 + const pages = [ + HomePage(), + AddPage(), + StatisticsPage(), + MinePage(), + ]; + return Scaffold( + body: IndexedStack(index: _index, children: pages), + bottomNavigationBar: NavigationBar( + selectedIndex: _index, + onDestinationSelected: (i) => setState(() => _index = i), + backgroundColor: Colors.white, + indicatorColor: const Color(0x33C8956E), + destinations: const [ + NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: '首页'), + NavigationDestination(icon: Icon(Icons.add_circle_outline), selectedIcon: Icon(Icons.add_circle), label: '记账'), + NavigationDestination(icon: Icon(Icons.bar_chart_outlined), selectedIcon: Icon(Icons.bar_chart), label: '统计'), + NavigationDestination(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: '我的'), + ], + ), + ); + } +} diff --git a/lib/pages/statistics/statistics_page.dart b/lib/pages/statistics/statistics_page.dart new file mode 100644 index 0000000..605c499 --- /dev/null +++ b/lib/pages/statistics/statistics_page.dart @@ -0,0 +1,178 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/network/api_exception.dart'; +import '../../models/statistics_response.dart'; +import '../../providers/statistics_provider.dart'; +import '../../utils/amount_util.dart'; +import '../../widgets/state_views.dart'; +import 'widgets/category_pie_card.dart'; +import 'widgets/category_rank_list.dart'; + +/// 统计页:按日/周/月切换 + 汇总 + 分类占比饼图 + 分类明细 +class StatisticsPage extends ConsumerWidget { + const StatisticsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tab = ref.watch(statisticsTabProvider); + final statsAsync = ref.watch(statisticsProvider(tab)); + + return Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8), Color(0xFFFFC08A)], + ), + ), + child: SafeArea( + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + children: [ + const Text( + '统计分析', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Color(0xFF5D4037), + ), + ), + const SizedBox(height: 14), + _tabBar(tab, ref), + const SizedBox(height: 14), + _content(ref, statsAsync), + const SizedBox(height: 90), // 底部导航避让 + ], + ), + ), + ), + ); + } + + // ---- 时间粒度切换 ---- + Widget _tabBar(StatisticsTab current, WidgetRef ref) { + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFC8956E)), + ), + child: Row( + children: [ + _tabItem('按日', StatisticsTab.daily, current, ref), + _tabItem('按周', StatisticsTab.weekly, current, ref), + _tabItem('按月', StatisticsTab.monthly, current, ref), + ], + ), + ); + } + + Widget _tabItem(String text, StatisticsTab tab, StatisticsTab current, WidgetRef ref) { + final active = current == tab; + return Expanded( + child: GestureDetector( + onTap: () => ref.read(statisticsTabProvider.notifier).set(tab), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + gradient: active + ? const LinearGradient(colors: [Color(0xFFC8956E), Color(0xFFF5D59E)]) + : null, + ), + child: Center( + child: Text( + text, + style: TextStyle( + fontSize: 14.5, + fontWeight: FontWeight.w600, + color: active ? const Color(0xFF5D4037) : const Color(0xFF8D6E63), + ), + ), + ), + ), + ), + ); + } + + // ---- 内容区 ---- + Widget _content(WidgetRef ref, AsyncValue statsAsync) { + return statsAsync.when( + loading: () => const LoadingView(), + error: (e, _) => ErrorView( + message: asApiException(e).message, + onRetry: () => ref.invalidate(statisticsProvider), + ), + data: (stats) => Column( + children: [ + _summaryCard(stats), + const SizedBox(height: 12), + if (stats.categoryStatistics.isEmpty) + const EmptyView(title: '暂无统计数据', tip: '所选时间范围内还没有账单') + else ...[ + CategoryPieCard(stats: stats), + const SizedBox(height: 12), + CategoryRankList(stats: stats), + ], + ], + ), + ); + } + + // ---- 汇总卡 ---- + Widget _summaryCard(StatisticsResponse stats) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 18), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + gradient: const LinearGradient( + colors: [Color(0xFFC8956E), Color(0xFFF5D59E), Color(0xFFFFD700)], + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFF5D4037).withValues(alpha: 0.18), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + _summaryItem('总收入', stats.totalIncome, const Color(0xFF1B5E20)), + _vDivider(), + _summaryItem('总支出', stats.totalExpense, const Color(0xFFB71C1C)), + _vDivider(), + _summaryItem('余额', stats.balance, const Color(0xFF3E2723)), + ], + ), + ); + } + + Widget _summaryItem(String label, double amount, Color color) { + return Expanded( + child: Column( + children: [ + Text( + label, + style: const TextStyle(fontSize: 12, color: Color(0xFF4E342E)), + ), + const SizedBox(height: 6), + Text( + '¥${formatAmount(amount)}', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: color), + ), + ], + ), + ); + } + + Widget _vDivider() => + Container(width: 1, height: 26, color: const Color(0xFF5D4037).withValues(alpha: 0.25)); +} diff --git a/lib/pages/statistics/widgets/category_pie_card.dart b/lib/pages/statistics/widgets/category_pie_card.dart new file mode 100644 index 0000000..c269723 --- /dev/null +++ b/lib/pages/statistics/widgets/category_pie_card.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; + +import '../../../models/statistics_response.dart'; +import '../../../utils/amount_util.dart'; +import '../../../widgets/donut_chart.dart'; + +/// 支出分类占比环形图卡 +class CategoryPieCard extends StatelessWidget { + const CategoryPieCard({super.key, required this.stats}); + + final StatisticsResponse stats; + + @override + Widget build(BuildContext context) { + // 只画支出分类(收入分类在下方排行列表里看) + final items = stats.categoryStatistics + .where((e) => e.isExpense && e.amount > 0) + .toList() + ..sort((a, b) => b.amount.compareTo(a.amount)); + + if (items.isEmpty) return const SizedBox.shrink(); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '支出分类占比', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)), + ), + const SizedBox(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + DonutChart( + size: 150, + centerSpaceRadius: 48, + slices: items + .asMap() + .entries + .map((e) => DonutSlice( + value: e.value.amount, + color: chartColorAt(e.key), + )) + .toList(), + center: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('支出', style: TextStyle(fontSize: 11, color: Color(0xFF8D6E63))), + Text( + '¥${formatAmount(stats.totalExpense)}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Color(0xFF5D4037), + ), + ), + ], + ), + ), + const SizedBox(width: 16), + // 图例最多显示前 6 项,避免色块与名称对不上号 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: items + .take(6) + .toList() + .asMap() + .entries + .map((e) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: chartColorAt(e.key), + borderRadius: BorderRadius.circular(3), + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + e.value.categoryName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12, + color: Color(0xFF8D6E63), + ), + ), + ), + Text( + '${percentOf(e.value.amount, stats.totalExpense).toStringAsFixed(1)}%', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + ], + ), + )) + .toList(), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/pages/statistics/widgets/category_rank_list.dart b/lib/pages/statistics/widgets/category_rank_list.dart new file mode 100644 index 0000000..6a415c7 --- /dev/null +++ b/lib/pages/statistics/widgets/category_rank_list.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; + +import '../../../models/statistics_response.dart'; +import '../../../utils/amount_util.dart'; + +/// 分类排行:emoji + 名称 + 进度条 + 金额 + 占比 +class CategoryRankList extends StatelessWidget { + const CategoryRankList({super.key, required this.stats}); + + final StatisticsResponse stats; + + @override + Widget build(BuildContext context) { + final items = [...stats.categoryStatistics] + ..sort((a, b) => b.amount.compareTo(a.amount)); + + if (items.isEmpty) return const SizedBox.shrink(); + + return Container( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 6), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '分类明细', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037)), + ), + const SizedBox(height: 12), + ...items.map(_row), + ], + ), + ); + } + + Widget _row(CategoryStat item) { + // 分母按行内类型取:支出项用总支出、收入项用总收入 + // (uniApp 原版一律用总支出做分母,导致收入项占比显示成百分比 > 100%) + final total = item.isExpense ? stats.totalExpense : stats.totalIncome; + final pct = percentOf(item.amount, total); + final barColor = item.isExpense ? const Color(0xFFC8956E) : const Color(0xFF19BE6B); + + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(item.categoryIcon, style: const TextStyle(fontSize: 16)), + const SizedBox(width: 8), + Expanded( + child: Text( + item.categoryName, + style: const TextStyle(fontSize: 13.5, color: Color(0xFF5D4037)), + ), + ), + Text( + '¥${formatAmount(item.amount)}', + style: const TextStyle( + fontSize: 13.5, + fontWeight: FontWeight.bold, + color: Color(0xFF5D4037), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 46, + child: Text( + '${pct.toStringAsFixed(1)}%', + textAlign: TextAlign.right, + style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63)), + ), + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Stack( + children: [ + Container(height: 6, color: const Color(0xFFF5EEE4)), + FractionallySizedBox( + widthFactor: (pct / 100).clamp(0.0, 1.0), + child: Container( + height: 6, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [barColor, barColor.withValues(alpha: 0.55)], + ), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/providers/account_provider.dart b/lib/providers/account_provider.dart new file mode 100644 index 0000000..d9f4ee9 --- /dev/null +++ b/lib/providers/account_provider.dart @@ -0,0 +1,31 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/account_api.dart'; +import '../core/network/api_exception.dart'; +import '../models/account.dart'; + +/// 账户信息(余额 / 总收入 / 总支出) +class AccountNotifier extends AsyncNotifier { + @override + Future build() => ref.watch(accountApiProvider).getAccount(); + + /// 把账户余额改成 [targetBalance]。 + /// + /// 后端只能写期初余额,所以这里先反算: + /// `initialBalance = targetBalance - totalIncome + totalExpense` + Future setBalance(double targetBalance) async { + final current = state.value; + if (current == null) throw ApiException(-1, '账户信息尚未加载完成'); + + final initial = initialBalanceFor( + targetBalance: targetBalance, + totalIncome: current.totalIncome, + totalExpense: current.totalExpense, + ); + final updated = await ref.read(accountApiProvider).updateBalance(initial); + state = AsyncData(updated); + } +} + +final accountProvider = + AsyncNotifierProvider(AccountNotifier.new); diff --git a/lib/providers/add_bill_provider.dart b/lib/providers/add_bill_provider.dart new file mode 100644 index 0000000..4f27b6a --- /dev/null +++ b/lib/providers/add_bill_provider.dart @@ -0,0 +1,114 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/bill_api.dart'; +import '../core/network/api_exception.dart'; +import '../models/bill_request.dart'; +import '../models/ocr_result.dart'; +import '../utils/date_util.dart'; +import 'bill_refresh.dart'; + +/// 手动记账表单状态 +class AddBillFormState { + final int type; // 1=支出 2=收入 + final int? categoryId; + final double? amount; + final String billDate; // yyyy-MM-dd + final String description; + final bool submitting; + final int revision; // 每次 fillFromOcr 外部填充时 +1,供 UI 同步非受控输入框 + + const AddBillFormState({ + this.type = 1, + this.categoryId, + this.amount, + required this.billDate, + this.description = '', + this.submitting = false, + this.revision = 0, + }); + + AddBillFormState copyWith({ + int? type, + int? categoryId, + Object? amount = _sentinel, + String? billDate, + String? description, + bool? submitting, + int? revision, + }) { + return AddBillFormState( + type: type ?? this.type, + categoryId: categoryId ?? this.categoryId, + amount: amount == _sentinel ? this.amount : amount as double?, + billDate: billDate ?? this.billDate, + description: description ?? this.description, + submitting: submitting ?? this.submitting, + revision: revision ?? this.revision, + ); + } + + static const _sentinel = Object(); +} + +class AddBillFormNotifier extends Notifier { + @override + AddBillFormState build() => AddBillFormState(billDate: formatDate(DateTime.now())); + + void setType(int type) { + if (type == state.type) return; + // 类型切换后分类需重选(对齐 uniApp watch 行为) + state = state.copyWith(type: type, categoryId: null); + } + + void setCategory(int id) => state = state.copyWith(categoryId: id); + + void setAmount(double? value) => state = state.copyWith(amount: value); + + void setDate(String date) => state = state.copyWith(billDate: date); + + void setDescription(String desc) => state = state.copyWith(description: desc); + + /// 保存账单,成功返回 true + Future submit() async { + final s = state; + if (s.categoryId == null) throw ApiException(-1, '请选择分类'); + if (s.amount == null || s.amount! <= 0) throw ApiException(-1, '请输入有效金额'); + state = state.copyWith(submitting: true); + try { + await ref.read(billApiProvider).createBill(BillRequest( + categoryId: s.categoryId!, + amount: s.amount!, + description: s.description, + billDate: s.billDate, + type: s.type, + )); + // 让首页列表与统计页重新取数 + invalidateBillData(ref); + _resetAfterSubmit(); + return true; + } finally { + state = state.copyWith(submitting: false); + } + } + + void _resetAfterSubmit() { + state = AddBillFormState( + type: state.type, + billDate: formatDate(DateTime.now()), + ); + } + + /// OCR「选择此条」:按金额正负推断类型并填充表单 + void fillFromOcr(OcrResult ocr) { + state = AddBillFormState( + type: ocr.isExpense ? 1 : 2, + amount: ocr.absAmount, + description: ocr.merchant ?? '', + billDate: parseBillDate(ocr.date), + revision: state.revision + 1, + ); + } +} + +final addBillFormProvider = + NotifierProvider(AddBillFormNotifier.new); diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart new file mode 100644 index 0000000..dca20ec --- /dev/null +++ b/lib/providers/auth_provider.dart @@ -0,0 +1,55 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/storage/local_storage.dart'; +import '../models/auth_response.dart'; +import '../utils/jwt_util.dart'; + +class AuthState { + final String? token; + final UserInfo? user; + + const AuthState({this.token, this.user}); + + bool get isLoggedIn => token != null && token!.isNotEmpty; + + const AuthState.initial() : this._(); + + const AuthState._({this.token, this.user}); +} + +class AuthNotifier extends Notifier { + @override + AuthState build() => const AuthState.initial(); + + /// App 启动时从本地存储恢复登录态。 + /// 若 token 已在本地可判定过期,直接清理,避免带着废 token 进入主界面。 + Future load() async { + final token = LocalStorage.instance.getToken(); + if (token == null || token.isEmpty) return; + + if (isJwtExpired(token)) { + await LocalStorage.instance.clearAuth(); + return; + } + + final userInfo = LocalStorage.instance.getUserInfo(); + state = AuthState(token: token, user: userInfo == null ? null : UserInfo.fromJson(userInfo)); + } + + /// 登录/注册成功后调用:持久化并更新状态 + Future loginSuccess(AuthResponse response) async { + final user = UserInfo( + username: response.username ?? '', + nickname: response.nickname ?? response.username ?? '', + ); + await LocalStorage.instance.setAuth(token: response.token, userInfo: user.toJson()); + state = AuthState(token: response.token, user: user); + } + + Future logout() async { + await LocalStorage.instance.clearAuth(); + state = const AuthState.initial(); + } +} + +final authProvider = NotifierProvider(AuthNotifier.new); diff --git a/lib/providers/bill_edit_provider.dart b/lib/providers/bill_edit_provider.dart new file mode 100644 index 0000000..2051e9b --- /dev/null +++ b/lib/providers/bill_edit_provider.dart @@ -0,0 +1,151 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/bill_api.dart'; +import '../core/network/api_exception.dart'; +import '../models/bill_request.dart'; +import '../utils/date_util.dart'; +import 'bill_refresh.dart'; + +/// 账单编辑页的表单状态 +class BillEditState { + final bool loading; // 详情加载中 + final String? loadError; // 详情加载失败提示 + final int type; // 1=支出 2=收入 + final int? categoryId; + final double? amount; + final String billDate; // yyyy-MM-dd + final String description; + final bool submitting; + final int revision; // 服务端数据回填时 +1,供页面同步非受控输入框 + + const BillEditState({ + this.loading = false, + this.loadError, + this.type = 1, + this.categoryId, + this.amount, + required this.billDate, + this.description = '', + this.submitting = false, + this.revision = 0, + }); + + BillEditState copyWith({ + bool? loading, + Object? loadError = _sentinel, + int? type, + int? categoryId, + Object? amount = _sentinel, + String? billDate, + String? description, + bool? submitting, + int? revision, + }) { + return BillEditState( + loading: loading ?? this.loading, + loadError: loadError == _sentinel ? this.loadError : loadError as String?, + type: type ?? this.type, + categoryId: categoryId ?? this.categoryId, + amount: amount == _sentinel ? this.amount : amount as double?, + billDate: billDate ?? this.billDate, + description: description ?? this.description, + submitting: submitting ?? this.submitting, + revision: revision ?? this.revision, + ); + } + + static const _sentinel = Object(); +} + +class BillEditNotifier extends Notifier { + BillEditNotifier(this.billId); + + final int billId; + + @override + BillEditState build() { + _load(); + return BillEditState(billDate: formatDate(DateTime.now()), loading: true); + } + + Future _load() async { + try { + final bill = await ref.read(billApiProvider).getBill(billId); + if (!ref.mounted) return; + state = BillEditState( + type: bill.type, + categoryId: bill.categoryId, + amount: bill.absAmount, + billDate: bill.billDate, + description: bill.description, + revision: state.revision + 1, + ); + } on ApiException catch (e) { + if (!ref.mounted) return; + state = state.copyWith(loading: false, loadError: e.message); + } catch (_) { + if (!ref.mounted) return; + state = state.copyWith(loading: false, loadError: '账单加载失败'); + } + } + + /// 重新加载(错误重试用) + void reload() { + state = state.copyWith(loading: true, loadError: null); + _load(); + } + + void setType(int type) { + if (type == state.type) return; + // 切换收支后分类需重选(与记账页行为一致) + state = state.copyWith(type: type, categoryId: null); + } + + void setCategory(int id) => state = state.copyWith(categoryId: id); + + void setAmount(double? value) => state = state.copyWith(amount: value); + + void setDate(String date) => state = state.copyWith(billDate: date); + + void setDescription(String desc) => state = state.copyWith(description: desc); + + /// 保存:必须回传全部字段,否则后端会把 description 覆盖成 null + Future save() async { + final s = state; + if (s.categoryId == null) throw ApiException(-1, '请选择分类'); + if (s.amount == null || s.amount! <= 0) throw ApiException(-1, '请输入有效金额'); + + state = state.copyWith(submitting: true); + try { + await ref.read(billApiProvider).updateBill( + billId, + BillRequest( + categoryId: s.categoryId!, + amount: s.amount!, + description: s.description, + billDate: s.billDate, + type: s.type, + ), + ); + if (!ref.mounted) return; + invalidateBillData(ref); + } finally { + if (ref.mounted) state = state.copyWith(submitting: false); + } + } + + /// 删除该账单 + Future remove() async { + state = state.copyWith(submitting: true); + try { + await ref.read(billApiProvider).deleteBill(billId); + if (!ref.mounted) return; + invalidateBillData(ref); + } finally { + if (ref.mounted) state = state.copyWith(submitting: false); + } + } +} + +final billEditProvider = NotifierProvider.autoDispose + .family(BillEditNotifier.new); diff --git a/lib/providers/bill_provider.dart b/lib/providers/bill_provider.dart new file mode 100644 index 0000000..396a0dc --- /dev/null +++ b/lib/providers/bill_provider.dart @@ -0,0 +1,49 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/bill_api.dart'; +import '../api/statistics_api.dart'; +import '../models/bill.dart'; +import '../models/statistics_response.dart'; +import '../utils/date_util.dart'; + +/// 首页选中的月份锚点(该月 1 日 00:00)。 +/// 切换月份只改这一个状态,依赖它的列表与汇总会自动换 key 重新取数。 +class MonthAnchorNotifier extends Notifier { + @override + DateTime build() => monthStart(DateTime.now()); + + bool get canGoNext => state.isBefore(monthStart(DateTime.now())); + + void prev() => state = shiftMonth(state, -1); + + /// 下一月,不允许超过当前月 + void next() { + final target = shiftMonth(state, 1); + if (target.isAfter(monthStart(DateTime.now()))) return; + state = target; + } + + /// 选中某一天所在的月份 + void pick(DateTime anyDayInMonth) => state = monthStart(anyDayInMonth); +} + +final monthAnchorProvider = + NotifierProvider(MonthAnchorNotifier.new); + +/// 某月的账单列表(key = 月份锚点) +final billsProvider = FutureProvider.autoDispose.family, DateTime>((ref, month) { + return ref.watch(billApiProvider).getBills( + startDate: formatDate(monthStart(month)), + endDate: formatDate(monthEnd(month)), + ); +}); + +/// 某月的统计(首页汇总卡用),与列表共用同一个月份 key +final monthlyStatisticsProvider = + FutureProvider.autoDispose.family((ref, month) { + return ref.watch(statisticsApiProvider).getMonthly(year: month.year, month: month.month); +}); + +/// 账单详情 +final billDetailProvider = + FutureProvider.autoDispose.family((ref, id) => ref.watch(billApiProvider).getBill(id)); diff --git a/lib/providers/bill_refresh.dart b/lib/providers/bill_refresh.dart new file mode 100644 index 0000000..2080e18 --- /dev/null +++ b/lib/providers/bill_refresh.dart @@ -0,0 +1,37 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'account_provider.dart'; +import 'bill_provider.dart'; +import 'budget_provider.dart'; +import 'statistics_provider.dart'; + +/// 账单数据的统一失效入口。 +/// +/// 任何写操作(手动记账、OCR 批量存储、编辑、删除)成功后调用这里, +/// 首页与统计页会自动重新取数——替代 uniApp 的 `uni.$emit('refreshBills')` 事件总线。 +/// +/// 传入的是 **family 对象**,Riverpod 会失效该 family 的所有 key, +/// 所以即使用户之前浏览过其它月份,那些缓存也一并作废,不会出现脏数据。 +/// +/// 账户信息也要一起失效:余额和总收支都是由账单汇总出来的。 +/// +/// 说明:Ref 与 WidgetRef 没有公共父类(且 invalidate 的参数类型 ProviderOrFamily +/// 未公开导出),所以这里写成两个入口,各自直接调用而不是抽公共函数。 +void invalidateBillData(Ref ref) { + ref.invalidate(billsProvider); + ref.invalidate(monthlyStatisticsProvider); + ref.invalidate(statisticsProvider); + ref.invalidate(budgetProvider); + ref.invalidate(billDetailProvider); + ref.invalidate(accountProvider); +} + +/// 供 Widget 层调用(下拉刷新、页面重试) +void invalidateBillDataFromWidget(WidgetRef ref) { + ref.invalidate(billsProvider); + ref.invalidate(monthlyStatisticsProvider); + ref.invalidate(statisticsProvider); + ref.invalidate(budgetProvider); + ref.invalidate(billDetailProvider); + ref.invalidate(accountProvider); +} diff --git a/lib/providers/budget_provider.dart b/lib/providers/budget_provider.dart new file mode 100644 index 0000000..b1c5b08 --- /dev/null +++ b/lib/providers/budget_provider.dart @@ -0,0 +1,21 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/budget_api.dart'; +import '../models/budget.dart'; + +/// 当月预算(后端只提供当月数据)。 +/// +/// 做成 AsyncNotifier 而不是 FutureProvider,是为了能直接调用 +/// [BudgetNotifier.setMonthBudget] 修改预算并拿到后端返回的最新值。 +class BudgetNotifier extends AsyncNotifier { + @override + Future build() => ref.watch(budgetApiProvider).getBudget(); + + /// 设置/修改当月预算(立即生效,可随时修改) + Future setMonthBudget(double amount) async { + final updated = await ref.read(budgetApiProvider).setCurrentBudget(amount); + state = AsyncData(updated); + } +} + +final budgetProvider = AsyncNotifierProvider(BudgetNotifier.new); diff --git a/lib/providers/category_provider.dart b/lib/providers/category_provider.dart new file mode 100644 index 0000000..162bd21 --- /dev/null +++ b/lib/providers/category_provider.dart @@ -0,0 +1,10 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/category_api.dart'; +import '../models/category.dart'; + +/// 按类型加载分类:1=支出 2=收入 +final categoriesProvider = + FutureProvider.autoDispose.family, int>((ref, type) { + return ref.watch(categoryApiProvider).getCategories(type); +}); diff --git a/lib/providers/ocr_provider.dart b/lib/providers/ocr_provider.dart new file mode 100644 index 0000000..50b0537 --- /dev/null +++ b/lib/providers/ocr_provider.dart @@ -0,0 +1,128 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/bill_api.dart'; +import '../api/category_api.dart'; +import '../api/ocr_api.dart'; +import '../core/network/api_exception.dart'; +import '../models/bill_request.dart'; +import '../models/ocr_result.dart'; +import '../utils/date_util.dart'; +import 'bill_refresh.dart'; + +/// OCR 识别流程状态 +class OcrState { + final String? imagePath; + final List results; + final Set expandedIndexes; + final bool recognizing; + final bool saving; + final String? error; + + const OcrState({ + this.imagePath, + this.results = const [], + this.expandedIndexes = const {}, + this.recognizing = false, + this.saving = false, + this.error, + }); + + OcrState copyWith({ + Object? imagePath = _sentinel, + List? results, + Set? expandedIndexes, + bool? recognizing, + bool? saving, + Object? error = _sentinel, + }) { + return OcrState( + imagePath: imagePath == _sentinel ? this.imagePath : imagePath as String?, + results: results ?? this.results, + expandedIndexes: expandedIndexes ?? this.expandedIndexes, + recognizing: recognizing ?? this.recognizing, + saving: saving ?? this.saving, + error: error == _sentinel ? this.error : error as String?, + ); + } + + static const _sentinel = Object(); +} + +class OcrNotifier extends Notifier { + @override + OcrState build() => const OcrState(); + + void setImagePath(String path) { + state = OcrState(imagePath: path); + } + + void clearImage() { + state = const OcrState(); + } + + void toggleExpanded(int index) { + final indexes = {...state.expandedIndexes}; + if (!indexes.remove(index)) indexes.add(index); + state = state.copyWith(expandedIndexes: indexes); + } + + /// 上传识别 + Future recognize() async { + final path = state.imagePath; + if (path == null) throw ApiException(-1, '请先选择图片'); + state = state.copyWith(recognizing: true, error: null); + try { + final results = await ref.read(ocrApiProvider).recognize(path); + state = state.copyWith( + results: results, + // 只有一条时自动展开 + expandedIndexes: results.length == 1 ? {0} : {}, + ); + } on ApiException catch (e) { + state = state.copyWith(error: e.message); + rethrow; + } finally { + state = state.copyWith(recognizing: false); + } + } + + /// 直接批量存储:按金额正负推断类型,分类取该类型下第一项 + Future saveDirectly() async { + if (state.results.isEmpty) throw ApiException(-1, '没有可存储的识别结果'); + state = state.copyWith(saving: true, error: null); + try { + final categoryApi = ref.read(categoryApiProvider); + final bills = []; + final cache = {}; // type -> 默认分类id + for (final r in state.results) { + final type = r.isExpense ? 1 : 2; + if (!cache.containsKey(type)) { + final categories = await categoryApi.getCategories(type); + cache[type] = categories.isNotEmpty ? categories.first.id : null; + } + final categoryId = cache[type]; + if (categoryId == null) { + throw ApiException(-1, '缺少${type == 1 ? '支出' : '收入'}分类,请先创建分类'); + } + bills.add(BillRequest( + categoryId: categoryId, + amount: r.absAmount, + description: r.merchant ?? '', + billDate: parseBillDate(r.date), + type: type, + )); + } + await ref.read(billApiProvider).createBillsBatch(bills); + // 让首页列表与统计页重新取数 + invalidateBillData(ref); + state = const OcrState(); + } on ApiException catch (e) { + state = state.copyWith(error: e.message); + rethrow; + } finally { + state = state.copyWith(saving: false); + } + } +} + +final ocrProvider = NotifierProvider(OcrNotifier.new); diff --git a/lib/providers/statistics_provider.dart b/lib/providers/statistics_provider.dart new file mode 100644 index 0000000..8eb8b21 --- /dev/null +++ b/lib/providers/statistics_provider.dart @@ -0,0 +1,35 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/statistics_api.dart'; +import '../models/statistics_response.dart'; +import '../utils/date_util.dart'; + +/// 统计页的时间粒度 +enum StatisticsTab { daily, weekly, monthly } + +class StatisticsTabNotifier extends Notifier { + @override + StatisticsTab build() => StatisticsTab.monthly; // 对齐 uniApp 默认按月 + + void set(StatisticsTab tab) => state = tab; +} + +final statisticsTabProvider = + NotifierProvider(StatisticsTabNotifier.new); + +/// 按时间粒度取统计。日期范围在 provider 内按"当前时刻"计算, +/// 与 uniApp 行为一致:日=今天,周=本周一~今天,月=本自然月。 +final statisticsProvider = + FutureProvider.autoDispose.family((ref, tab) { + final api = ref.watch(statisticsApiProvider); + final now = DateTime.now(); + switch (tab) { + case StatisticsTab.daily: + final today = formatDate(now); + return api.getDaily(startDate: today, endDate: today); + case StatisticsTab.weekly: + return api.getWeekly(weekStart: formatDate(weekStart(now))); + case StatisticsTab.monthly: + return api.getMonthly(year: now.year, month: now.month); + } +}); diff --git a/lib/utils/amount_input_formatter.dart b/lib/utils/amount_input_formatter.dart new file mode 100644 index 0000000..4127e22 --- /dev/null +++ b/lib/utils/amount_input_formatter.dart @@ -0,0 +1,23 @@ +import 'package:flutter/services.dart'; + +/// 金额输入过滤:仅数字 + 单小数点 + 最多 2 位小数 +/// +/// [allowNegative] 为 true 时允许开头的负号(账户余额可能为负)。 +/// 记账金额、预算不使用负号,保持默认 false。 +class AmountInputFormatter extends TextInputFormatter { + AmountInputFormatter({this.allowNegative = false}); + + final bool allowNegative; + + static final _positiveRegex = RegExp(r'^\d*\.?\d{0,2}$'); + static final _negativeRegex = RegExp(r'^-?\d*\.?\d{0,2}$'); + + @override + TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { + final text = newValue.text; + if (text.isEmpty) return newValue; + final regex = allowNegative ? _negativeRegex : _positiveRegex; + if (!regex.hasMatch(text)) return oldValue; + return newValue; + } +} diff --git a/lib/utils/amount_util.dart b/lib/utils/amount_util.dart new file mode 100644 index 0000000..b543d16 --- /dev/null +++ b/lib/utils/amount_util.dart @@ -0,0 +1,27 @@ +/// 金额展示与计算工具 +/// +/// 统一入口的意义:后端返回 double,可能出现 null / NaN / Infinity, +/// 直接 toStringAsFixed 会抛异常或显示 "NaN",所以全部走这里兜底。 +library; + +/// 金额展示:固定两位小数,非法值回退 '0.00' +String formatAmount(double? value) { + if (value == null || !value.isFinite) return '0.00'; + return value.toStringAsFixed(2); +} + +/// 带符号金额:支出 '-¥12.00',收入 '+¥12.00' +/// +/// 是否支出必须由调用方显式传入(用 bill.type 判断), +/// 不要用金额正负判断——后端返回的 amount 恒为正数。 +String formatSignedAmount(double? amount, {required bool isExpense}) { + final text = formatAmount(amount?.abs()); + return isExpense ? '-¥$text' : '+¥$text'; +} + +/// 占比(0~100)。分母 <= 0 或非法时返回 0,避免除零与 NaN +double percentOf(double part, double total) { + if (!part.isFinite || !total.isFinite || total <= 0) return 0; + final pct = part / total * 100; + return pct.clamp(0, 100); +} diff --git a/lib/utils/bill_grouping.dart b/lib/utils/bill_grouping.dart new file mode 100644 index 0000000..0f7e1c5 --- /dev/null +++ b/lib/utils/bill_grouping.dart @@ -0,0 +1,45 @@ +import '../models/bill.dart'; + +/// 按日期分组的账单 +class BillDayGroup { + final String date; // yyyy-MM-dd + final List bills; + final double expenseTotal; // type==1 的合计 + final double incomeTotal; // type==2 的合计 + + const BillDayGroup({ + required this.date, + required this.bills, + required this.expenseTotal, + required this.incomeTotal, + }); +} + +/// 按 billDate 分组,并分别累计当日支出/收入。 +/// +/// 后端已按 `billDate DESC, createTime DESC` 排序,Dart 的 Map 保持插入顺序, +/// 因此直接顺序遍历累积即可,不要再次排序(否则会打乱同日内的时间顺序)。 +List groupBillsByDate(List bills) { + final grouped = >{}; + for (final bill in bills) { + grouped.putIfAbsent(bill.billDate, () => []).add(bill); + } + + return grouped.entries.map((entry) { + var expense = 0.0; + var income = 0.0; + for (final bill in entry.value) { + if (bill.isExpense) { + expense += bill.absAmount; + } else { + income += bill.absAmount; + } + } + return BillDayGroup( + date: entry.key, + bills: entry.value, + expenseTotal: expense, + incomeTotal: income, + ); + }).toList(); +} diff --git a/lib/utils/crypto.dart b/lib/utils/crypto.dart new file mode 100644 index 0000000..de79041 --- /dev/null +++ b/lib/utils/crypto.dart @@ -0,0 +1,29 @@ +import 'dart:convert'; + +/// 与 uniApp 端 utils/crypto.js 完全一致的密码混淆算法(XOR + Base64)。 +/// 注意:仅防明文存储,非强加密。 +const String _secretKey = 'QIANGBAO_AI_JIZHANG_2024'; + +String encryptPassword(String password) { + if (password.isEmpty) return ''; + final chars = password.codeUnits; + final keyUnits = _secretKey.codeUnits; + final encrypted = String.fromCharCodes([ + for (var i = 0; i < chars.length; i++) chars[i] ^ keyUnits[i % keyUnits.length], + ]); + return base64Encode(utf8.encode(encrypted)); +} + +String decryptPassword(String encrypted) { + if (encrypted.isEmpty) return ''; + try { + final decoded = utf8.decode(base64Decode(encrypted)); + final chars = decoded.codeUnits; + final keyUnits = _secretKey.codeUnits; + return String.fromCharCodes([ + for (var i = 0; i < chars.length; i++) chars[i] ^ keyUnits[i % keyUnits.length], + ]); + } catch (_) { + return ''; + } +} diff --git a/lib/utils/date_util.dart b/lib/utils/date_util.dart new file mode 100644 index 0000000..5f510bb --- /dev/null +++ b/lib/utils/date_util.dart @@ -0,0 +1,68 @@ +import 'package:intl/intl.dart'; + +/// billDate 标准格式 yyyy-MM-dd +String formatDate(DateTime date) => DateFormat('yyyy-MM-dd').format(date); + +/// 解析后端日期字段,兼容 yyyy-MM-dd 与 yyyy-MM-ddTHH:mm:ss(LocalDateTime)两种格式 +String parseBillDate(String? raw) { + if (raw == null || raw.isEmpty) return formatDate(DateTime.now()); + final tIndex = raw.indexOf('T'); + if (tIndex > 0) return raw.substring(0, tIndex); + final spaceIndex = raw.indexOf(' '); + if (spaceIndex > 0) return raw.substring(0, spaceIndex); + return raw; +} + +/// 解析后端日期为 DateTime,失败返回 null +DateTime? tryParseDate(String? raw) { + if (raw == null || raw.isEmpty) return null; + return DateTime.tryParse(raw); +} + +// ---------------- 月份 / 周 相关(首页、统计页用) ---------------- + +/// 当月 1 日 00:00,作为月份锚点使用 +DateTime monthStart(DateTime date) => DateTime(date.year, date.month, 1); + +/// 当月最后一天 00:00。 +/// 用 day = 0 让 DateTime 自动回退到上个月最后一天,避免手算 28/30/31。 +DateTime monthEnd(DateTime date) => DateTime(date.year, date.month + 1, 0); + +/// 月份平移(delta 为月数,可为负),跨年由 DateTime 自动归一 +DateTime shiftMonth(DateTime month, int delta) => + DateTime(month.year, month.month + delta, 1); + +/// 本周一 00:00(以周一为一周起点,对齐 uniApp 的 getWeekStart) +DateTime weekStart(DateTime date) { + final d = DateTime(date.year, date.month, date.day); + return d.subtract(Duration(days: d.weekday - DateTime.monday)); +} + +/// 是否为同一个自然月 +bool isSameMonth(DateTime a, DateTime b) => a.year == b.year && a.month == b.month; + +/// 月份标题:2026年9月 +String formatMonthTitle(DateTime month) => '${month.year}年${month.month}月'; + +const List _weekdayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; + +/// 分组头日期:今天 / 昨天 / 09-21 周一 +/// +/// 注意:不要用 DateFormat('EEEE', 'zh_CN')——本项目未调用 +/// initializeDateFormatting,会抛 LocaleDataException,所以手写星期名。 +String formatGroupDate(String billDate) { + final parsed = tryParseDate(billDate); + if (parsed == null) return billDate; + + final today = DateTime.now(); + final todayStart = DateTime(today.year, today.month, today.day); + final target = DateTime(parsed.year, parsed.month, parsed.day); + final diff = todayStart.difference(target).inDays; + + if (diff == 0) return '今天'; + if (diff == 1) return '昨天'; + + final mm = target.month.toString().padLeft(2, '0'); + final dd = target.day.toString().padLeft(2, '0'); + return '$mm-$dd ${_weekdayNames[target.weekday - 1]}'; +} diff --git a/lib/utils/jwt_util.dart b/lib/utils/jwt_util.dart new file mode 100644 index 0000000..b055ad4 --- /dev/null +++ b/lib/utils/jwt_util.dart @@ -0,0 +1,25 @@ +import 'dart:convert'; + +/// 本地解析 JWT 的 exp 字段,判断是否已过期。 +/// +/// 目的:App 启动时立刻发现"登录已过期",不必先发一次请求才报错。 +/// 注意:只做本地时间判断,不校验签名——签名的真伪由后端负责。 +/// 返回 false(视为未过期)的条件:token 不是标准 JWT 格式或没有 exp 字段, +/// 避免因格式差异把正常登录态误判为过期。 +bool isJwtExpired(String? token, {Duration leeway = const Duration(seconds: 30)}) { + if (token == null || token.isEmpty) return true; + final parts = token.split('.'); + if (parts.length != 3) return false; + + try { + final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))); + final claims = jsonDecode(payload); + if (claims is! Map) return false; + final exp = claims['exp']; + if (exp is! num) return false; // 无 exp 字段 → 不做本地判断 + final expireAt = DateTime.fromMillisecondsSinceEpoch(exp.toInt() * 1000); + return expireAt.isBefore(DateTime.now().add(leeway)); + } catch (_) { + return false; + } +} diff --git a/lib/utils/toast.dart b/lib/utils/toast.dart new file mode 100644 index 0000000..b0d7398 --- /dev/null +++ b/lib/utils/toast.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + +import '../core/navigation/navigator_key.dart'; + +/// 统一提示封装(对齐 uni.showToast 的常用场景) +void showToast(BuildContext context, String message, {bool error = false}) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: error ? const Color(0xFFB71C1C) : const Color(0xFF323232), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + ), + ); +} + +/// 全局提示:供没有 BuildContext 的地方调用(网络层、Provider) +void showGlobalToast(String message, {bool error = false}) { + final context = navigatorKey.currentContext; + if (context != null) showToast(context, message, error: error); +} diff --git a/lib/widgets/category_picker_grid.dart b/lib/widgets/category_picker_grid.dart new file mode 100644 index 0000000..77ea6e3 --- /dev/null +++ b/lib/widgets/category_picker_grid.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; + +import '../models/category.dart'; + +/// 分类 emoji 宫格(手动记账页与账单编辑页共用) +/// +/// 这是纯同步组件:分类列表的加载/错误/空态由调用方用 AsyncValue.when 处理, +/// 组件本身不关心 type 之类的业务参数。 +class CategoryPickerGrid extends StatelessWidget { + const CategoryPickerGrid({ + super.key, + required this.categories, + required this.selectedId, + required this.onSelect, + this.crossAxisCount = 5, + this.emptyText = '暂无分类', + }); + + final List categories; + final int? selectedId; + final ValueChanged onSelect; + final int crossAxisCount; + final String emptyText; + + @override + Widget build(BuildContext context) { + if (categories.isEmpty) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(14), + ), + child: Text(emptyText, style: const TextStyle(color: Color(0xFF8D6E63))), + ); + } + + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: 10, + crossAxisSpacing: 6, + childAspectRatio: 0.85, + ), + itemCount: categories.length, + itemBuilder: (context, i) => _item(categories[i]), + ); + } + + Widget _item(Category category) { + final active = selectedId == category.id; + return GestureDetector( + onTap: () => onSelect(category.id), + child: Column( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 52, + height: 52, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: active ? const Color(0xFFC8956E) : Colors.white, + border: Border.all( + color: active ? const Color(0xFF5D4037) : const Color(0xFFE0C9A6), + ), + ), + alignment: Alignment.center, + child: Text(category.icon, style: const TextStyle(fontSize: 24)), + ), + const SizedBox(height: 4), + Text( + category.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: active ? const Color(0xFF5D4037) : const Color(0xFF8D6E63), + fontWeight: active ? FontWeight.bold : FontWeight.normal, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/donut_chart.dart b/lib/widgets/donut_chart.dart new file mode 100644 index 0000000..110f987 --- /dev/null +++ b/lib/widgets/donut_chart.dart @@ -0,0 +1,87 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; + +/// 一个扇区 +class DonutSlice { + final double value; + final Color color; + final String label; + + const DonutSlice({required this.value, required this.color, this.label = ''}); +} + +/// 图表配色:以主题橙棕/金色为主,穿插少量对比色便于区分分类 +const List kChartPalette = [ + Color(0xFFC8956E), // 橙棕(主色) + Color(0xFFFFB74D), // 亮橙 + Color(0xFFE57373), // 红 + Color(0xFF81C784), // 绿 + Color(0xFF64B5F6), // 蓝 + Color(0xFFBA68C8), // 紫 + Color(0xFF4DB6AC), // 青 + Color(0xFFF06292), // 粉 + Color(0xFFA1887F), // 棕灰 + Color(0xFF90A4AE), // 蓝灰 +]; + +Color chartColorAt(int index) => kChartPalette[index % kChartPalette.length]; + +/// 通用环形图(首页预算环 + 统计页分类饼图共用) +/// +/// 内部处理了 fl_chart 的空数据问题:`PieChartSectionData.value` 必须 > 0, +/// 全 0 / NaN 会算出 NaN 角度导致渲染异常甚至崩溃,所以这里统一过滤并兜底成灰环。 +/// fl_chart 类型只出现在本文件,升级图表库时只需改这一处。 +class DonutChart extends StatelessWidget { + const DonutChart({ + super.key, + required this.slices, + this.size = 150, + this.centerSpaceRadius = 46, + this.sectionsSpace = 2, + this.center, + }); + + final List slices; + final double size; + final double centerSpaceRadius; + final double sectionsSpace; + + /// 圆心内容,例如 Text('68%') + final Widget? center; + + @override + Widget build(BuildContext context) { + final valid = slices.where((s) => s.value.isFinite && s.value > 0).toList(); + // 无有效数据时退化为单段灰环,避免空数据渲染问题 + final effective = valid.isEmpty + ? const [DonutSlice(value: 1, color: Color(0xFFE0C9A6))] + : valid; + + final ringRadius = (size / 2 - centerSpaceRadius).clamp(8.0, double.infinity); + + return SizedBox( + width: size, + height: size, + child: Stack( + alignment: Alignment.center, + children: [ + PieChart( + PieChartData( + sections: effective + .map((s) => PieChartSectionData( + value: s.value, + color: s.color, + radius: ringRadius, + showTitle: false, + )) + .toList(), + centerSpaceRadius: centerSpaceRadius, + sectionsSpace: sectionsSpace, + ), + ), + ?center, + ], + ), + ); + } +} diff --git a/lib/widgets/state_views.dart b/lib/widgets/state_views.dart new file mode 100644 index 0000000..42974c3 --- /dev/null +++ b/lib/widgets/state_views.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; + +/// 加载态 +class LoadingView extends StatelessWidget { + const LoadingView({super.key, this.tip = '加载中...', this.padding = const EdgeInsets.all(40)}); + + final String tip; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + return Padding( + padding: padding, + child: Column( + children: [ + const SizedBox( + width: 26, + height: 26, + child: CircularProgressIndicator(strokeWidth: 2.5, color: Color(0xFFC8956E)), + ), + const SizedBox(height: 12), + Text(tip, style: const TextStyle(fontSize: 13, color: Color(0xFF8D6E63))), + ], + ), + ); + } +} + +/// 错误态(可带重试) +class ErrorView extends StatelessWidget { + const ErrorView({super.key, required this.message, this.onRetry}); + + final String message; + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(14), + ), + child: Column( + children: [ + const Text('😵', style: TextStyle(fontSize: 30)), + const SizedBox(height: 8), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13, color: Color(0xFF8D6E63)), + ), + if (onRetry != null) ...[ + const SizedBox(height: 12), + OutlinedButton( + onPressed: onRetry, + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFFC8956E), + side: const BorderSide(color: Color(0xFFC8956E)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), + ), + child: const Text('重试'), + ), + ], + ], + ), + ); + } +} + +/// 空状态 +class EmptyView extends StatelessWidget { + const EmptyView({ + super.key, + this.icon = '📭', + required this.title, + this.tip, + this.padding = const EdgeInsets.symmetric(vertical: 40), + }); + + final String icon; + final String title; + final String? tip; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + return Padding( + padding: padding, + child: Column( + children: [ + Text(icon, style: const TextStyle(fontSize: 44)), + const SizedBox(height: 10), + Text(title, style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037))), + if (tip != null) ...[ + const SizedBox(height: 6), + Text( + tip!, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 12.5, color: Color(0xFFBCAAA4)), + ), + ], + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..e6780d8 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,586 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.3" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.3.5+5" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.9" + dio: + dependency: "direct main" + description: + name: dio + sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.11.1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.2" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.9.4+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.9.5+1" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.9.3+6" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: b938f77d042cbcd822936a7a359a7235bad8bd72070de1f827efc2cc297ac888 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.35" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "2b7f9d2a3c730ac1a98221b420cef966b990fca7ab546ede324642ca57a533e3" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.4.3" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.3" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "0a55d645a670d6ae11efa948f955ae904afa472f1344fc15a7ddacc20c1e7219" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.8.13+23" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: ee3885b6fcd71958fbc79770dd194c63371439d536d69c47b279171a486482ae + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.8.13+7" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.20.3" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.flutter-io.cn" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.1.0" + listen: + dependency: transitive + description: + name: listen + sha256: "47501a08016a43fcad79252439d723f50f14f88fa7bfd8a177e0a417e5c9e1f2" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.1" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.19.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.8" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "484dfc873ea4c4f4240e5635444fa066f87b07862b7279ebd004a2b71fba4b7a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.4.3" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.28" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.12.2" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.12" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.2" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "15.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.0" +sdks: + dart: ">=3.13.3 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..425fc89 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,85 @@ +name: qiangbao_accounting +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.13.3 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + flutter_riverpod: ^3.0.0 + dio: ^5.8.0 + image_picker: ^1.2.0 + shared_preferences: ^2.3.0 + intl: ^0.20.0 + fl_chart: ^1.2.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + + uses-material-design: true + + assets: + - assets/images/ + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/test/account_test.dart b/test/account_test.dart new file mode 100644 index 0000000..101006c --- /dev/null +++ b/test/account_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:qiangbao_accounting/models/account.dart'; + +void main() { + group('余额反算 initialBalanceFor', () { + // 后端公式:balance = initialBalance + totalIncome - totalExpense + test('无收支时目标余额就是期初余额', () { + expect( + initialBalanceFor(targetBalance: 5000, totalIncome: 0, totalExpense: 0), + 5000, + ); + }); + + test('有收入时需扣除收入', () { + // 收入 8000,想让余额显示 5000 → 期初 = 5000 - 8000 = -3000 + expect( + initialBalanceFor(targetBalance: 5000, totalIncome: 8000, totalExpense: 0), + -3000, + ); + }); + + test('有支出时需加回支出', () { + // 支出 1200,想让余额显示 5000 → 期初 = 5000 + 1200 = 6200 + expect( + initialBalanceFor(targetBalance: 5000, totalIncome: 0, totalExpense: 1200), + 6200, + ); + }); + + test('收支都有时综合换算', () { + // 收 8000 支 1200,目标 5000 → 5000 - 8000 + 1200 = -1800 + expect( + initialBalanceFor(targetBalance: 5000, totalIncome: 8000, totalExpense: 1200), + -1800, + ); + }); + + test('换算后余额能被后端公式还原(往返一致)', () { + const target = 12345.67; + const income = 8000.0; + const expense = 3456.78; + final initial = initialBalanceFor( + targetBalance: target, + totalIncome: income, + totalExpense: expense, + ); + // 模拟后端计算 + final balance = initial + income - expense; + expect(balance, closeTo(target, 0.001)); + }); + + test('目标余额非法时返回 0', () { + expect( + initialBalanceFor(targetBalance: double.nan, totalIncome: 100, totalExpense: 0), + 0, + ); + expect( + initialBalanceFor(targetBalance: double.infinity, totalIncome: 0, totalExpense: 0), + 0, + ); + }); + }); + + group('AccountInfo.fromJson 防御性', () { + test('字段缺失时不抛异常并按公式兜底算余额', () { + final a = AccountInfo.fromJson({ + 'id': '3', + 'initialBalance': '1000', + 'totalIncome': 500, + 'totalExpense': '200.5', + // name / balance 缺失 + }); + expect(a.id, 3); + expect(a.name, '默认账户'); + expect(a.initialBalance, 1000); + expect(a.totalIncome, 500); + expect(a.totalExpense, 200.5); + expect(a.balance, 1000 + 500 - 200.5); // 本地兜底计算 + }); + + test('后端给了 balance 时优先使用后端值', () { + final a = AccountInfo.fromJson({ + 'initialBalance': 1000, + 'totalIncome': 500, + 'totalExpense': 200, + 'balance': 9999, // 故意与公式不一致 + }); + expect(a.balance, 9999); + }); + + test('完全空对象返回零值账户', () { + final a = AccountInfo.fromJson({}); + expect(a.balance, 0); + expect(a.totalIncome, 0); + expect(a.totalExpense, 0); + }); + }); +} diff --git a/test/bill_logic_test.dart b/test/bill_logic_test.dart new file mode 100644 index 0000000..f8d7a93 --- /dev/null +++ b/test/bill_logic_test.dart @@ -0,0 +1,142 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:qiangbao_accounting/models/bill.dart'; +import 'package:qiangbao_accounting/utils/amount_util.dart'; +import 'package:qiangbao_accounting/utils/bill_grouping.dart'; +import 'package:qiangbao_accounting/utils/date_util.dart'; + +Bill bill({ + required int id, + required String date, + required double amount, + required int type, + String description = '', +}) => + Bill( + id: id, + categoryId: 1, + categoryName: '餐饮', + categoryIcon: '🍔', + amount: amount, + description: description, + billDate: date, + type: type, + ); + +void main() { + group('月份/周界工具', () { + test('monthStart 取当月 1 日', () { + expect(formatDate(monthStart(DateTime(2026, 9, 21))), '2026-09-01'); + }); + + test('monthEnd 处理 28/30/31 天与闰年 2 月', () { + expect(formatDate(monthEnd(DateTime(2026, 9, 5))), '2026-09-30'); + expect(formatDate(monthEnd(DateTime(2026, 2, 5))), '2026-02-28'); + expect(formatDate(monthEnd(DateTime(2028, 2, 5))), '2028-02-29'); // 闰年 + expect(formatDate(monthEnd(DateTime(2026, 12, 5))), '2026-12-31'); + }); + + test('shiftMonth 跨年正确', () { + expect(formatDate(shiftMonth(DateTime(2026, 1, 1), -1)), '2025-12-01'); + expect(formatDate(shiftMonth(DateTime(2026, 12, 1), 1)), '2027-01-01'); + }); + + test('weekStart 返回本周一', () { + // 2026-09-21 是周一 + expect(formatDate(weekStart(DateTime(2026, 9, 21))), '2026-09-21'); + // 周日应回到本周一(9/20 是周日 → 9/14) + expect(formatDate(weekStart(DateTime(2026, 9, 20))), '2026-09-14'); + // 跨月:2026-10-01 是周四 → 本周一 9/28 + expect(formatDate(weekStart(DateTime(2026, 10, 1))), '2026-09-28'); + }); + + test('isSameMonth', () { + expect(isSameMonth(DateTime(2026, 9, 1), DateTime(2026, 9, 30)), isTrue); + expect(isSameMonth(DateTime(2026, 9, 1), DateTime(2026, 10, 1)), isFalse); + }); + + test('parseBillDate 兼容 LocalDateTime', () { + expect(parseBillDate('2026-09-21T12:30:00'), '2026-09-21'); + expect(parseBillDate('2026-09-21 12:30:00'), '2026-09-21'); + expect(parseBillDate('2026-09-21'), '2026-09-21'); + }); + }); + + group('金额工具', () { + test('formatAmount 两位小数并挡掉非法值', () { + expect(formatAmount(12.5), '12.50'); + expect(formatAmount(null), '0.00'); + expect(formatAmount(double.nan), '0.00'); + expect(formatAmount(double.infinity), '0.00'); + }); + + test('formatSignedAmount 按类型给符号(不看金额正负)', () { + expect(formatSignedAmount(12.0, isExpense: true), '-¥12.00'); + expect(formatSignedAmount(12.0, isExpense: false), '+¥12.00'); + }); + + test('percentOf 防除零', () { + expect(percentOf(25, 100), 25); + expect(percentOf(25, 0), 0); + expect(percentOf(0, 0), 0); + }); + }); + + group('账单分组', () { + test('按日期分组并分别累计支出/收入', () { + final groups = groupBillsByDate([ + bill(id: 1, date: '2026-09-21', amount: 30, type: 1), + bill(id: 2, date: '2026-09-21', amount: 20, type: 1), + bill(id: 3, date: '2026-09-21', amount: 5000, type: 2), + bill(id: 4, date: '2026-09-20', amount: 15.5, type: 1), + ]); + + expect(groups.length, 2); + // 保持输入顺序(后端已按日期倒序) + expect(groups[0].date, '2026-09-21'); + expect(groups[0].bills.length, 3); + expect(groups[0].expenseTotal, 50); + expect(groups[0].incomeTotal, 5000); + // 同日内不重排,保持后端 createTime 倒序 + expect(groups[0].bills.map((b) => b.id).toList(), [1, 2, 3]); + + expect(groups[1].date, '2026-09-20'); + expect(groups[1].expenseTotal, 15.5); + expect(groups[1].incomeTotal, 0); + }); + + test('空列表返回空分组', () { + expect(groupBillsByDate([]), isEmpty); + }); + + test('金额恒为正数时按 type 判断收支方向', () { + final expense = bill(id: 1, date: '2026-09-21', amount: 30, type: 1); + final income = bill(id: 2, date: '2026-09-21', amount: 30, type: 2); + expect(expense.isExpense, isTrue); + expect(income.isExpense, isFalse); + expect(expense.absAmount, 30); + }); + }); + + group('Bill.fromJson 防御性', () { + test('字段缺失/类型不符不抛异常', () { + final b = Bill.fromJson({ + 'id': '12', // 字符串数字 + 'amount': '35.5', // 字符串金额 + 'billDate': '2026-09-21T10:00:00', + // categoryName / categoryIcon / type 缺失 + }); + expect(b.id, 12); + expect(b.amount, 35.5); + expect(b.billDate, '2026-09-21'); + expect(b.categoryName, '未分类'); + expect(b.categoryIcon, '📦'); + expect(b.type, 1); // 缺省按支出 + }); + + test('type 为 2 时是收入', () { + final b = Bill.fromJson({'id': 1, 'amount': 100, 'billDate': '2026-09-21', 'type': 2}); + expect(b.isExpense, isFalse); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..38c22e2 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:qiangbao_accounting/utils/crypto.dart'; +import 'package:qiangbao_accounting/utils/jwt_util.dart'; + +/// 构造一个只含 payload 的假 JWT(本地 exp 判断不校验签名) +String fakeJwt({DateTime? expiredAt, bool withExp = true}) { + String seg(Map m) => + base64Url.encode(utf8.encode(jsonEncode(m))).replaceAll('=', ''); + final header = seg({'alg': 'HS512'}); + final payload = seg({ + 'sub': 'tester', + if (withExp) + 'exp': (expiredAt ?? DateTime.now().add(const Duration(hours: 1))) + .millisecondsSinceEpoch ~/ + 1000, + }); + return '$header.$payload.signature'; +} + +void main() { + test('crypto encrypt/decrypt roundtrip 与 uniApp 端算法一致', () { + const password = 'abc12345'; + final encrypted = encryptPassword(password); + expect(encrypted, isNotEmpty); + expect(encrypted, isNot(password)); + expect(decryptPassword(encrypted), password); + }); + + group('JWT 过期判断', () { + test('未来的 exp 视为未过期', () { + expect( + isJwtExpired(fakeJwt(expiredAt: DateTime.now().add(const Duration(hours: 1)))), + isFalse, + ); + }); + + test('过去的 exp 视为已过期', () { + expect( + isJwtExpired(fakeJwt(expiredAt: DateTime.now().subtract(const Duration(hours: 1)))), + isTrue, + ); + }); + + test('即将过期(落在 30 秒余量内)视为已过期', () { + expect( + isJwtExpired(fakeJwt(expiredAt: DateTime.now().add(const Duration(seconds: 5)))), + isTrue, + ); + }); + + test('无 exp 字段不做本地判断,避免误判', () { + expect(isJwtExpired(fakeJwt(withExp: false)), isFalse); + }); + + test('非 JWT 格式不做本地判断;空 token 视为无效', () { + expect(isJwtExpired('not-a-jwt'), isFalse); + expect(isJwtExpired(''), isTrue); + expect(isJwtExpired(null), isTrue); + }); + }); +} diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..36767de --- /dev/null +++ b/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + qiangbao_accounting + + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..0480893 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "qiangbao_accounting", + "short_name": "qiangbao_accounting", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +}