first commit
@@ -0,0 +1 @@
|
|||||||
|
db/
|
||||||
@@ -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` ③ 补桌面端平台目录。用户尚未选择。
|
||||||
@@ -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、多端适配、官方文档出处感兴趣,回答时应附**官方文档链接**。
|
||||||
@@ -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/
|
||||||
@@ -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'
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
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>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
|
After Width: | Height: | Size: 1.0 MiB |
@@ -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` 热重载,几乎无等待。
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
@@ -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 "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||||
|
<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>
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
|
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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
#import "GeneratedPluginRegistrant.h"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import Flutter
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class SceneDelegate: FlutterSceneDelegate {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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, '账户操作失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, '请求失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, '请求失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, '分类响应格式异常');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, '识别失败,请重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, '统计加载失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// 全局 navigatorKey
|
||||||
|
///
|
||||||
|
/// 供没有 BuildContext 的地方使用(网络层 401/403 跳转、全局 toast)。
|
||||||
|
/// 独立成文件是为了避免 app.dart ↔ 网络层 之间的循环依赖。
|
||||||
|
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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() ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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']),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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']),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: (_, _) {});
|
||||||
|
}
|
||||||