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: (_, _) {});
|
||||
}
|
||||