first commit

This commit is contained in:
2026-09-21 17:37:53 +08:00
commit 67d4dd5b7c
141 changed files with 8558 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
db/
+25
View File
@@ -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` ③ 补桌面端平台目录。用户尚未选择。
+37
View File
@@ -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<XxxApi>(...)`。
- 本地存储:`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、多端适配、官方文档出处感兴趣,回答时应附**官方文档链接**。
+48
View File
@@ -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/
+36
View File
@@ -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'
+17
View File
@@ -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.
+35
View File
@@ -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
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+49
View File
@@ -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 = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+47
View File
@@ -0,0 +1,47 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="qiangbao_accounting"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package com.qiangbao.qiangbao_accounting
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+10
View File
@@ -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
+5
View File
@@ -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
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.1.0" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
}
include(":app")
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+411
View File
@@ -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
<uses-permission android:name="android.permission.INTERNET"/>
<application android:usesCleartextTraffic="true" ...>
```
用于 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 <apk路径> # 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` 热重载,几乎无等待。
+34
View File
@@ -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
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+647
View File
@@ -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 = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* 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 = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
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 = "<group>";
};
/* 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 = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* 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 */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
+16
View File
@@ -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)
}
}
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

@@ -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.
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
+74
View File
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSCameraUsageDescription</key>
<string>用于拍摄账单照片进行OCR识别</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>用于选择账单截图进行OCR识别</string>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Qiangbao Accounting</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>qiangbao_accounting</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
+1
View File
@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"
+6
View File
@@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}
+12
View File
@@ -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.
}
}
+54
View File
@@ -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<AccountApi>((ref) => AccountApi(ref.watch(dioProvider)));
/// 账户接口(后端每个用户只有一个默认账户)
class AccountApi {
final Dio _dio;
AccountApi(this._dio);
/// 账户信息:余额、总收入、总支出(收支为全部账单汇总,只读)
Future<AccountInfo> getAccount() async {
try {
final res = await _dio.get('/accounts');
return AccountInfo.fromJson(_parseObject(res.data));
} on DioException catch (e) {
throw _unwrap(e);
}
}
/// 更新余额。
///
/// 注意:后端这个接口写入的是**期初余额**,传入前需用
/// `initialBalanceFor()` 把目标余额换算成期初余额。
Future<AccountInfo> 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<String, dynamic> _parseObject(Object? data) {
if (data is Map<String, dynamic>) return data;
if (data is Map && data['data'] is Map) {
return Map<String, dynamic>.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, '账户操作失败');
}
}
+52
View File
@@ -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<AuthApi>((ref) => AuthApi(ref.watch(dioProvider)));
class AuthApi {
final Dio _dio;
AuthApi(this._dio);
/// 登录 → {token, username, nickname}
Future<AuthResponse> 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<AuthResponse> 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<String, dynamic> _map(Object? data) {
if (data is Map<String, dynamic>) return data;
throw ApiException(-1, '响应格式异常');
}
Never _unwrap(DioException e) {
final err = e.error;
if (err is ApiException) throw err;
throw ApiException(-1, '请求失败');
}
}
+104
View File
@@ -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<BillApi>((ref) => BillApi(ref.watch(dioProvider)));
class BillApi {
final Dio _dio;
BillApi(this._dio);
/// 账单列表,可选日期区间(yyyy-MM-dd)。
/// 后端返回裸数组,无分页。
Future<List<Bill>> getBills({String? startDate, String? endDate}) async {
try {
final params = <String, dynamic>{};
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<Bill> 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<void> createBill(BillRequest bill) async {
try {
await _dio.post('/bills', data: bill.toJson());
} on DioException catch (e) {
throw _unwrap(e);
}
}
/// 批量创建账单(OCR 直接存储)
Future<void> createBillsBatch(List<BillRequest> 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<Bill> 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<void> deleteBill(int id) async {
try {
await _dio.delete('/bills/$id');
} on DioException catch (e) {
throw _unwrap(e);
}
}
/// 兼容裸数组与 {code,data:[...]} 两种形态
List<Map<String, dynamic>> _parseList(Object? data) {
if (data is List) {
return data.whereType<Map<String, dynamic>>().toList();
}
if (data is Map && data['data'] is List) {
return (data['data'] as List).whereType<Map<String, dynamic>>().toList();
}
throw ApiException(-1, '响应格式异常');
}
Map<String, dynamic> _parseObject(Object? data) {
if (data is Map<String, dynamic>) return data;
if (data is Map && data['data'] is Map) return Map<String, dynamic>.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, '请求失败');
}
}
+50
View File
@@ -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<BudgetApi>((ref) => BudgetApi(ref.watch(dioProvider)));
/// 预算接口(后端只提供当月预算)
class BudgetApi {
final Dio _dio;
BudgetApi(this._dio);
/// 获取当月预算。
///
/// 未设置预算时后端可能返回 204 / 空 body,此时返回 [BudgetInfo.empty]
/// 而不是抛错——否则首页会因为"没设预算"而进入错误态。
Future<BudgetInfo> 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<BudgetInfo> 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<String, dynamic>) return BudgetInfo.fromJson(data);
if (data is Map && data['data'] is Map) {
return BudgetInfo.fromJson(Map<String, dynamic>.from(data['data'] as Map));
}
return BudgetInfo.empty;
}
}
+34
View File
@@ -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<CategoryApi>((ref) => CategoryApi(ref.watch(dioProvider)));
class CategoryApi {
final Dio _dio;
CategoryApi(this._dio);
/// 获取分类列表,type: 1=支出 2=收入(后端可能返回裸数组或 {code,data})
Future<List<Category>> getCategories(int type) async {
try {
final res = await _dio.get('/categories', queryParameters: {'type': type});
return _parseList(res.data)
.whereType<Map<String, dynamic>>()
.map(Category.fromJson)
.toList();
} on DioException catch (e) {
final err = e.error;
throw err is ApiException ? err : ApiException(-1, '请求失败');
}
}
List<dynamic> _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, '分类响应格式异常');
}
}
+41
View File
@@ -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<OcrApi>((ref) => OcrApi(ref.watch(dioProvider)));
class OcrApi {
final Dio _dio;
OcrApi(this._dio);
/// 上传图片识别账单,字段名固定为 file(与 uniApp upload 对齐)。
/// 后端可能返回裸数组或 {code, data:[...]},两种都兼容。
Future<List<OcrResult>> 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<dynamic> 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<String, dynamic>>()
.map(OcrResult.fromJson)
.toList();
} on DioException catch (e) {
final err = e.error;
throw err is ApiException ? err : ApiException(-1, '识别失败,请重试');
}
}
}
+56
View File
@@ -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<StatisticsApi>((ref) => StatisticsApi(ref.watch(dioProvider)));
/// 统计接口。
///
/// 注意后端三个接口实际都委托给同一段按日聚合逻辑,区别只在参数:
/// daily 传日期区间,weekly 传周一,monthly 传年月。
class StatisticsApi {
final Dio _dio;
StatisticsApi(this._dio);
/// 按日期区间统计(yyyy-MM-dd)
Future<StatisticsResponse> getDaily({
required String startDate,
required String endDate,
}) async {
return _request('/statistics/daily', {
'startDate': startDate,
'endDate': endDate,
});
}
/// 按周统计,weekStart 为本周一(后端自行算到周内其余日期)
Future<StatisticsResponse> getWeekly({required String weekStart}) async {
return _request('/statistics/weekly', {'weekStart': weekStart});
}
/// 按月统计
Future<StatisticsResponse> getMonthly({required int year, required int month}) async {
return _request('/statistics/monthly', {'year': year, 'month': month});
}
Future<StatisticsResponse> _request(String path, Map<String, dynamic> params) async {
try {
final res = await _dio.get(path, queryParameters: params);
final data = res.data;
if (data is Map<String, dynamic>) return StatisticsResponse.fromJson(data);
if (data is Map && data['data'] is Map) {
return StatisticsResponse.fromJson(Map<String, dynamic>.from(data['data'] as Map));
}
// 空响应(无数据)按空统计处理,不视为错误
return StatisticsResponse.empty;
} on DioException catch (e) {
final err = e.error;
throw err is ApiException ? err : ApiException(-1, '统计加载失败');
}
}
}
+33
View File
@@ -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(),
);
}
}
+7
View File
@@ -0,0 +1,7 @@
import 'package:flutter/material.dart';
/// 全局 navigatorKey
///
/// 供没有 BuildContext 的地方使用(网络层 401/403 跳转、全局 toast)。
/// 独立成文件是为了避免 app.dart ↔ 网络层 之间的循环依赖。
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
+31
View File
@@ -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://<host>:12345/api`
const String _override = String.fromEnvironment('API_BASE_URL');
String get baseUrl {
if (_override.isNotEmpty) return _override;
if (!kDebugMode) return kProdBaseUrl;
return kLocalBaseUrl;
}
+16
View File
@@ -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());
}
+15
View File
@@ -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);
}
}
+51
View File
@@ -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<void> logoutAndGoLogin() async {
await _container?.read(authProvider.notifier).logout();
_goLoginPage();
}
/// 登录已失效(token 过期/无效):清本地登录态并跳登录页,给出提示
Future<void> 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),
),
);
});
}
+20
View File
@@ -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<Dio>((ref) {
final dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
));
dio.interceptors.addAll([
AuthInterceptor(),
ErrorInterceptor(),
]);
return dio;
});
+108
View File
@@ -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);
}
+73
View File
@@ -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<void> init() async {
_prefs ??= await SharedPreferences.getInstance();
}
Future<SharedPreferences> _getPrefs() async {
return _prefs ??= await SharedPreferences.getInstance();
}
// ---- 认证 ----
static const _keyToken = 'token';
static const _keyUserInfo = 'userInfo';
Future<void> setAuth({required String token, required Map<String, dynamic> userInfo}) async {
final prefs = await _getPrefs();
await prefs.setString(_keyToken, token);
await prefs.setString(_keyUserInfo, jsonEncode(userInfo));
}
Future<void> clearAuth() async {
final prefs = await _getPrefs();
await prefs.remove(_keyToken);
await prefs.remove(_keyUserInfo);
}
String? getToken() => _prefs?.getString(_keyToken);
Map<String, dynamic>? getUserInfo() {
final raw = _prefs?.getString(_keyUserInfo);
if (raw == null) return null;
try {
return jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
return null;
}
}
// ---- 记住密码 ----
static const _keySavedUsername = 'saved_username';
static const _keySavedPassword = 'saved_password';
Future<void> saveRememberedPassword(String username, String encryptedPassword) async {
final prefs = await _getPrefs();
await prefs.setString(_keySavedUsername, username);
await prefs.setString(_keySavedPassword, encryptedPassword);
}
Future<void> 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);
}
}
+23
View File
@@ -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<void> 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(),
),
);
}
+63
View File
@@ -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<String, dynamic> 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;
}
+29
View File
@@ -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<String, dynamic> 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<String, dynamic> toJson() => {'id': id, 'username': username, 'nickname': nickname};
factory UserInfo.fromJson(Map<String, dynamic> json) => UserInfo(
id: json['id']?.toString(),
username: json['username']?.toString() ?? '',
nickname: json['nickname']?.toString() ?? '',
);
}
+52
View File
@@ -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<String, dynamic> 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']),
);
}
+24
View File
@@ -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<String, dynamic> toJson() => {
'categoryId': categoryId,
'amount': amount,
'description': description,
'billDate': billDate,
'type': type,
};
}
+54
View File
@@ -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<String, dynamic> 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,
);
}
+24
View File
@@ -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<String, dynamic> 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(),
);
}
+36
View File
@@ -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<Map<String, dynamic>> asMapList(Object? value) {
if (value is! List) return const [];
return value.whereType<Map<String, dynamic>>().toList();
}
+25
View File
@@ -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<String, dynamic> json) => OcrResult(
amount: (json['amount'] as num?)?.toDouble() ?? 0,
merchant: json['merchant']?.toString(),
date: json['date']?.toString(),
confidence: (json['confidence'] as num?)?.toDouble(),
);
}
+90
View File
@@ -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<CategoryStat> categoryStatistics;
final List<DailyStat> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) => DailyStat(
date: asString(json['date']),
income: asDouble(json['income']),
expense: asDouble(json['expense']),
);
}
+94
View File
@@ -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<int> {
@override
int build() => 0;
void set(int index) => state = index;
}
final addTabProvider = NotifierProvider<AddTabNotifier, int>(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),
),
),
),
),
),
);
}
}
+247
View File
@@ -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<ManualEntryTab> createState() => _ManualEntryTabState();
}
class _ManualEntryTabState extends ConsumerState<ManualEntryTab> {
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<int>(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)),
),
);
}
}
+139
View File
@@ -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))),
),
],
),
);
}
}
+208
View File
@@ -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<void> _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<void> _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<void> _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);
},
),
],
),
),
);
}
}
+358
View File
@@ -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<BillEditPage> createState() => _BillEditPageState();
}
class _BillEditPageState extends ConsumerState<BillEditPage> {
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<int>(
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<void> _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<void> _confirmDelete(BillEditNotifier notifier) async {
final confirmed = await showDialog<bool>(
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);
}
}
}
+197
View File
@@ -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<StatisticsResponse> 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<Widget> _billSection(
BuildContext context,
WidgetRef ref,
AsyncValue<List<Bill>> 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<void> _onRefresh(WidgetRef ref) async {
final anchor = ref.read(monthAnchorProvider);
invalidateBillDataFromWidget(ref);
// 等三个请求结束再收起下拉动画;异常吞掉,由各自的 AsyncValue 渲染错误态
await Future.wait<void>([
_silent(ref.read(billsProvider(anchor).future)),
_silent(ref.read(monthlyStatisticsProvider(anchor).future)),
_silent(ref.read(budgetProvider.future)),
]);
}
Future<void> _silent(Future<Object?> future) =>
future.then<void>((_) {}, onError: (_, _) {});
}

Some files were not shown because too many files have changed in this diff Show More