app scaffold

This commit is contained in:
Guangfei.Zhao
2026-08-17 15:29:55 +08:00
commit 681688dfae
301 changed files with 18414 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"flutter": "3.44.9"
}
+73
View File
@@ -0,0 +1,73 @@
# ---------------------------------------------------------------------------
# 产物策略来源:conti-docs/14-conventions-and-ci-gates.md §四
#
# *.g.dart / *.freezed.dart 不提交(CI 会跑 melos run gen 重新生成)
# Pigeon 产物 提交(评审时能看到跨语言接口变更)
# 根 pubspec.lock 提交;各子包 pubspec.lock 不提交
# ---------------------------------------------------------------------------
# --- 代码生成产物 ---
**/*.g.dart
**/*.freezed.dart
**/*.mocks.dart
# Pigeon 产物必须入库:上面的 **/*.g.dart 会误伤 native_* 的生成文件,
# 这里显式反选回来。生成的 Kotlin/Swift 用的是 .g.kt / .g.swift,不受影响。
!packages/native_*/lib/src/generated/**
# --- 依赖锁 ---
# 根 lock 提交(保证所有人和 CI 拿到同一套版本),子包 lock 不提交。
app/pubspec.lock
packages/*/pubspec.lock
# Melos 8 + Pub Workspace 下不再生成 pubspec_overrides.yaml,保留兜底规则。
**/pubspec_overrides.yaml
# --- 签名与密钥:任何情况下都不能进仓库 ---
android/key.properties
app/android/key.properties
**/*.jks
**/*.keystore
**/*.p12
**/*.mobileprovision
**/*.cer
.env
.env.*
# --- Dart / Flutter ---
.dart_tool/
.packages
build/
.flutter-plugins
.flutter-plugins-dependencies
coverage/
doc/api/
# --- FVM ---
.fvm/
# --- Android ---
app/android/.gradle/
app/android/local.properties
app/android/app/debug/
app/android/app/profile/
app/android/app/release/
**/GeneratedPluginRegistrant.java
# --- iOS ---
app/ios/Pods/
app/ios/.symlinks/
app/ios/Flutter/Flutter.framework/
app/ios/Flutter/Flutter.podspec
app/ios/Flutter/Generated.xcconfig
app/ios/Flutter/flutter_export_environment.sh
**/Podfile.lock
**/*.xcworkspace/xcuserdata/
**/xcuserdata/
# --- 编辑器 ---
.idea/
*.iml
.vscode/*
!.vscode/launch.json
!.vscode/settings.json
.DS_Store
+140
View File
@@ -0,0 +1,140 @@
# CI 门禁。来源:conti-docs/14-conventions-and-ci-gates.md + 08-build-flavors.md
#
# ---------------------------------------------------------------------------
# 门禁的立法目的(14 §一):**约定只有被机器强制才叫约定**。下面每一条都对应
# 一条人工评审记不住、也不该由人来记的规则。
#
# TODO(ops): 镜像名和 mac runner 的 tag 都是占位符,两项文档自己就列为待确认:
# - Flutter 镜像:需要一个预装 Flutter 3.44.9 的内网镜像(公网 ghcr 拉不动)
# - iOS 构建:需要一台 mac runner,目前团队没有
# ---------------------------------------------------------------------------
stages:
- verify
- test
- build
default:
image: $FLUTTER_IMAGE # TODO(ops): 形如 registry.internal/flutter:3.44.9
tags:
- docker
before_script:
- dart pub global activate melos 8.2.2
- export PATH="$PATH:$HOME/.pub-cache/bin"
- melos bootstrap
variables:
# 产物不入库,每一步 CI 都得自己生成一遍(14 §四)。
PUB_CACHE: "$CI_PROJECT_DIR/.pub-cache"
cache:
key:
files:
- pubspec.lock
paths:
- .pub-cache
# ---------------------------------------------------------------------------
# verify:不跑测试就能发现的问题,全部拦在这里,因为它最快
# ---------------------------------------------------------------------------
format:
stage: verify
script:
- melos run format
# 格式化不是审美问题:不统一的话每个 PR 的 diff 里都混着大段无关的换行改动,
# 评审会开始跳过 diff。
analyze:
stage: verify
script:
- melos run gen
- melos run analyze
# --fatal-infos 在 melos 脚本里。不加等于 lint 形同虚设——绝大部分 riverpod_lint
# 规则报的是 info 级别。
pigeon-consistency:
stage: verify
script:
- melos run gen:pigeon
# Pigeon 产物是入库的。改了 pigeons/*.dart 却忘了重新生成,是一个编译期
# 完全看不出来、运行时才炸 MissingPluginException 的经典事故。
- git diff --exit-code || (echo "Pigeon 产物与 pigeons/ 不一致,请本地跑 melos run gen:pigeon 后提交" && exit 1)
# ---------------------------------------------------------------------------
# test
# ---------------------------------------------------------------------------
unit-test:
stage: test
script:
- melos run gen
- melos run test
coverage: '/lines\.*: \d+\.\d+\%/'
artifacts:
when: always
paths:
- packages/*/coverage/lcov.info
expire_in: 1 week
# 覆盖率门禁单独一个 job:它会失败得比较频繁,混在 unit-test 里会让人分不清
# 是"测试挂了"还是"覆盖率不够"。
coverage-gate:
stage: test
needs: [unit-test]
allow_failure: true # TODO: 骨架期先不卡人,等业务代码进来再改成 false
script:
- dart pub global activate coverde
- melos run coverage
# ---------------------------------------------------------------------------
# builddev/uat 按分支出包,prod 只认 tag
# ---------------------------------------------------------------------------
.android-build: &android-build
stage: build
script:
- melos run gen
- cd app
- >
flutter build apk
--flavor $FLAVOR
-t lib/main_$FLAVOR.dart
--dart-define-from-file=env/$FLAVOR.json
$EXTRA_ARGS
artifacts:
paths:
- app/build/app/outputs/flutter-apk/*.apk
expire_in: 1 month
android-dev:
<<: *android-build
variables:
FLAVOR: dev
rules:
- if: $CI_COMMIT_BRANCH == "develop"
android-uat:
<<: *android-build
variables:
FLAVOR: uat
rules:
- if: $CI_COMMIT_BRANCH == "main"
android-prod:
<<: *android-build
variables:
FLAVOR: prod
# release 包必须混淆 + 拆符号表,否则 Sentry 上的堆栈是一堆十六进制地址。
# --split-debug-info 的产物要留着,sentry_dart_plugin 靠它还原堆栈。
EXTRA_ARGS: --release --obfuscate --split-debug-info=build/symbols
after_script:
# SENTRY_AUTH_TOKEN 只从 CI 变量读,仓库里任何文件都不许出现它。
- cd app && dart run sentry_dart_plugin
artifacts:
paths:
- app/build/app/outputs/flutter-apk/*.apk
- app/build/symbols
expire_in: 1 year
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
# TODO(ops): iOS 构建需要 mac runner。配置步骤见 app/ios/FLAVORS.md
# 在 Scheme 建好并进 git 之前,这个 job 加了也跑不通。
+171
View File
@@ -0,0 +1,171 @@
# conti-retail-app
大陆马门店 App。Flutter + Melos 单仓多包。
架构约束全部来自相邻仓库 `conti-docs/` 的 01–14 号文档。**本仓库对这些文档的
偏离,逐条记在 [SCAFFOLD-NOTES.md](SCAFFOLD-NOTES.md)**——那份文件是回写文档的
依据,改动之前先看一眼。
---
## 怎么跑起来
三步。不要跳过第二步。
```bash
# 0. 工具链(一次性)
fvm use 3.44.9 # 或者保证 PATH 上的 flutter 就是 3.44.9
dart pub global activate melos 8.2.2
# 1. 装依赖
melos bootstrap
# 2. 生成代码(riverpod 的 *.g.dart 不入库,不跑这步全仓库编译不过)
melos run gen
# 3. 跑起来
cd app
flutter run --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json
```
`--flavor` / `-t` / `--dart-define-from-file` **三个参数缺一不可**
- 少了 `--dart-define-from-file``AppEnv.fromDartDefine` 会在启动瞬间抛错。
这是故意的——带着空 baseUrl 跑起来,问题会在第一个请求 404 时才暴露。
- 少了 `-t`Flutter 会去找 `lib/main.dart`,本仓库没有这个文件。
VS Code / Android Studio 用户建议把三条 flavor 配进 `launch.json`
## 环境与工具链版本
跑通时的实测版本,与 `pubspec.lock` 一致:
| | 版本 | 备注 |
|---|---|---|
| Flutter | 3.44.9 | `.fvmrc` 里锁着 |
| Dart | 3.12.2 | **Flutter 自带的那个**,见下面的坑 |
| Melos | 8.2.2 | 配置内联在根 `pubspec.yaml`,没有 `melos.yaml` |
| flutter_riverpod | 3.3.2 | |
| riverpod_annotation | 4.0.3 | |
| riverpod_generator | 4.0.4 | |
| build_runner | 2.15.1 | |
| analyzer | 12.1.0 | |
> **riverpod 的版本被 `flutter_test` 卡着。** `flutter_test` pin 了
> `test_api 0.7.11`,往上升 riverpod 会拉起不兼容的 `analyzer`,解析直接失败。
> 想升级先确认这条链路,不要只看 pub.dev 上的最新版。
### 坑:机器上有两个 Dart SDK
如果你的 PATH 上装了独立的 Dart SDK`dart --version` 不等于 3.12.2),那么
```bash
dart run build_runner build # ← 会用错 SDK,报一堆看不懂的解析错误
```
要显式用 Flutter 自带的那个:
```bash
export FDART="$(dirname "$(which flutter)")/cache/dart-sdk/bin/dart"
$FDART run build_runner build
# fvm 用户:~/fvm/versions/stable/bin/cache/dart-sdk/bin/dart
```
`melos run gen` 内部走的是 `dart run`,所以同样受影响。最省事的做法是让 PATH
上只有 Flutter 自带的 Dart。
### 坑:melos 命令
pub cache 里只有 `melos.bat`git-bash 下直接敲 `melos` 可能找不到。用:
```bash
dart pub global run melos:melos <cmd>
```
## 常用命令
```bash
melos run gen # 代码生成(riverpod
melos run gen:watch # 开发期常驻
melos run gen:pigeon # native_* 的 Pigeon 产物(产物入库)
melos run analyze # flutter analyze --fatal-infos,含 riverpod_lint
melos run format # dart format --set-exit-if-changed
melos run test # flutter test --coverage
```
`analyze``--fatal-infos`。不加等于没加 lint——绝大多数 riverpod_lint 规则
报的是 info 级。
`riverpod_lint``flutter analyze` 直接执行(顶层 `plugins:` 映射),**不再
需要 `custom_lint`**,也没有 `dart run custom_lint` 这一步。文档 03/14 写的还是
旧方案,见 SCAFFOLD-NOTES §B。
## 包结构与依赖规则
```
app/ 壳工程。唯一知道所有包的地方:环境注入、启动编排、路由聚合
packages/
core_foundation/ AppEnv / AppException 体系 / ApiCode ← 叶子包,谁都能依赖
core_logging/ AppLogger / CrashReporter / 脱敏
core_analytics/ Analytics 接口 + 事件常量表
core_storage/ PrefsKV)。Drift 暂缓,见 SCAFFOLD-NOTES
core_auth/ AppSession 四态 / TokenStorage / SessionNotifier
core_network/ dio + 4 个拦截器 + ApiClient
core_router/ goRouterProvider / menuRouteMap / go_router re-export
core_ui/ 主题 / AsyncValueView / ErrorPresenter
core_webview/ UrlGuard / JSBridge / WebViewSession
feature_auth/ 登录、选店
feature_home/ 工作台
native_scan/ Pigeon 扫码接口(原生实现待补)
```
依赖规则(01):
- `feature_* → core_* / native_*`。**feature 之间禁止互相依赖**——两个 feature
要共享东西,说明那个东西属于某个 core_*。
- `core_* → core_*` 只允许三条边:`core_network → core_auth`
`core_router → core_auth``core_webview → core_auth`。外加所有包都可以依赖
叶子包 `core_foundation`
- `native_* ` 只依赖 Flutter SDK 和 Pigeon 产物,**一个 core_* 都不依赖**。
- feature 的 pubspec 里**不出现 `go_router`**:路由类型由 `core_router`
re-export。
### 这些规则靠 `--fatal-infos` 守,不是靠编译器(实测结论)
直觉上会以为「没在 pubspec 里声明就 import 不到」。**在 Pub Workspace 里这是错的**
所有成员包共用根目录一份 `.dart_tool/package_config.json`,任何成员都能解析到
任何其他成员。实测在 `feature_home` 里 import `feature_auth` 而不声明依赖:
```
flutter test → All tests passed! ← 编译通过,跑得起来
flutter analyze → info: depend_on_referenced_packages
```
只有一条 **info**。所以:
> **`melos run analyze` 的 `--fatal-infos` 是这套包边界唯一的强制点。**
> 谁把它从 CI 里拿掉,或者在某个包里 ignore 掉
> `depend_on_referenced_packages`,边界当天就失效,而且没有任何别的信号。
想自己复现:在 `feature_home/lib/src/` 下扔一个 import `feature_auth` 的文件,
`flutter test` 是绿的,`flutter analyze --fatal-infos` 是红的。
### 那些 `throw UnimplementedError('必须在 bootstrap 里 override')`
依赖规则会挡住一些**合理**的调用(比如 core_auth 想发 HTTP、core_webview 想
换票)。这些地方一律用依赖反转解决:包内声明 `abstract interface` + 一个会抛错
的 provider,实现落在 `app/lib/bootstrap.dart` 里 override 进去。
新增一个这样的端口时,**必须同时在 bootstrap 里接上**——它是运行期才炸的,
编译器帮不了你。
## 待办 / 阻塞项
- **iOS flavor 未配置**Scheme 和 Build Configuration 只能在 Xcode 里建,
步骤见 [`app/ios/FLAVORS.md`](app/ios/FLAVORS.md)。目前没有 Mac 构建机。
- **native_scan 没有原生实现**Pigeon 接口和 Dart 侧齐了,Kotlin/Swift 侧是
模板。现在调 `startScan` 会抛 `MissingPluginException`,属预期。
- **神策 SDK 未采购**`analyticsProvider``NoopAnalytics`。接入时注意必须在
用户同意隐私政策之后再初始化。
- **env/*.json 里全是占位域名**`SENTRY_DSN` 全空,待运维确认。
- **CI 镜像名未定**`.gitlab-ci.yml` 里标了 TODO(ops)。
+222
View File
@@ -0,0 +1,222 @@
# 脚手架落地记录:偏离文档的地方
这份文件是给 `conti-docs` 的**回写清单**。搭这个骨架的过程中,文档里有一部分
内容无法照抄——有的是版本过期,有的是两篇文档互相矛盾,有的是照抄会直接编译
不过。每一条的裁决都记在下面,代码里对应位置也留了注释。
**本次没有改动 `conti-docs` 仓库的任何文件。** 更新 01 / 03 / 07 / 12 / 14 四篇
文档时以这份清单为准。
日期:2026-08-17。工具链:Flutter 3.44.9 / Dart 3.12.2 / Melos 8.2.2。
---
## A. 版本纠错
文档 03 写的 riverpod 版本已经过期,但**不能直接升到 pub.dev 上的最新版**:
| 包 | 文档 | 最新 | 本仓库实际 | 为什么不是最新 |
|---|---|---|---|---|
| `riverpod_annotation` | `^3.4.2` | 4.0.6 | **4.0.3** | 见下 |
| `riverpod_generator` | `^3.4.2` | 4.0.8 | **4.0.4** | 见下 |
| `flutter_riverpod` | `^3.4.2` | 3.4.2 | **3.3.2** | 见下 |
**约束来自 `flutter_test`**:它 pin 了 `test_api 0.7.11`,而 riverpod 4.0.6+ 要
`analyzer 13.x`,两者的解析结果冲突。往上升之前先确认这条链路,光看 pub.dev 的
最新版会浪费半天。
`build_runner 2.15.1` / `analyzer 12.1.0` 是被上面这条连带定死的。
其余 22 个包与文档一致。
## B. `riverpod_lint` 不再走 `custom_lint`
文档 03/14 要求装 `custom_lint`、在 `analysis_options.yaml` 里写
`analyzer: plugins: - custom_lint`、CI 里单独跑 `dart run custom_lint`
**实测 `riverpod_lint 3.1.8` 的依赖里没有 `custom_lint`**,它依赖
`analysis_server_plugin`。官方 changelog
> 3.1.0 — `riverpod_lint` is no-longer implemented using `custom_lint`, but
> instead `analysis_server_plugin`
因此本仓库:
- `analysis_options.yaml` 用**顶层** `plugins:` 映射,不是 `analyzer.plugins`
- 所有 `dev_dependencies` 里没有 `custom_lint`
- CI 里没有 `dart run custom_lint` 这一步,lint 由 `flutter analyze` 直接执行。
**顺带解决了 01 和 14 关于「custom_lint 装根还是装每个包」的长期矛盾**——这个
问题不存在了。
## C. 新增了一个文档里没有的包:`core_foundation`
这是本次唯一的结构性增补。理由是文档现有的依赖规则**自相矛盾**,不加就无法
同时满足:
- 文档 12 把 `sealed AppException` 放在 `core_network`。但这个体系里有
`StorageException``core_storage` 要用)和 `UnauthorizedException`
`core_auth` 要用),而 01 明令 `core_storage`/`core_auth` **不许**依赖
`core_network`
- `AppEnv``core_network`baseUrl)、`core_auth`TokenRefresher)、
`core_webview`(域名白名单)、`core_logging`(日志级别)同时需要。放进任何
一个现有 `core_*` 都会造出非法依赖边或循环。
方案:`packages/core_foundation` 是一个**叶子包**,只依赖 `flutter_riverpod`
内容是 `AppEnv` + `AppException` 体系 + `NetworkErrorKind` + `ApiCode` +
`NativeErrorCode`。所有 `core_*` / `feature_*` 都可以依赖它;`native_*` **不**
依赖(保住 01 那条「native_* 只依赖 Flutter SDK 和 Pigeon 产物」)。
叶子包不可能形成循环,01「core_* 之间不互相依赖」的立法目的(防循环)不受损。
`PageQuery` / `PageResult` 按文档 02 留在 `core_network`
## D. `native_*` 无法抛 `AppException`
文档 07 写「原生异常不外泄,统一转成 AppException 体系」,但 `native_*` 不许
依赖任何 `core_*`(含 `core_foundation`)。
裁决:`native_scan` 抛包内自定义的 `NativeScanException`(纯 Dart 无依赖),
**映射到 `NativeException` 的动作放在调用方**feature_scan / core_webview 的
bridge handler)。文档 07 的 `NativeCapabilityException` /
`UnsupportedPlatformException` 收敛成文档 12 的 `NativeException(code, message)`
## E. 逐条冲突裁决
| # | 冲突 | 裁决 |
|---|---|---|
| 1 | `BusinessException` 构造签名:05 用具名,12 用位置参数 | 按 **12**(定义类的那篇),05 的拦截器代码相应调整 |
| 2 | 05 的 `HttpException` 不在 12 的 sealed 体系里,且与 `dart:io` 同名 | 改用 12 的 `ServerException` |
| 3 | `NetworkErrorKind` 被 12 的 `present()` 用了但从未声明 | 在 core_foundation 声明;`ErrorMappingInterceptor` 负责填 |
| 4 | `UnauthorizedException` / `RequestCancelledException` / `StorageException` 无构造函数,父类却要求位置参数 | 各补 `const` 构造 + 默认文案 |
| 5 | **`PreconditionException` 不在 12 的 `present()` switch 里** | 补分支。sealed 穷尽,不补**编译不过** |
| 6 | `AppException.bridgeCode` 被 core_webview 用了但未定义 | 在 `AppException` 上加 getter,子类 override。码表待与 F6 对齐 |
| 7 | 05 的拦截器顺序正文与附录不一致 | 按**附录**Header → Log → Auth → ApiResult → ErrorMapping |
| 8 | 06 有两个 `readAccessToken` 实现 | 用带 try/catch 兜底那版:读失败按未登录处理 + `clear()`,绝不让异常逃进启动流程 |
| 9 | `currentStoreIdProvider` 返回非空且会 throw(11),但 05 的 `HeaderInterceptor``if (storeId != null)` | 提供**两个** provider`currentStoreIdProvider`(非空,业务用)和 `currentStoreIdOrNullProvider`(可空,基础设施用) |
| 10 | melos 脚本名 `pigeon`01vs `gen:pigeon`14 | 统一 `gen:pigeon` |
| 11 | `analyze` 脚本 01 无 `--fatal-infos`,14 的 CI 有 | 脚本里加上 |
| 12 | melos 脚本里 `flutter` vs `fvm flutter` | 用裸 `flutter`(按 01),版本靠 `.fvmrc` + PATH 保证 |
| 13 | 02 的 `PageResult.hasMore` 计算逻辑有误 | 用后端返回的 `hasMore`,不在客户端算 |
| 14 | 02 正文说 `data/repository_impl/`,示例写 `data/repository/` | 统一 `data/repository/` |
| 15 | `Analytics` 接口:13 正文说 `login()`/`logout()`,代码块用 `identify()`/`reset()` | 按代码块 |
## F. 本次范围调整
- **Drift / 本地数据库暂缓**。骨架阶段没有任何业务表,`core_storage` 现在只有
KV 一档(`Prefs`)。文档 06 的表结构、迁移策略、`{storeId, orderId}` 联合主键
规则仍然有效,等第一张业务表落地时按 06 建。
> 顺带:melos 8.x 和 `drift_dev` 有一个 `cli_util` 的版本冲突,接 Drift 时会
> 撞上,先有个心理准备。
- **原生 Kotlin/Swift 实现不做**。`native_scan` 的 Pigeon 接口和 Dart 侧齐了,
原生侧是 `flutter create` 的模板 + TODO。
- **不做 lefthook**、不做 `git init` / 首次提交(按需求)。
- **i18n 只做结构预留**`app/l10n.yaml` + 一个 `app_zh.arb`,文案还写在 Widget
里。理由见 conti-docs README 的待补充清单——等 30 个页面都写死中文再抽,成本
是现在的几十倍。
## G. 依赖反转("端口")模式
**这是本次落地里最值得回写文档的一条。** 文档里有若干处伪代码违反了 01 自己
定的依赖规则:
| 文档处 | 想做的事 | 为什么不行 |
|---|---|---|
| 11 `SessionNotifier` | 调 `authRepository.login()` | core_auth 不许依赖 core_network / feature_* |
| 11 切店第 6 步 | `goRouterProvider.go('/home')` | core_auth 不许依赖 core_router |
| 11 切店级联 | 清 WebView cookie | core_auth 不许依赖 core_webview |
| 05 `HeaderInterceptor` | 读 `deviceIdProvider` | core_network 不许依赖 core_storage |
| 05 `ApiResultInterceptor` | 收一个 `AppLogger` | core_network 不许依赖 core_logging |
| 10 H5 换票 | 发 `/h5/launch` 请求 | core_webview 不许依赖 core_network |
| 04 未知菜单编码 | 上报埋点 | core_router 不许依赖 core_analytics |
统一解法:**包内只声明"我需要什么",不声明"谁来满足"**。
```dart
// core_auth/lib/src/session_ports.dart
abstract interface class SessionRemote { Future<UserContext> fetchCurrentUser(); ... }
final Provider<SessionRemote> sessionRemoteProvider = Provider<SessionRemote>(
(Ref ref) => throw UnimplementedError('sessionRemoteProvider 必须在 bootstrap() 里 override'),
);
```
```dart
// app/lib/bootstrap.dart —— 唯一知道所有包的地方
sessionRemoteProvider.overrideWith((Ref ref) => ref.watch(authRepositoryProvider)),
```
现有的端口文件:
- `core_auth/lib/src/session_ports.dart``SessionRemote` / `SessionScopedStore`
/ `SessionObserver`
- `core_network/lib/src/ports.dart``ClientInfo` / `ApiLogSink`
- `core_router/lib/src/ports.dart``appRoutesProvider` /
`navigatorObserversProvider` / `RouteReporter`
- `core_webview/lib/src/h5_launch.dart``H5LaunchRepository`
代价要说清楚:**这些 override 是运行期才炸的,编译器不管**。新增一个端口就要
同时在 `bootstrap()` 里接上。默认值给不给有讲究——`appEnvProvider` /
`sessionRemoteProvider` 故意不给(忘了接必须立刻炸),`apiLogSinkProvider` /
`sessionObserversProvider` 给空实现(忘了接只是没日志,不影响功能)。
切店后回工作台那一条,实现成了 `goRouterProvider` 里的一个 `ref.listen`
```dart
if (before is SessionActive && after is SessionActive &&
before.store.storeId != after.store.storeId) {
router.go(AppRoutes.home); // go 而不是 push:替换整个栈
}
```
方向反过来了(core_router 观察 core_auth,而不是 core_auth 调 core_router),
符合允许的依赖边。
## H. Pub Workspace 让「包边界靠编译器强制」这句话不成立
**这条建议直接回写进 01。** 01 的立论是「违反分包规则的代码根本写不出来,
因为 pubspec 里没声明就 import 不到」。在 Pub Workspace 下**这是错的**:所有
成员包共用根目录一份 `.dart_tool/package_config.json`,任何成员都能解析到任何
其他成员,不管 pubspec 里写没写。
实测(在 `feature_home/lib/src/` 放一个 import `feature_auth` 的文件,
`feature_home/pubspec.yaml` 里**不**声明这条依赖):
```
flutter test → All tests passed! 编译通过,跑得起来
flutter analyze --fatal-infos → info: depend_on_referenced_packages ✗
```
只有一条 **info**级 lint。结论:
> **`--fatal-infos` 是这套包边界唯一的强制点。** 它没了,边界当天失效,且
> 没有任何别的信号——不会编译失败,测试还是绿的。
所以 §E 第 11 条(`analyze` 脚本要不要加 `--fatal-infos`)不是风格问题,是
**这套架构成不成立的问题**,01 和 14 都应该按这个高度重写那一段。相应地,
`depend_on_referenced_packages` 不允许在任何包的 `analysis_options.yaml` 里被
ignore,建议在 14 的 CI 门禁一节里单列。
(改用 path dependency 而不是 workspace 可以拿回编译期强制,代价是失去统一
版本解析——不建议为此推翻 workspace,把 `--fatal-infos` 守住即可。)
## I. 写代码时踩到的坑(Riverpod 3 / flutter_test
不属于文档偏离,但会反复咬人,记在这里:
1. **`AsyncValue.valueOrNull` 没了**,用 `.value`
2. **`copyWithPrevious` 是 internal**,用了会报 `invalid_use_of_internal_member`
3. **`Override` 类型不公开导出**。`overrides: [...]` 不要写类型参数。
4. **riverpod_generator 4.x 只剥 `Notifier` 后缀**`SessionNotifier`
`sessionProvider`,但 `LoginController``loginControllerProvider`
5. **所有 provider 默认 autoDispose**。测试里 `container.read` 之后没有监听者,
状态立刻被回收;要 `container.listen(p, (_, _) {})` 顶住。
6. **`ProviderObserver``base` 类**,子类必须标 `final` / `base` / `sealed`
7. **`testWidgets` 跑在 fake async 区里**:里面 `await` 一个真实的 Future
(比如 `container.refresh(p.future)`)**永远不会完成**,会挂到 10 分钟超时。
碰真 provider 生命周期必须包一层 `await tester.runAsync(() async { ... })`
8. **`ProviderContainer` + 异步 provider**:同步 `read` 拿到的是 loading 态,
要先 `await container.read(p.future)`
9. Pigeon 生成的 `@HostApi` 是**具体类**不是接口,测试替身要 `extends` 不是
`implements`
10. 局部变量别叫 `fail`——会遮蔽 `flutter_test``fail()`
+63
View File
@@ -0,0 +1,63 @@
# 根级共享 lint 配置。
# 来源:conti-docs/14-conventions-and-ci-gates.md §二
# 每个子包的 analysis_options.yaml 只写一行 include 指向本文件,禁止在子包里
# 放宽规则——放宽必须改这里,让所有人一起讨论。
include: package:flutter_lints/flutter.yaml
# ---------------------------------------------------------------------------
# 偏离文档说明(riverpod_lint 3.1.0 起的破坏性变更)
#
# 文档 03/14 写的是 `analyzer: plugins: - custom_lint` + CI 里单独跑
# `dart run custom_lint`。但 riverpod_lint 3.1.0 已经从 custom_lint 迁移到
# analysis_server_plugin
# > `riverpod_lint` is no-longer implemented using `custom_lint`,
# > but instead `analysis_server_plugin`. —— riverpod_lint CHANGELOG 3.1.0
# 实测 riverpod_lint 3.1.8 的依赖里已无 custom_lint。
#
# 因此:插件改在下面的顶层 plugins: 段声明,规则由 `dart analyze` /
# `flutter analyze` 直接执行,CI 里不再需要独立的 custom_lint 步骤。
# 详见根目录 SCAFFOLD-NOTES.md §B。
# ---------------------------------------------------------------------------
plugins:
riverpod_lint: ^3.1.8
analyzer:
language:
strict-casts: true
strict-raw-types: true
strict-inference: true
errors:
# freezed / riverpod 生成器会在非法位置放注解,这是生成器的正常行为
invalid_annotation_target: ignore
# 下面三条从 warning 提到 error:它们几乎总是真实缺陷
unused_import: error
dead_code: error
unawaited_futures: error
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
- "**/generated/**"
formatter:
page_width: 100
linter:
rules:
# --- 可读性 ---
- always_declare_return_types
- prefer_single_quotes
- require_trailing_commas
- directives_ordering
- sort_pub_dependencies
# --- 正确性(这几条是真正拦过线上事故的) ---
- avoid_dynamic_calls
- avoid_slow_async_io
- cancel_subscriptions
- close_sinks
- discarded_futures
- unawaited_futures
- no_adjacent_strings_in_list
- test_types_in_equals
- throw_in_finally
- unnecessary_statements
+45
View File
@@ -0,0 +1,45 @@
# 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
+33
View File
@@ -0,0 +1,33 @@
# 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: "6b182d2c7585eba26d4edce0f97630effd256c33"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
base_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
- platform: android
create_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
base_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
- platform: ios
create_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
base_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+17
View File
@@ -0,0 +1,17 @@
# retail
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.
+1
View File
@@ -0,0 +1 @@
include: ../analysis_options.yaml
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+91
View File
@@ -0,0 +1,91 @@
import java.util.Properties
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")
}
// 签名配置从 key.properties 读,**这个文件和 keystore 都不进 git**(见 08 / 14)。
// 缺文件时不报错、退回 debug 签名,这样新同事 clone 下来就能 `flutter run`。
val keystoreProperties = Properties().apply {
val f = rootProject.file("key.properties")
if (f.exists()) f.inputStream().use { load(it) }
}
android {
namespace = "com.conti.retail"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
applicationId = "com.conti.retail"
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
// ------------------------------------------------------------------------
// flavor 三件套(08)。要点:
// 1. 用 applicationIdSuffix 而不是覆盖 applicationId——后者一改,Firebase /
// 推送 / 应用市场的包名对应关系全部要重配一遍;
// 2. app_name 走 resValue,让三个环境在桌面上一眼能分清。装了三个图标都叫
// "大陆马门店"的时候,测试提的 bug 会有一半定位不到环境;
// 3. prod 没有后缀,就是正式包名。
// ------------------------------------------------------------------------
flavorDimensions += "env"
productFlavors {
create("dev") {
dimension = "env"
applicationIdSuffix = ".dev"
resValue("string", "app_name", "马店(开发)")
}
create("uat") {
dimension = "env"
applicationIdSuffix = ".uat"
resValue("string", "app_name", "马店(测试)")
}
create("prod") {
dimension = "env"
resValue("string", "app_name", "大陆马门店")
}
}
signingConfigs {
if (keystoreProperties.isNotEmpty()) {
create("release") {
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
storePassword = keystoreProperties["storePassword"] as String?
keyAlias = keystoreProperties["keyAlias"] as String?
keyPassword = keystoreProperties["keyPassword"] as String?
}
}
}
buildTypes {
release {
signingConfig = if (keystoreProperties.isNotEmpty()) {
signingConfigs.getByName("release")
} else {
// TODO(ops): 正式密钥到位前用 debug 签名,**不能这样发版**。
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,48 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="@string/app_name"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="false"
android:networkSecurityConfig="@xml/network_security_config">
<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.conti.retail
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
全环境禁止明文 HTTP**dev 也不例外**08 / 14 的安全约定)。
给 dev 开一个口子的代价:开发期习惯了 http,等到 uat 才发现某个接口、某张
图片、某个 H5 资源是明文的,而那时候已经绕不过去了。一开始就关掉,问题在
第一天暴露。
抓包调试请用 Charles/Fiddler 的 HTTPS 代理 + debug 变体单独放行,不要改这个文件。
-->
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system"/>
</trust-anchors>
</base-config>
</network-security-config>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+6
View File
@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
}
include(":app")
+7
View File
@@ -0,0 +1,7 @@
{
"_comment": "dev 环境。构建时必须带 --dart-define-from-file=env/dev.json,否则 AppEnv.fromDartDefine 会直接抛错。所有地址都是占位符,TODO(ops) 待运维确认真实域名。",
"API_BASE_URL": "https://api-dev.example.com",
"ENABLE_LOG": true,
"SENTRY_DSN": "",
"H5_ALLOWED_HOSTS": "h5-dev.example.com"
}
+7
View File
@@ -0,0 +1,7 @@
{
"_comment": "prod 环境。ENABLE_LOG 必须为 false(13:生产不写控制台、不落日志文件)。TODO(ops) 待运维确认真实域名与 Sentry DSN。",
"API_BASE_URL": "https://api.example.com",
"ENABLE_LOG": false,
"SENTRY_DSN": "",
"H5_ALLOWED_HOSTS": "h5.example.com"
}
+7
View File
@@ -0,0 +1,7 @@
{
"_comment": "uat 环境。TODO(ops) 待运维确认真实域名与 Sentry DSN。",
"API_BASE_URL": "https://api-uat.example.com",
"ENABLE_LOG": true,
"SENTRY_DSN": "",
"H5_ALLOWED_HOSTS": "h5-uat.example.com"
}
+33
View File
@@ -0,0 +1,33 @@
// 端到端骨架。来源:conti-docs/09-testing-strategy.md §集成测试。
//
// ---------------------------------------------------------------------------
// 09 的原则:**集成测试只覆盖"跨层出问题就没人发现"的主干链路**,不覆盖分支。
// 现在能跑通的只有第一段(启动 → 停在登录页),后面几段等真实后端环境和测试
// 账号到位后逐段打开——测试打桩到"点了按钮什么都没验证"是负资产。
//
// 跑法:
// flutter test integration_test/app_test.dart \
// --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json
// ---------------------------------------------------------------------------
import 'package:app/bootstrap.dart';
import 'package:core_foundation/core_foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('冷启动后停在登录页', (WidgetTester tester) async {
await bootstrap(AppEnv.fromDartDefine(flavor: 'dev'));
await tester.pumpAndSettle();
// 没有 token → SessionUnauthenticated → redirect 到 /login04)。
expect(find.byKey(const Key('login_username')), findsOneWidget);
});
// TODO(09): 登录 → 选店 → 工作台 → 采购下单 → 支付回跳。
// 需要 UAT 环境的常驻测试账号和一家固定测试门店;账号一变整条链路就红,
// 所以在账号方案定下来之前不写死。
}
+34
View File
@@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3
+76
View File
@@ -0,0 +1,76 @@
# iOS flavor 手工配置步骤
**这一步必须在 macOS + Xcode 上做,命令行做不到。** Xcode 的 Build
Configuration 和 Scheme 存在 `Runner.xcodeproj/project.pbxproj` 里,那是一份
Xcode 自己维护的二进制风格文本,手写会在下一次 Xcode 打开时被改乱,出的问题
"某个 target 的某个配置莫名其妙丢了")极难排查。
所以这个仓库只提供三份 `.xcconfig``ios/Flutter/{Dev,Uat,Prod}.xcconfig`),
剩下的连线由第一个拿到 Mac 的人做一次,之后进 git。
> 08-build-flavors.md 已经把 iOS 侧列为**头号阻塞项**——目前团队没有可用的
> Mac 构建机,Apple 开发者账号也未确认。在那之前 iOS 只能跑默认配置。
## 步骤
1. 打开 `app/ios/Runner.xcworkspace`(不是 `.xcodeproj`)。
2. 选中项目 → **Info****Configurations**。此时应该有 `Debug` / `Release` /
`Profile` 三条。对每一条点 `+`**Duplicate ... Configuration**,复制成:
| 原 | 复制为 |
|---|---|
| Debug | `Debug-dev``Debug-uat``Debug-prod` |
| Release | `Release-dev``Release-uat``Release-prod` |
| Profile | `Profile-dev``Profile-uat``Profile-prod` |
一共 9 条。**名字必须完全是 `<原名>-<flavor>`**`flutter run --flavor dev`
就是靠这个命名约定找配置的,写成 `Debug-Dev` 都不行。
做完之后把原来的 `Debug` / `Release` / `Profile` 删掉。
3. 每条配置的 Runner target 那一列,选对应的 xcconfig
`*-dev``Flutter/Dev.xcconfig``*-uat``Flutter/Uat.xcconfig`
`*-prod``Flutter/Prod.xcconfig`
> 注意:Flutter 生成的 `Debug.xcconfig` / `Release.xcconfig` 里
> `#include "Generated.xcconfig"` 不能丢。三份 flavor 配置需要在开头补上
> `#include "Debug.xcconfig"`(或 `Release.xcconfig`)——`flutter build` 靠
> `Generated.xcconfig` 传 `FLUTTER_TARGET` 等参数,丢了会构建失败且报错
> 信息完全指不到这里。
4. **Product → Scheme → Manage Schemes**,把 `Runner` 复制成 `dev` / `uat` /
`prod` 三个 Scheme(名字就是 flavor 名),各自 Edit Scheme
- Run → Build Configuration → `Debug-<flavor>`
- Profile → `Profile-<flavor>`
- Archive → `Release-<flavor>`
- 三个 Scheme 都要勾 **Shared**,否则不进 git,只有你自己的机器上有。
5. `Runner/Info.plist` 里把
```xml
<key>CFBundleDisplayName</key>
<string>Retail</string>
```
改成
```xml
<key>CFBundleDisplayName</key>
<string>$(APP_DISPLAY_NAME)</string>
```
**改完必须先做完第 3 步**:`APP_DISPLAY_NAME` 只在三份 flavor xcconfig 里
定义,没接上就是空的桌面名。
6. 验证:
```bash
cd app
flutter build ios --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json
```
三个 flavor 各跑一次,确认桌面上能同时装下三个图标、名字不同。
## 已知的坑
- **CocoaPods 和 flavor 无关**`Podfile` 不需要改,pod 是按 target 装的,
不是按 configuration。但新增 configuration 后要跑一次 `pod install`,否则
会报 `Unable to find a configuration named 'Debug-dev'`。
- **`--flavor` 和 `-t` 必须同时给**。只给 `--flavor dev` 会用默认的
`lib/main.dart`——这个文件在本仓库里**不存在**(入口是 `main_dev.dart`),
报错信息是找不到文件,和 flavor 看不出关系。
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+10
View File
@@ -0,0 +1,10 @@
// dev 环境。由 Xcode 里名为 "Debug-dev" / "Release-dev" / "Profile-dev"
// 的 Build Configuration include 进来(手工步骤见 ios/FLAVORS.md)。
//
// 这里只放**环境差异**,公共配置留在 Generated.xcconfig / Debug.xcconfig。
BUNDLE_ID_SUFFIX=.dev
APP_DISPLAY_NAME=马店(开发)
// 包名不覆盖、只加后缀——和 Android 的 applicationIdSuffix 保持同一套规则。
PRODUCT_BUNDLE_IDENTIFIER=com.conti.retail$(BUNDLE_ID_SUFFIX)
+10
View File
@@ -0,0 +1,10 @@
// prod 环境。由 Xcode 里名为 "Debug-prod" / "Release-prod" / "Profile-prod"
// 的 Build Configuration include 进来(手工步骤见 ios/FLAVORS.md)。
//
// 这里只放**环境差异**,公共配置留在 Generated.xcconfig / Debug.xcconfig。
BUNDLE_ID_SUFFIX=
APP_DISPLAY_NAME=大陆马门店
// 包名不覆盖、只加后缀——和 Android 的 applicationIdSuffix 保持同一套规则。
PRODUCT_BUNDLE_IDENTIFIER=com.conti.retail$(BUNDLE_ID_SUFFIX)
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+10
View File
@@ -0,0 +1,10 @@
// uat 环境。由 Xcode 里名为 "Debug-uat" / "Release-uat" / "Profile-uat"
// 的 Build Configuration include 进来(手工步骤见 ios/FLAVORS.md)。
//
// 这里只放**环境差异**,公共配置留在 Generated.xcconfig / Debug.xcconfig。
BUNDLE_ID_SUFFIX=.uat
APP_DISPLAY_NAME=马店(测试)
// 包名不覆盖、只加后缀——和 Android 的 applicationIdSuffix 保持同一套规则。
PRODUCT_BUNDLE_IDENTIFIER=com.conti.retail$(BUNDLE_ID_SUFFIX)
+644
View File
@@ -0,0 +1,644 @@
// !$*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 = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
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.conti.retail;
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.conti.retail.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.conti.retail.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.conti.retail.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 = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
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 = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
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.conti.retail;
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.conti.retail;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
+16
View File
@@ -0,0 +1,16 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}
@@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

@@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
+70
View File
@@ -0,0 +1,70 @@
<?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>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Retail</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>retail</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
+1
View File
@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"
+6
View File
@@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}
+12
View File
@@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}
+8
View File
@@ -0,0 +1,8 @@
# 16-i18n.md 还没写,这份配置是**结构预留**:等 arb 方案定了,把散在 Widget
# 里的中文文案迁进 lib/l10n/app_zh.arb,代码侧只多一个 import。
#
# 首版就留好的理由(见 conti-docs/README 待补充清单):等 30 个页面都写死中文
# 再回来抽,成本是现在的几十倍。
arb-dir: lib/l10n
template-arb-file: app_zh.arb
output-localization-file: app_localizations.dart
+148
View File
@@ -0,0 +1,148 @@
/// 三个入口共用的启动编排。
///
/// 来源:08(多环境)、13(Sentry / 日志)、12(全局错误)、03(Riverpod 装配)。
library;
import 'package:app/src/app_widget.dart';
import 'package:app/src/device_id.dart';
import 'package:app/src/error_observer.dart';
import 'package:app/src/h5_launch_repository.dart';
import 'package:app/src/session_observer.dart';
import 'package:core_analytics/core_analytics.dart';
import 'package:core_auth/core_auth.dart';
import 'package:core_foundation/core_foundation.dart';
import 'package:core_logging/core_logging.dart';
import 'package:core_network/core_network.dart';
import 'package:core_router/core_router.dart';
import 'package:core_storage/core_storage.dart';
import 'package:core_webview/core_webview.dart';
import 'package:feature_auth/feature_auth.dart';
import 'package:feature_home/feature_home.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
/// TODO(app): 换成 package_info_plus 读真实版本,现在写死会在灰度期骗人。
const String _appVersion = '1.0.0+1';
/// 全部三个 main_*.dart 都只调这一个函数。
///
/// ---------------------------------------------------------------------------
/// 这个函数是**整个仓库唯一一处知道所有包的地方**。各个 core_* 只声明自己需要
/// 什么(端口 + provider),谁来满足它在这里决定——这就是那些
/// `throw UnimplementedError('必须在 bootstrap 里 override')` 的兑现点。
///
/// 每加一个 override 就等于在编译期之外多了一个"忘了接就炸"的风险,所以下面
/// 每一条都标注了它兑现的是哪个端口。
/// ---------------------------------------------------------------------------
Future<void> bootstrap(AppEnv env) async {
WidgetsFlutterBinding.ensureInitialized();
// 装一次全局单例,供拿不到 Ref 的 TokenRefresher 用;重复调用会抛错。
AppEnv.install(env);
final LogBuffer buffer = LogBuffer();
final AppLogger logger = LoggerAppLogger(env: env, buffer: buffer);
// dsn 为空(dev 默认)时连 SDK 都不初始化:开发期的噪音不该混进线上数据。
final bool sentryEnabled = env.sentryDsn.isNotEmpty;
final CrashReporter reporter = sentryEnabled
? const SentryCrashReporter()
: const NoopCrashReporter();
final Prefs prefs = Prefs();
final ClientInfo clientInfo = ClientInfo(
appVersion: _appVersion,
deviceId: await loadOrCreateDeviceId(prefs),
);
// 12 §五:默认的红屏在 release 里是白屏加一行英文,用户只会以为 App 坏了。
ErrorWidget.builder = (FlutterErrorDetails details) => env.isProd
? const Material(child: Center(child: Text('页面出了点问题,请退出重试')))
: ErrorWidget(details.exception);
Widget buildApp() {
return ProviderScope(
observers: <ProviderObserver>[ErrorObserver(logger: logger, reporter: reporter)],
overrides: [
// --- 基础设施:值已经在上面造好了,直接注入 -----------------------
appEnvProvider.overrideWithValue(env), // core_foundation
appLoggerProvider.overrideWithValue(logger), // core_logging
logBufferProvider.overrideWithValue(buffer), // core_logging(和 beforeSend 同一实例)
crashReporterProvider.overrideWithValue(reporter), // core_logging
prefsProvider.overrideWithValue(prefs), // core_storage
clientInfoProvider.overrideWithValue(clientInfo), // core_network 端口
// --- 端口:接口在 core_*,实现在能依赖 core_network 的这一层 --------
sessionRemoteProvider.overrideWith(
(Ref ref) => ref.watch(authRepositoryProvider), // core_auth ← feature_auth
),
h5LaunchRepositoryProvider.overrideWith(
(Ref ref) => ApiH5LaunchRepository(ref.watch(apiClientProvider)), // core_webview ← app
),
// --- 会话级联的参与者 ---------------------------------------------
// 登出/切店时要被清掉的东西在这里登记。**漏登记 = 上一个用户的数据
// 留在设备上**,而门店设备是共用的(11)。
sessionScopedStoresProvider.overrideWith(
(Ref ref) => <SessionScopedStore>[ref.watch(webViewSessionProvider)],
),
sessionObserversProvider.overrideWith(
(Ref ref) => <SessionObserver>[
AppSessionObserver(
analytics: ref.watch(analyticsProvider),
reporter: ref.watch(crashReporterProvider),
),
],
),
// --- 路由聚合:core_router 不认识任何 feature,在这里拼 -------------
appRoutesProvider.overrideWith(
(Ref ref) => <RouteBase>[...buildAuthRoutes(), ...buildHomeRoutes()],
),
navigatorObserversProvider.overrideWith(
(Ref ref) => <NavigatorObserver>[
CrashBreadcrumbObserver(ref.watch(crashReporterProvider)),
],
),
routeReporterProvider.overrideWith(
(Ref ref) => AppRouteReporter(
logger: ref.watch(appLoggerProvider),
reporter: ref.watch(crashReporterProvider),
),
),
// --- 日志出口:core_network 只知道"往这里写字符串" -------------------
apiLogSinkProvider.overrideWith((Ref ref) {
final AppLogger sink = ref.watch(appLoggerProvider);
return (String message) => sink.d(message);
}),
// TODO(analytics): 神策采购未落地,暂用 NoopAnalyticscore_analytics 的默认值)。
// 接入时在这里 override,且必须在**用户同意隐私政策之后**才初始化 SDK(13)。
],
child: const ContiApp(),
);
}
if (!sentryEnabled) {
runApp(buildApp());
return;
}
await SentryFlutter.init(
(SentryFlutterOptions options) {
options.dsn = env.sentryDsn;
options.environment = env.flavor.name;
options.release = 'conti-retail-app@$_appVersion';
options.tracesSampleRate = env.isProd ? 0.1 : 1.0;
// 合规红线:不自动带用户 IP / 请求头 / cookie。
options.sendDefaultPii = false;
options.beforeBreadcrumb = scrubBreadcrumb;
options.beforeSend = buildScrubEvent(buffer);
options.debug = false;
},
// 用 appRunner 而不是自己写 FlutterError.onErrorSentryFlutter 已经在
// 里面装好了 Flutter / PlatformDispatcher / Zone 三层钩子,再手写一遍
// 会**每个异常上报两次**(13)。
appRunner: () => runApp(buildApp()),
);
}
+130
View File
@@ -0,0 +1,130 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart' as intl;
import 'app_localizations_zh.dart';
// ignore_for_file: type=lint
/// Callers can lookup localized strings with an instance of AppLocalizations
/// returned by `AppLocalizations.of(context)`.
///
/// Applications need to include `AppLocalizations.delegate()` in their app's
/// `localizationDelegates` list, and the locales they support in the app's
/// `supportedLocales` list. For example:
///
/// ```dart
/// import 'l10n/app_localizations.dart';
///
/// return MaterialApp(
/// localizationsDelegates: AppLocalizations.localizationsDelegates,
/// supportedLocales: AppLocalizations.supportedLocales,
/// home: MyApplicationHome(),
/// );
/// ```
///
/// ## Update pubspec.yaml
///
/// Please make sure to update your pubspec.yaml to include the following
/// packages:
///
/// ```yaml
/// dependencies:
/// # Internationalization support.
/// flutter_localizations:
/// sdk: flutter
/// intl: any # Use the pinned version from flutter_localizations
///
/// # Rest of dependencies
/// ```
///
/// ## iOS Applications
///
/// iOS applications define key application metadata, including supported
/// locales, in an Info.plist file that is built into the application bundle.
/// To configure the locales supported by your app, youll need to edit this
/// file.
///
/// First, open your projects ios/Runner.xcworkspace Xcode workspace file.
/// Then, in the Project Navigator, open the Info.plist file under the Runner
/// projects Runner folder.
///
/// Next, select the Information Property List item, select Add Item from the
/// Editor menu, then select Localizations from the pop-up menu.
///
/// Select and expand the newly-created Localizations item then, for each
/// locale your application supports, add a new item and select the locale
/// you wish to add from the pop-up menu in the Value field. This list should
/// be consistent with the languages listed in the AppLocalizations.supportedLocales
/// property.
abstract class AppLocalizations {
AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName;
static AppLocalizations? of(BuildContext context) {
return Localizations.of<AppLocalizations>(context, AppLocalizations);
}
static const LocalizationsDelegate<AppLocalizations> delegate = _AppLocalizationsDelegate();
/// A list of this localizations delegate along with the default localizations
/// delegates.
///
/// Returns a list of localizations delegates containing this delegate along with
/// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
/// and GlobalWidgetsLocalizations.delegate.
///
/// Additional delegates can be added by appending to this list in
/// MaterialApp. This list does not have to be used at all if a custom list
/// of delegates is preferred or required.
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
<LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[Locale('zh')];
/// App 名称。目前只有这一条——其余文案等 16-i18n.md 定了方案再统一迁入。
///
/// In zh, this message translates to:
/// **'大陆马门店'**
String get appTitle;
}
class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
const _AppLocalizationsDelegate();
@override
Future<AppLocalizations> load(Locale locale) {
return SynchronousFuture<AppLocalizations>(lookupAppLocalizations(locale));
}
@override
bool isSupported(Locale locale) => <String>['zh'].contains(locale.languageCode);
@override
bool shouldReload(_AppLocalizationsDelegate old) => false;
}
AppLocalizations lookupAppLocalizations(Locale locale) {
// Lookup logic when only language code is specified.
switch (locale.languageCode) {
case 'zh':
return AppLocalizationsZh();
}
throw FlutterError(
'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
'an issue with the localizations generation tool. Please file an issue '
'on GitHub with a reproducible sample app and the gen-l10n configuration '
'that was used.',
);
}
+13
View File
@@ -0,0 +1,13 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for Chinese (`zh`).
class AppLocalizationsZh extends AppLocalizations {
AppLocalizationsZh([String locale = 'zh']) : super(locale);
@override
String get appTitle => '大陆马门店';
}
+7
View File
@@ -0,0 +1,7 @@
{
"@@locale": "zh",
"appTitle": "大陆马门店",
"@appTitle": {
"description": "App 名称。目前只有这一条——其余文案等 16-i18n.md 定了方案再统一迁入。"
}
}
+14
View File
@@ -0,0 +1,14 @@
/// dev 环境入口。
///
/// 跑法(08):
/// ```
/// flutter run --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json
/// ```
/// flavor 名写死在这里而不是从 dart-define 读——"用 dev 的入口配了别的环境的
/// json"这种事故必须在代码里看得见。
library;
import 'package:app/bootstrap.dart';
import 'package:core_foundation/core_foundation.dart';
Future<void> main() => bootstrap(AppEnv.fromDartDefine(flavor: 'dev'));
+14
View File
@@ -0,0 +1,14 @@
/// prod 环境入口。
///
/// 跑法(08):
/// ```
/// flutter run --flavor prod -t lib/main_prod.dart --dart-define-from-file=env/prod.json
/// ```
/// flavor 名写死在这里而不是从 dart-define 读——"用 prod 的入口配了别的环境的
/// json"这种事故必须在代码里看得见。
library;
import 'package:app/bootstrap.dart';
import 'package:core_foundation/core_foundation.dart';
Future<void> main() => bootstrap(AppEnv.fromDartDefine(flavor: 'prod'));
+14
View File
@@ -0,0 +1,14 @@
/// uat 环境入口。
///
/// 跑法(08):
/// ```
/// flutter run --flavor uat -t lib/main_uat.dart --dart-define-from-file=env/uat.json
/// ```
/// flavor 名写死在这里而不是从 dart-define 读——"用 uat 的入口配了别的环境的
/// json"这种事故必须在代码里看得见。
library;
import 'package:app/bootstrap.dart';
import 'package:core_foundation/core_foundation.dart';
Future<void> main() => bootstrap(AppEnv.fromDartDefine(flavor: 'uat'));
+58
View File
@@ -0,0 +1,58 @@
/// 根 Widget。
library;
import 'package:core_auth/core_auth.dart';
import 'package:core_router/core_router.dart';
import 'package:core_ui/core_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 应用根。
///
/// 壳工程只做组装:路由来自 core_router,主题来自 core_ui,页面来自 feature_*。
/// **这里不应该出现任何业务逻辑**——一旦出现,它就没有能承载它的包了。
class ContiApp extends ConsumerWidget {
/// 构造。
const ContiApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final GoRouter router = ref.watch(goRouterProvider);
// ------------------------------------------------------------------
// 会话指纹:用户 + 门店。11 §切店级联的最后一步——把整棵页面子树按这个
// key 重建,扔掉所有 StatefulWidget 里攒着的门店维度状态。
//
// 光 invalidate provider 是不够的:翻页页码、已勾选的行、输入框里半截的
// 单号都活在 State 里,provider 层看不见它们。切完店留着上一家店的选中
// 状态,会直接变成"给 A 店的单据提交到 B 店"。
// ------------------------------------------------------------------
final String sessionKey = ref.watch(
sessionProvider.select(
(AsyncValue<AppSession> value) => switch (value.value) {
SessionActive(:final UserContext user, :final StoreContext store) =>
'u${user.userId}-s${store.storeId}',
_ => 'anonymous',
},
),
);
return MaterialApp.router(
title: '大陆马门店',
theme: AppTheme.light,
darkTheme: AppTheme.dark,
routerConfig: router,
// 16-i18n.md 还没写,但结构先留着:首版之后再补代价高得多。
// 文案暂时直接写在 Widget 里,等 arb 方案定了统一迁移(见 lib/l10n/)。
localizationsDelegates: const <LocalizationsDelegate<Object>>[
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const <Locale>[Locale('zh', 'CN')],
builder: (BuildContext context, Widget? child) =>
KeyedSubtree(key: ValueKey<String>(sessionKey), child: child ?? const SizedBox.shrink()),
);
}
}
+29
View File
@@ -0,0 +1,29 @@
/// 安装级匿名设备 ID。
library;
import 'dart:convert';
import 'dart:math';
import 'package:core_storage/core_storage.dart';
/// 首次安装时生成、之后一直复用的随机 ID。
///
/// ---------------------------------------------------------------------------
/// **绝不是 IMEI / IDFA / MAC / AndroidID**。这几个是设备唯一标识,采集它们是
/// 合规红线(05 / 07 的隐私清单),而且 Android 10+ / iOS 早就限制了读取。
///
/// 这里的语义是"这次安装":卸载重装换一个新 ID 是**预期行为**,不需要跨安装
/// 追踪——它的用途只有一个,把同一台设备的日志串起来排查问题。
/// ---------------------------------------------------------------------------
Future<String> loadOrCreateDeviceId(Prefs prefs) async {
const String key = 'device_id';
final String? existing = await prefs.getString(key);
if (existing != null && existing.isNotEmpty) {
return existing;
}
final Random random = Random.secure();
final List<int> bytes = List<int>.generate(16, (int _) => random.nextInt(256));
final String created = base64Url.encode(bytes).replaceAll('=', '');
await prefs.setString(key, created);
return created;
}
+47
View File
@@ -0,0 +1,47 @@
/// Provider 层的全局错误出口。来源:conti-docs/12-error-and-api-contract.md §五。
library;
import 'package:core_foundation/core_foundation.dart';
import 'package:core_logging/core_logging.dart';
import 'package:core_ui/core_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 所有 provider 抛出的异常都会经过这里。
///
/// ---------------------------------------------------------------------------
/// 它是**兜底**,不是主路径:UI 该显示的错误由 `AsyncValueView` 负责,这里只
/// 负责"这个异常有没有人处理过"之外的另一件事——落日志和上报。
///
/// 三类要**主动排除**,否则线上告警会被噪音淹没:
/// - [BusinessException]:后端明确告诉我们"这个操作不允许",是预期内的流程
/// 分支(余额不足、单据已关闭),不是缺陷;
/// - [UnauthorizedException]:登录过期,SessionNotifier 已经在处理了;
/// - [RequestCancelledException]:用户切走了页面,请求被主动取消。
/// ---------------------------------------------------------------------------
final class ErrorObserver extends ProviderObserver {
/// 构造。
ErrorObserver({required this.logger, required this.reporter});
/// 日志出口。
final AppLogger logger;
/// 崩溃上报出口。
final CrashReporter reporter;
@override
void providerDidFail(ProviderObserverContext context, Object error, StackTrace stackTrace) {
final String name = context.provider.name ?? context.provider.runtimeType.toString();
if (ErrorPresenter.isSilent(error)) {
return;
}
if (error is BusinessException) {
// 记一条 info 就够:需要它来复盘"用户为什么走不下去",但它不是缺陷。
logger.i('业务拒绝 $name: ${error.code} ${error.message}');
return;
}
logger.e('provider 失败 $name', error: error, stackTrace: stackTrace);
reporter.report(error, stackTrace, extra: <String, String>{'provider': name});
}
}
+36
View File
@@ -0,0 +1,36 @@
/// `/api/v1/h5/launch` 的实现。
///
/// 接口声明在 core_webview`h5_launch.dart`),实现必须落在能依赖
/// core_network 的地方——core_webview 不允许依赖 core_network01)。
library;
import 'package:core_foundation/core_foundation.dart';
import 'package:core_network/core_network.dart';
import 'package:core_webview/core_webview.dart';
/// 用 target 编码换一份带票据的 H5 URL。
class ApiH5LaunchRepository implements H5LaunchRepository {
/// 构造。
const ApiH5LaunchRepository(this._api);
final ApiClient _api;
@override
Future<H5LaunchInfo> launch(String target) async {
// 只传 target 编码,不传 URL:URL 由后端从服务端会话上下文拼(见 10)。
final Map<String, dynamic> data = await _api.post<Map<String, dynamic>>(
'/api/v1/h5/launch',
data: <String, String>{'target': target},
);
final Object? url = data['url'];
final Object? title = data['title'];
if (url is! String || title is! String) {
throw const ServerException('H5 启动信息不完整');
}
return H5LaunchInfo(
url: url,
title: title,
ttl: Duration(seconds: (data['ttlSeconds'] as num?)?.toInt() ?? 300),
);
}
}
+82
View File
@@ -0,0 +1,82 @@
/// 会话事件的旁路接线:埋点身份、崩溃上报的用户上下文、路由错误上报。
///
/// 这些都是 core_auth 声明的端口(`session_ports.dart` / `core_router/ports.dart`
/// 的实现——core_auth 不能依赖 core_analytics / core_logging,所以实现落在这里。
library;
import 'package:core_analytics/core_analytics.dart';
import 'package:core_auth/core_auth.dart';
import 'package:core_logging/core_logging.dart';
import 'package:core_router/core_router.dart';
/// 把会话变化广播给埋点和崩溃上报。
class AppSessionObserver implements SessionObserver {
/// 构造。
const AppSessionObserver({required this.analytics, required this.reporter});
/// 埋点。
final Analytics analytics;
/// 崩溃上报。
final CrashReporter reporter;
@override
void onUserIdentified(UserContext user) {
analytics.identify(user.userId);
analytics.registerSuperProperties(<String, Object?>{
AnalyticsSuperProperty.roleCode: user.roleCode,
});
// 只传 userId,不传手机号——Sentry 侧 sendDefaultPii = false 的前提就是
// 我们自己也不往里塞 PII。
reporter.setUser(user.userId);
}
@override
void onStoreChanged(StoreContext store) {
// 运营侧几乎所有分析都按门店维度看,靠每个调用点自己传一定会漏。
analytics.registerSuperProperties(<String, Object?>{
AnalyticsSuperProperty.storeId: store.storeId,
});
reporter.setTag('storeId', '${store.storeId}');
}
@override
void onSessionEnded(LogoutReason reason) {
// 被动登出没有对应的接口调用,后端看不见,必须客户端报(13)。
analytics.track(AnalyticsEvent.logout, <String, Object?>{AnalyticsParam.reason: reason.name});
// 门店设备是共用的:不 reset,下一个人的数据会串到上一个人身上。
analytics.reset();
reporter.clearUser();
}
@override
void onSessionRestoreFailed(String stage) {
analytics.track(AnalyticsEvent.sessionRestoreFailed, <String, Object?>{
AnalyticsParam.stage: stage,
});
}
}
/// 路由未命中时上报。
class AppRouteReporter implements RouteReporter {
/// 构造。
const AppRouteReporter({required this.logger, required this.reporter});
/// 日志。
final AppLogger logger;
/// 崩溃上报。
final CrashReporter reporter;
@override
void onRouteNotFound(String location) {
// 记路径不记 query——H5 相关路径的 query 里带票据(13 §脱敏)。
final String path = Uri.tryParse(location)?.path ?? location;
logger.w('路由未命中: $path');
reporter.report(
StateError('route not found'),
StackTrace.current,
extra: <String, String>{AnalyticsParam.path: path},
);
}
}
+52
View File
@@ -0,0 +1,52 @@
name: app
description: Conti Retail App 壳工程。只做组装:环境注入、启动编排、feature 路由聚合。
publish_to: none
version: 1.0.0+1
resolution: workspace
environment:
sdk: ^3.12.0
dependencies:
core_analytics: ^0.1.0
core_auth: ^0.1.0
core_foundation: ^0.1.0
core_logging: ^0.1.0
core_network: ^0.1.0
core_router: ^0.1.0
core_storage: ^0.1.0
core_ui: ^0.1.0
core_webview: ^0.1.0
feature_auth: ^0.1.0
feature_home: ^0.1.0
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
flutter_riverpod: ^3.3.2
intl: any
native_scan: ^0.1.0
sentry_flutter: ^9.26.0
dev_dependencies:
flutter_lints: ^6.0.0
flutter_test:
sdk: flutter
integration_test:
sdk: flutter
sentry_dart_plugin: ^3.4.0
# 只为测试拿 InMemorySharedPreferencesAsync,不在 lib/ 里出现。
shared_preferences_platform_interface: ^2.4.2
flutter:
uses-material-design: true
generate: true
# release 构建后由 CI 调用 `dart run sentry_dart_plugin` 上传符号表。
# 见 08 §release 构建 和 13 §崩溃上报。
sentry:
upload_debug_symbols: true
upload_source_maps: false
project: conti-retail-app
org: continental
# auth_token 只从 CI 变量 SENTRY_AUTH_TOKEN 读,绝不写进仓库
+40
View File
@@ -0,0 +1,40 @@
// deviceId 是这个壳工程里唯一有合规约束的一段逻辑,所以它有测试:
// 它必须是**本端随机生成**的,不能是任何设备唯一标识(IMEI / IDFA / MAC /
// AndroidID)。这条断言防的不是今天的代码,是将来某个人为了"提高准确率"
// 把它换成 device_info_plus 的某个字段。
import 'package:app/src/device_id.dart';
import 'package:core_storage/core_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
void main() {
setUp(() {
SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty();
});
test('首次调用生成并落盘,之后一直复用同一个值', () async {
final Prefs prefs = Prefs();
final String first = await loadOrCreateDeviceId(prefs);
expect(first, isNotEmpty);
// 换一个 Prefs 实例读,模拟下次冷启动。
final String second = await loadOrCreateDeviceId(Prefs());
expect(second, first);
});
test('两台设备(两份存储)拿到的是不同的随机值', () async {
final String a = await loadOrCreateDeviceId(Prefs());
SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty();
final String b = await loadOrCreateDeviceId(Prefs());
// 撞了就说明它不是随机的——大概率是有人换成了设备标识。
expect(b, isNot(a));
// 128 bit 的 base64url,去掉 padding 后 22 个字符。
expect(a.length, 22);
expect(a, isNot(contains('=')));
});
}
+3
View File
@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
+266
View File
@@ -0,0 +1,266 @@
# 01. 工程结构 / 分包策略
## 决策
使用 **[Melos](https://melos.invertase.dev/) monorepo**,按 **feature** 拆分成独立 Dart package,而不是单一 Flutter package 内部用文件夹分层。
## 包结构总览
```
conti-app/
pubspec.yaml # 根 workspace 配置(melos 8.x 不再有独立 melos.yaml,见下文)
.fvmrc # 锁定 Flutter SDK 版本
analysis_options.yaml # 全仓库共享 lint 规则
app/ # 壳工程:唯一的 Flutter application,负责路由汇总、DI 装配、编译出 ipa/apk
packages/
core_ui/ # 通用组件、主题、设计 token
core_network/ # dio 封装、拦截器、统一异常、ApiResult 解包
core_storage/ # 本地存储抽象(Drift + shared_preferences 封装)
core_auth/ # 登录态、token 管理、secure storage、门店上下文
core_router/ # 路由聚合、公共 route guard、动态菜单映射
core_webview/ # F6 H5 容器 + JSBridge(见 10-webview-h5.md
core_analytics/ # 埋点统一 API(见 13-observability-analytics.md
core_logging/ # 日志规范、脱敏、崩溃上报接入
feature_auth/ # 登录、验证码、用户协议与隐私政策
feature_home/ # 首页工作台:动态菜单、待办、预警、公告、促销位
feature_store_mgmt/ # 店铺管理:基础信息、服务信息、执照、人员管理
feature_sales/ # 销售流程:客户查询、历史工单、商机(H5 承载的部分走 core_webview
feature_purchase/ # 采购:产品查询、购物车、结算、订单、收货
feature_inventory/ # 库存:明细、安全库存、盘点、DOT
feature_analytics/ # 经营分析:对账单、核销收入、返利、报表
feature_profile/ # 个人中心:地址、热线、客服
feature_scan/ # 扫码业务入口(VIN/车牌/二维码/条码 → 分发到对应业务)
native_scan/ # 原生插件包:扫码能力(android/ios 两端实现)
native_media/ # 相机、相册、文件选择/上传
native_device/ # 拨号、设备信息、权限申请
```
包清单按 [PRD](../../conti-docs/Architecture-Diagram/202606-Continental-Retail-APP-PRD.md) 的模块划分列出,实际开工时按迭代顺序逐个建,不需要一次性全建出来。
## 依赖规则(编译期强制边界,是这套结构的核心价值)
- `app` 可以依赖所有 `core_*``feature_*`
- `feature_*` **只能**依赖 `core_*``native_*`**不能**相互依赖(`feature_purchase``pubspec.yaml` 里不允许出现 `feature_inventory` 的 path dependency)。
- `core_*` 可以依赖 `native_*``core_webview` 的 JSBridge 需要调起扫码/相机/上传)。
- `native_*` 只依赖 Flutter SDK 和 [Pigeon](https://pub.dev/packages/pigeon) 生成的代码,不依赖任何 `core_*` / `feature_*`——保证原生插件包可以脱离业务单独编译、单独测试(详见 [07-native-integration.md](./07-native-integration.md))。
`core_*` 之间原则上不互相依赖,允许的例外只有下面三条,多一条都要走评审:
| 允许的依赖 | 原因 |
|---|---|
| `core_network``core_auth` | 取 token 附加到请求头、401 时触发刷新 |
| `core_router``core_auth` | 路由 `redirect` 里判断登录态(见 [04-routing.md](./04-routing.md) |
| `core_webview``core_auth` | H5 换票需要当前登录态与门店上下文(见 [10-webview-h5.md](./10-webview-h5.md) |
两条容易踩的反向约束,必须记住:
- **`core_auth` 不依赖 `core_network`**。`core_auth` 要发 refresh 请求,如果依赖 `core_network` 就和上表第一行构成循环依赖。做法是:`core_auth` 直接依赖 `dio` 包,内部自建一个**不挂任何拦截器的裸 `Dio` 实例**专门用于刷新——这同时也避免了"刷新请求本身被 `AuthInterceptor` 拦截 → 401 → 再刷新"的递归(见 [05-networking.md](./05-networking.md))。
- **`core_auth` 不依赖 `core_storage`**。token / refresh token 走 `flutter_secure_storage`,这个依赖**归 `core_auth` 独占**`core_storage` 只负责 Drift 和 `shared_preferences`(见 [06-local-storage.md](./06-local-storage.md))。这样划分是为了不让 `core_*` 之间再多一条依赖边。
`feature_*` 不直接依赖 `go_router`,路由相关类型由 `core_router` 统一 re-export`export 'package:go_router/go_router.dart';`),这样将来换路由库时只有 `core_router` 一个包要改。
这些规则由 Dart 的包依赖机制**物理强制**:`feature_a` 根本 import 不到 `feature_b` 的任何符号,不是靠代码规范或 review 口头约束。
## Feature 间通信怎么处理
这是最容易被绕开、也是这套边界能否守住的关键点,必须写清楚合法方式:
1. **路由跳转 + 可序列化参数**(多数场景)——比如从 `feature_home` 跳到 `feature_purchase`,通过 `core_router` 声明的路径 + query/extra 参数传递,不直接引用对方的 Dart 类型。
2. **通过 `core_*` 定义的抽象接口 + DI 注册实现**——真正需要跨 feature 拿数据或发通知的场景(比如切换门店后要清空购物车),在某个 `core_*` 包里定义接口,各 feature 各自实现并在 `app` 层注册,调用方只依赖 `core_*` 里的抽象类型(门店切换的级联失效见 [11-store-context-and-session.md](./11-store-context-and-session.md))。
**不允许**的做法:任何 `feature_*``pubspec.yaml` 里直接 path dependency 另一个 `feature_*`,哪怕只是想复用一个 widget——这种情况应该把这个 widget 提到 `core_ui`
## 命名规范
- `core_xxx`:基础设施层,不含具体业务逻辑。
- `feature_xxx`:对应一个业务域(多数是原来的某个小程序,也有全新的,如首页工作台)。
- `native_xxx`:原生能力插件包,首版含 `android/``ios/` 两套原生实现目录(OHOS 不在首版范围,见 [07-native-integration.md](./07-native-integration.md))。
## SDK 版本基线
| 项 | 版本 | 说明 |
|---|---|---|
| Flutter | **3.44.9** | 用 [FVM](https://fvm.app/) 锁定,仓库根目录提交 `.fvmrc` |
| Dart | 随 Flutter 3.44.9 附带(3.12.x | 具体号以 `flutter --version` 实测为准;`environment: sdk: ^3.12.0` 对整个 3.12.x 都成立 |
**为什么不跟最新 stable3.47.0 / Dart 3.13.02026-08-12 发布)**:鸿蒙(OpenHarmony)的 Flutter 分支适配落后于官方 stable 一段时间,虽然 OHOS 不在首版范围(见 [07-native-integration.md](./07-native-integration.md) 的「OHOS 后续演进」),但 SDK 基线要为后续接 OHOS 留出兼容窗口,所以刻意停在 3.44.9 而不是追最新。这条约束在决定升级 Flutter 版本时必须重新评估,不要因为"新版本有新特性"就单方面升。
**为什么必须用 FVM 锁**monorepo 里各人本地 Flutter 版本不一致,会导致同一份代码有人 `flutter analyze` 过、有人不过,生成代码(`build_runner` 产物)也可能不一致——这类问题排查成本远高于装一次 FVM。CI 也用 `.fvmrc` 里的版本,保证本地和流水线一致。
```json
// .fvmrc
{ "flutter": "3.44.9" }
```
## Melos 配置示例(8.x,基于 Dart Pub Workspaces
Melos 7.0 起改用 Dart 官方原生的 **[Pub Workspaces](https://dart.dev/tools/pub/workspaces)** 机制,不再有独立的 `melos.yaml` 文件,配置写进根目录 `pubspec.yaml`;每个子包的 `pubspec.yaml` 需要加 `resolution: workspace`
两个不同的 SDK 下限,别搞混:
- **Pub Workspaces 机制本身**要求 Dart SDK ≥ **3.6.0**
- **melos 8.2.2 这个工具**自己要求 Dart SDK **^3.9.0**。
我们的基线(Dart 3.12.x)两条都满足。
根目录 `pubspec.yaml`
```yaml
name: conti_app
publish_to: none
environment:
sdk: ^3.12.0
workspace:
- app
- packages/core_ui
- packages/core_network
- packages/core_storage
- packages/core_auth
- packages/core_router
- packages/core_webview
- packages/core_analytics
- packages/core_logging
- packages/feature_auth
- packages/feature_home
- packages/feature_purchase
- packages/native_scan
# ... 其余包按实际建包进度追加
dev_dependencies:
melos: ^8.2.2
melos:
scripts:
analyze:
run: melos exec --fail-fast -- flutter analyze
test:
# --dir-exists=test 跳过还没有测试目录的包(如新建的 native_*),
# 否则批量命令会因为「找不到 test 目录」整体失败
run: melos exec --dir-exists=test --fail-fast -- flutter test
format:
run: melos exec -- dart format --set-exit-if-changed .
gen:
# 代码生成:riverpod_generator / drift_dev / json_serializable
run: melos exec --depends-on=build_runner -- dart run build_runner build --delete-conflicting-outputs
pigeon:
# 原生接口生成,见 07-native-integration.md
run: melos exec --scope="native_*" -- dart run pigeon --input pigeons/
```
每个子包(比如 `packages/feature_purchase/pubspec.yaml`):
```yaml
name: feature_purchase
resolution: workspace
dependencies:
core_ui:
path: ../core_ui
core_network:
path: ../core_network
core_router:
path: ../core_router
```
## 共享 lint 配置
根目录一份 `analysis_options.yaml`,各子包 include 它,不允许各包自己维护一套规则(选型与具体规则见 [14-conventions-and-ci-gates.md](./14-conventions-and-ci-gates.md)):
```yaml
# packages/feature_purchase/analysis_options.yaml
include: ../../analysis_options.yaml
```
用到 `custom_lint``riverpod_lint` 依赖它)的包,需要各自在 `dev_dependencies` 里加 `custom_lint`,并在自己的 `analysis_options.yaml` 里启用 `custom_lint` 插件——`custom_lint` 是按包运行的,不能只在根目录配一次(见 [03-state-management.md](./03-state-management.md))。
## 新增 feature 包的标准脚手架
```
feature_xxx/
pubspec.yaml # resolution: workspace + 依赖 core_ui / core_network / core_router 等,不依赖其他 feature
lib/
feature_xxx.dart # 唯一对外导出文件(barrel file):只暴露路由注册函数和必要的 public widget
src/
presentation/
domain/ # 可选,见下方分层规范文档
data/
test/
```
`src/` 目录下的内容视为包内私有实现,只有 `feature_xxx.dart` 这一个文件是对外契约——这条靠 code review 检查,Dart 语言本身没有强制的 package-private 关键字。`domain/` 目录的取舍规则详见 [02-layering.md](./02-layering.md)。
## 版本管理
不发布到 pub.dev,全部用 melos 的 path dependency,包版本号跟随 `app` 的整体版本号统一管理(fixed versioning),不做 melos 的 independent versioning——没有对外发布需求,独立版本号只会增加维护负担。
## 附录:Melos 是什么,日常怎么用
给没接触过 Dart 多包仓库工具的同学看的入门说明。
### 要解决的问题
Dart 官方的包管理工具 `pub` 天生只认"一个 `pubspec.yaml` = 一个包"。如果要在同一个 git 仓库里维护多个互相依赖的私有包(比如 `app` 依赖 `feature_scan``feature_scan` 依赖 `core_network`),原生 pub 只支持手动在每个包的 `pubspec.yaml` 里写 `path: ../../packages/core_network` 这种相对路径依赖——能跑,但没有任何批量操作能力:想给所有包统一跑一次 `flutter analyze``flutter test`,或者统一升级某个第三方库版本,都得一个包一个包手动进去执行。
**Melos 就是给这种多包仓库提供批量管理能力的工具**,类似 JS 生态里的 [Lerna](https://lerna.js.org/)/Nx,只不过是 Dart/Flutter 版本。它不改变 Dart 语言或 pub 本身的机制,只是在多个包外面包一层"批处理脚本 + 配置"。
### 核心概念
1. **根目录 `pubspec.yaml` 里的 `workspace:` 字段 + `melos:` 配置块**8.x 版本不再有独立的 `melos.yaml` 文件(7.0 之前是独立文件,现已合并进 Dart 官方原生的 Pub Workspaces 机制)。`workspace:` 列出所有子包路径,`melos:` 块下的 `scripts:` 定义可复用脚本(见上文示例)。
2. **`melos bootstrap`**(简写 `melos bs`):一键解析 workspace 内所有包之间的依赖关系。在 8.x 的 Pub Workspaces 模式下,它的效果约等于"在仓库根目录跑一次 `flutter pub get` + 校验各包 `resolution: workspace` 配置是否正确"——包间链接由 pub 原生的 workspace 机制完成,**不再生成 `pubspec_overrides.yaml`**(那是 7.0 之前的实现方式)。**新人拉下代码后第一步永远是跑这个命令**。
3. **`melos exec`**:在每一个包目录下依次/并行执行同一条命令,比如 `melos exec -- flutter test` 就是把所有包都跑一遍测试,替代手动 `cd packages/feature_purchase && flutter test && cd ../feature_inventory && ...`。常用过滤参数:`--scope`(只跑匹配名字的包)、`--dir-exists=test`(只跑有测试目录的包)、`--fail-fast`(有一个包失败就停)。
4. **`melos run <script-name>`**:调用根目录 `pubspec.yaml``melos: scripts:` 下预定义的脚本别名(比如上文的 `melos run test`),团队里统一敲固定命令,不用记 `exec` 的完整写法。
### 日常开发流程(拿本仓库举例)
```bash
# 0. 一次性:安装 fvm 并装上基线版本的 Flutter
dart pub global activate fvm
fvm install # 读 .fvmrc,装 3.44.9
fvm flutter --version
# 1. 第一次拉代码,或者别人加了新包/新依赖之后
melos bootstrap
# 2. 正常改代码,比如在 feature_purchase 里改一个页面
cd packages/feature_purchase
flutter run # 这一步跟平时写单个 Flutter 项目完全一样,感觉不到 melos 的存在
# 3. 改了带注解的代码(Riverpod / Drift / json_serializable)之后
melos run gen
# 4. 提交前,跑一遍全仓库检查
melos run analyze
melos run test
# 5. 新增了 feature 包,或者某个包新增了对另一个包的依赖之后
melos bootstrap # 重新解析依赖关系
```
**关键体感**:平时在某一个包里写代码、`flutter run`、热重载,跟没有 melos 时完全一样——melos 只在"跨包操作"(装依赖、批量测试、批量分析)时才会用到,不侵入日常单包开发的手感。
### 常见的坑
- 加了新包,或改了某个包的依赖之后忘记跑 `melos bootstrap`,会出现"明明加了依赖但 import 不到"的报错——看到这个报错先跑一遍 bootstrap 再排查。
- 8.x 基于 Pub Workspaces 后,正常的包间链接**不再**依赖 `pubspec_overrides.yaml`(这是 7.0 之前版本的机制);只有配置了额外的 `dependencyOverridePaths`(用于覆盖外部第三方依赖,不是本仓库包之间的常规场景)时才会生成这个文件。如果看到这个文件出现却不记得配置过覆盖路径,说明配置可能有误,需要检查。
### 安装
```bash
dart pub global activate melos
```
全局命令,装一次即可,不需要每个项目单独安装。
## 参考链接
- [Melos 官方文档](https://melos.invertase.dev/)
- [melos | Dart package (pub.dev)](https://pub.dev/packages/melos)
- [Melos changelog](https://pub.dev/packages/melos/changelog)
- [Melos Configuration overview](https://melos.invertase.dev/configuration/overview)
- [Dart Pub Workspaces 官方文档](https://dart.dev/tools/pub/workspaces)
- [FVMFlutter Version Management](https://fvm.app/)
- [Pigeon | Dart package](https://pub.dev/packages/pigeon)
- [Drift | Dart package](https://pub.dev/packages/drift)
- [LernaJS 生态对标工具)](https://lerna.js.org/)
+234
View File
@@ -0,0 +1,234 @@
# 02. 分层架构规范
## 决策
每个 `feature_*` 包内部采用简化版分层,`domain` 层**可选**,不强制每个 feature 都有:
```
feature_xxx/
lib/
feature_xxx.dart # 对外唯一导出文件
src/
presentation/ # widgets + Riverpod provider/notifier
domain/ # 可选:entity + repository 接口 + use case
data/ # repository 实现 + remote/local datasource
test/
```
## 各层职责
- **presentation**widgets、Riverpod `Notifier`/`Provider`。只处理 UI 状态和用户交互,不直接调用 `data` 层的具体实现类,通过依赖注入拿到抽象类型。
- **domain**(可选):`entity` 定义业务模型,`repository` 接口声明数据契约,`use case` 封装跨 repository 协调或多步骤业务规则。
- **data**`repository` 接口的具体实现,内部再拆 `remote_datasource`(走 `core_network`)和 `local_datasource`(走 `core_storage`)。
## 何时可以跳过 domain 层
判断标准:
- **可以跳过**:功能是简单 CRUD、没有跨 repository 协调、没有多步骤业务规则——`presentation` 直接依赖 `data` 层定义的 repository 接口即可,`repository` 接口挪到 `data` 层里声明。
- **必须要有**:涉及多步骤业务规则(如支付的多步校验)、需要协调多个 repository、包含状态机或需要独立于 UI 单元测试的核心业务逻辑——`repository` 接口放在 `domain``data` 层依赖 `domain` 反向实现接口。
## Repository 接口的位置规则
-`domain` 层:接口定义在 `domain/repository/``data/repository_impl/` 实现它,`presentation` 只依赖 `domain` 里的抽象类型。
-`domain` 层:接口直接定义在 `data/repository/`,同文件或同目录下给出实现类,`presentation` 依赖这个接口类型。
两种情况下,`presentation` 都不允许直接依赖 `data` 层的具体实现类(如 `XxxRepositoryImpl`),只依赖接口——这条不因为是否跳过 domain 层而改变。
## 跨层依赖规则
```
presentation → domain(或直接 → data 的接口,若跳过 domain
domain → 不依赖 presentation / data
data → 依赖 domain 的接口(若有),依赖 core_network / core_storage
```
`domain` 层禁止 import 的东西,不只是 Flutter SDK
- `package:flutter/...`UI 框架)
- `package:dio/...`(网络库)
- `package:drift/...`(数据库)
- 任何做 IO 的第三方库
`domain` 只允许 `dart:core`/`dart:async` 这类纯语言能力和项目内的纯 Dart 类型。这条如果松了,"domain 可以脱离 UI 和网络单独跑 unit test"就名存实亡——只要 import 了 `dio`,测试就得处理它的初始化和平台依赖。
## 数据模型与 JSON 序列化
**决策**DTO 用 [json_serializable](https://pub.dev/packages/json_serializable) 生成 `fromJson`/`toJson`,不手写;**不引入 freezed**。
```yaml
dependencies:
json_annotation: ^4.9.0
dev_dependencies:
json_serializable: ^6.9.0
build_runner: ^2.15.2
```
- **为什么不上 freezed**freezed 主要提供不可变类、`copyWith`、联合类型(sealed class)。Dart 3 已经原生支持 `sealed class`/`final class` 和模式匹配,联合类型这块的收益大幅缩水;而 `copyWith` 的收益不足以抵消"再加一个 codegen 目标 + 生成文件体积翻倍 + 编译变慢"的成本。项目里已经有 `riverpod_generator``drift_dev``json_serializable``pigeon` 四个 codegen 目标,能不加就不加(同 [09-testing.md](./09-testing.md) 里不选 `mockito` 的理由)。
- **DTO 与 entity 是否分两套类型**:默认**不分**,`data` 层的 DTO 直接当 `domain` 的 entity 用,只在下面两种情况才拆两套并写转换函数:
1. 后端字段结构明显不适合业务使用(比如时间戳是字符串、状态是魔法数字、嵌套层级很深)。
2. 同一个业务概念由多个接口拼出来(比如首页 tile 聚合了多个 Mini 域的返回)。
拆两套要付出双份类型 + 一份转换代码的成本,多数简单 CRUD 场景不值得。
-`domain` 层的 feature 如果拆了两套类型,转换函数放在 `data` 层(`domain` 不能知道 JSON 长什么样)。
## 后端统一响应包装在哪一层解开
后端所有接口返回 `ApiResult<T> { code, message, data, traceId }`(见 [backend/06-api-design.md](../../conti-backend/docs/06-api-design.md))。**解包统一发生在 `core_network` 的拦截器里,不在各 feature 的 repository 里重复写**
- `code == 0` → 把 `data` 取出来交给 repositoryrepository 的 `fromJson` 只需要认识 `data` 的结构,完全不用感知外层包装。
- `code != 0` → 直接抛 `BusinessException(code, message, traceId)`
- `traceId` 无论成功失败都记录进日志。
完整契约见 [12-error-and-api-contract.md](./12-error-and-api-contract.md)。这条规则的意义是:以后如果后端调整了包装格式,只有 `core_network` 一个地方要改。
## 分页的统一约定
PRD §21.1 要求列表页支持分页/分段加载。repository 层的分页方法统一签名,不让每个 feature 各自发明一套参数名:
```dart
// core_network 里定义的通用分页类型
class PageQuery {
const PageQuery({required this.page, this.size = 20});
final int page; // 从 1 开始
final int size;
}
class PageResult<T> {
const PageResult({required this.items, required this.total, required this.page});
final List<T> items;
final int total;
final int page;
bool get hasMore => items.length + (page - 1) * items.length < total;
}
// feature 侧
abstract class PurchaseOrderRepository {
Future<PageResult<PurchaseOrder>> fetchOrders(PageQuery query);
}
```
具体字段名以后端最终约定为准(backend 06 的「待补充」里也挂着分页约定这一项),联调前需要跟后端对齐一次。
## 附录:分层架构是什么,为什么要分层
给还没接触过这套分层习惯的同学看的入门说明。
> 下面示例里的 `feature_payment` / `feature_store` 是为了讲清分层概念用的简化例子,不是最终包清单(实际包清单见 [01-project-structure.md](./01-project-structure.md))。
### 要解决的问题
如果 UI 代码里直接写网络请求、直接 new 一个 `Dio` 实例、直接操作数据库——短期能跑,但会导致两个问题:
1. **没法单独测试业务逻辑**:想验证"支付金额校验规则对不对",得连 widget 一起跑测试,跑得慢还容易因为 UI 变了导致业务逻辑测试跟着挂。
2. **换底层实现要动 UI 代码**:比如把网络库从 `dio` 换掉,或者把本地存储从 `shared_preferences` 换成 `Drift`,如果 UI 直接依赖具体实现类,改动会散落得到处都是。
**分层的本质**:把"业务规则"和"业务规则的具体实现方式(用什么网络库、存什么数据库)"分开,中间用抽象接口隔开。这就是 [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) 这套思想的核心,我们只取最简化的三层版本,不套用它完整的同心圆规则。
### 依赖方向是关键
三层最重要的不是"分了几层",而是**依赖只能单向流动**:
```
presentation ──依赖──> domain ──定义接口,不依赖任何人
│ 实现接口(依赖倒置)
data
```
`domain` 不 import `data`,也不 import `presentation`——它甚至不知道 `data` 层是用 `dio` 还是别的什么网络库实现的,只定义"我需要一个能拿到 `PaymentOrder` 的东西"(接口),至于这个东西具体怎么实现,由 `data` 层负责,这就是[依赖倒置原则](https://en.wikipedia.org/wiki/Dependency_inversion_principle)。好处是:`domain` 层的业务规则可以完全脱离网络、脱离 UI 单独写单元测试。
### 示例一:有 domain 层(`feature_payment`,支付确认——多步骤业务规则)
```dart
// domain/entity/payment_order.dart
class PaymentOrder {
final String orderId;
final int amountCents;
final PaymentStatus status;
const PaymentOrder({required this.orderId, required this.amountCents, required this.status});
}
// domain/repository/payment_repository.dart
abstract class PaymentRepository {
Future<PaymentOrder> fetchOrder(String orderId);
Future<void> confirmPayment(String orderId, String pinToken);
}
// domain/use_case/confirm_payment_use_case.dart
class ConfirmPaymentUseCase {
final PaymentRepository _repository;
ConfirmPaymentUseCase(this._repository);
Future<void> call(String orderId, String pinToken) async {
final order = await _repository.fetchOrder(orderId);
if (order.status != PaymentStatus.pending) {
throw StateError('订单状态不允许支付: ${order.status}');
}
if (order.amountCents <= 0) {
throw ArgumentError('订单金额非法');
}
await _repository.confirmPayment(orderId, pinToken);
}
}
// data/repository/payment_repository_impl.dart
class PaymentRepositoryImpl implements PaymentRepository {
final ApiClient _api; // 来自 core_network,不是裸 Dio,见 05-networking.md
PaymentRepositoryImpl(this._api);
@override
Future<PaymentOrder> fetchOrder(String orderId) async {
// 注意:返回的已经是 ApiResult 里的 data 部分——
// { code, message, data, traceId } 这层包装由 core_network 的拦截器统一解开,
// repository 不感知它的存在(见上文「后端统一响应包装在哪一层解开」)
final json = await _api.get<Map<String, dynamic>>('/api/v1/orders/$orderId');
return PaymentOrder(
orderId: json['orderId'] as String,
amountCents: json['amountCents'] as int,
status: PaymentStatus.values.byName(json['status'] as String),
);
}
@override
Future<void> confirmPayment(String orderId, String pinToken) =>
_api.post('/api/v1/orders/$orderId/confirm', data: {'pinToken': pinToken});
}
```
`ConfirmPaymentUseCase` 的多步校验规则可以直接用假的 `PaymentRepository` 实现来做单元测试,完全不需要启动 Flutter engine 或起一个 mock server。
### 示例二:跳过 domain 层(`feature_store`,门店列表——简单 CRUD
```dart
// data/repository/store_repository.dart
abstract class StoreRepository {
Future<List<Store>> fetchNearbyStores(double lat, double lng);
}
class StoreRepositoryImpl implements StoreRepository {
final ApiClient _api;
StoreRepositoryImpl(this._api);
@override
Future<List<Store>> fetchNearbyStores(double lat, double lng) async {
// 同上:拿到的是解开 ApiResult 包装之后的 data
final list = await _api.get<List<dynamic>>(
'/api/v1/stores',
query: {'lat': lat, 'lng': lng},
);
return list.map((e) => Store.fromJson(e as Map<String, dynamic>)).toList();
}
}
```
没有多步骤规则、没有跨 repository 协调,接口和实现直接放在 `data` 层,`presentation` 直接依赖 `StoreRepository` 这个接口,省掉一层 `domain` 目录和 use case 模板代码。
## 参考链接
- [Flutter 官方状态管理文档](https://docs.flutter.dev/data-and-backend/state-mgmt)
- [The Clean ArchitectureUncle Bob 原文)](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
- [依赖倒置原则(Dependency Inversion Principle](https://en.wikipedia.org/wiki/Dependency_inversion_principle)
- [json_serializable | Dart package](https://pub.dev/packages/json_serializable)
- [Dart 3 sealed class 与模式匹配](https://dart.dev/language/patterns)
+227
View File
@@ -0,0 +1,227 @@
# 03. 状态管理方案
## 决策
使用 **[Riverpod](https://riverpod.dev/)**`flutter_riverpod` + `riverpod_generator` 代码生成),不使用 Bloc/Provider/GetX。
版本基线:`flutter_riverpod: ^3.4.2`(当前 stable,2026-08 快照,需在实际开工时用 `flutter pub outdated` 复核)。
## 依赖
```yaml
dependencies:
flutter_riverpod: ^3.4.2
riverpod_annotation: ^3.4.2
dev_dependencies:
riverpod_generator: ^3.4.2
build_runner: ^2.15.2
custom_lint: ^0.8.1
riverpod_lint: ^3.1.8
```
> `custom_lint` 的版本必须是 `^0.8.x``riverpod_lint 3.x` 依赖的是 `custom_lint 0.8.x`,写成 `^0.6.0` 会直接 `pub get` 解析失败。`custom_lint` 的版本约束比较严,每次升 `riverpod_lint` 都要顺带核一下它要求的 `custom_lint` 版本。
## 使用规则
- 所有跨 widget 共享的状态、依赖注入,统一通过 Riverpod provider 暴露,不额外引入 `get_it`/`provider` 等其他 DI 方案。
- 优先使用 `riverpod_generator` 的注解写法(`@riverpod`),不手写裸 `Provider`/`StateNotifierProvider` 模板代码。
- `Notifier`/`AsyncNotifier` 用于承载可变的 feature 状态;无状态的计算/依赖注入用普通 `Provider`
- `domain`/`data` 层的 repository 实现通过 provider 注入到 `presentation` 层,`presentation` 只依赖 provider 暴露的接口类型(见 [02-layering.md](./02-layering.md))。
- 每个 `feature_*` 包各自维护自己的 provider,不跨包直接引用另一个 feature 的 provider(同 [01-project-structure.md](./01-project-structure.md) 的 feature 隔离规则);跨 feature 共享的 provider 定义在对应的 `core_*` 包里。
## Riverpod 3 的自动重试:全局关掉
Riverpod 3 起,**provider 抛异常后会自动重试**,默认策略是指数退避(200ms 起,翻倍到 6.4s 封顶)。这个默认行为在本项目里弊大于利,有三个具体问题:
1. **和 401 刷新打架**access token 过期时,`core_network``AuthInterceptor` 已经在做刷新 + 重放(见 [05-networking.md](./05-networking.md))。provider 层再自动重试一轮,等于同一个失败被两套机制各重试一次,日志里会出现莫名其妙的重复请求。更糟的是后端 refresh token 是**一次性轮换**的(见 [backend/04-security-auth.md](../../conti-backend/docs/04-security-auth.md)),并发刷新会被判定为重放攻击,导致该用户所有 refresh token 被撤销、被强制登出。
2. **错误提示会闪**UI 拿到 `AsyncError` 弹了错误提示,200ms 后自动重试又切回 `AsyncLoading`,用户看到的是提示一闪而过。
3. **测试 flaky**:单测里断言 `AsyncError` 时,后台还挂着一个待重试的定时器,测试跑完 container 被 dispose 会报 pending timer,或者断言时机不对直接读到 `AsyncLoading`
**决策**:在 `ProviderScope` 上全局关闭 retry,需要重试的地方显式打开。
```dart
// app/lib/main.dart
void main() {
runApp(
ProviderScope(
// 全局关掉自动重试:返回 null 表示"不重试"
retry: (retryCount, error) => null,
child: const ContiApp(),
),
);
}
```
单个 provider 确实需要重试时(比如首页 tile 这种失败了自己悄悄重试一次比弹错更好的场景),在该 provider 上单独开:
```dart
@Riverpod(retry: _homeTileRetry)
Future<List<Tile>> homeTiles(Ref ref) async { /* ... */ }
// 只重试一次,且只对网络类错误重试;业务错误(BusinessException)重试没有意义
Duration? _homeTileRetry(int retryCount, Object error) {
if (retryCount >= 1) return null;
if (error is! NetworkException) return null;
return const Duration(milliseconds: 500);
}
```
规则:**重试只对"重试一次可能就好了"的错误有意义**——超时、连接失败。业务错误码(后端返回 `code != 0`)、401、参数错误重试多少次都是同样的结果,只是在浪费用户的时间和流量。
## 缓存生命周期:默认 autoDispose,长驻要写理由
`@riverpod` 注解生成的 provider **默认是 autoDispose 的**(没有 listener 时自动销毁并释放状态)。这个默认值保持不变,原因是门店切换的场景下(见下一节)"用完就销毁"能省掉一大堆手动清理。
要改成长驻的写 `@Riverpod(keepAlive: true)`,并且**必须在注释里写清为什么**。目前认可的长驻场景只有三类:
- 全局单例依赖(`Dio` 实例、`Database` 实例、`SharedPreferences`)——本来就该活到进程结束。
- 全局会话状态(登录态、当前门店上下文,见 [11-store-context-and-session.md](./11-store-context-and-session.md))。
- 明确要跨页面保留的数据(比如工作台数据,用户从子页面返回时不希望再 loading 一次)。
除此之外一律 autoDispose。列表页数据尤其不要 keepAlive——门店切了、权限变了,长驻的旧数据会直接显示成错的。
需要"短时间内返回不重新加载、但也不永久长驻"的,用 `ref.keepAlive()` + 定时器的写法,别直接 `keepAlive: true`
```dart
@riverpod
Future<List<Store>> storeList(Ref ref) async {
final link = ref.keepAlive();
final timer = Timer(const Duration(minutes: 5), link.close); // 5 分钟后允许被回收
ref.onDispose(timer.cancel);
return ref.watch(storeRepositoryProvider).fetchStores();
}
```
## 门店切换 / 登出时的批量失效
PRD §11.4 要求切换门店后购物车、待办、预警、订单上下文全部跟着切。落到 Riverpod 上,**不能靠每个 feature 自己去监听门店变化**——总会漏掉一个,而漏掉的表现是"用户在 A 门店看到 B 门店的数据",属于严重问题。
统一做法:所有与门店相关的 provider 都 `ref.watch(currentStoreIdProvider)`,让 Riverpod 的依赖图自己完成级联失效。
```dart
@riverpod
Future<List<PurchaseOrder>> purchaseOrders(Ref ref) async {
// watch 而不是 read:门店一变,这个 provider 自动重建
final storeId = ref.watch(currentStoreIdProvider);
return ref.watch(purchaseRepositoryProvider).fetchOrders(storeId);
}
```
这条规则要写进 code review checklist**任何请求带 storeId 的 providerstoreId 必须来自 `ref.watch(currentStoreIdProvider)`,不允许从别处传参或 `ref.read`**。`ref.read` 拿到的是快照,门店变了不会触发重建,这正是最容易漏的地方。
依赖图管不到的部分(Drift 本地缓存、H5 会话、导航栈)需要显式清理,完整清单见 [11-store-context-and-session.md](./11-store-context-and-session.md)。
## 测试
- `Notifier`/`AsyncNotifier` 的单元测试用 **`ProviderContainer.test()`** 直接实例化,不依赖 widget tree——这是 Riverpod 3 新增的测试专用构造,自带 `addTearDown(container.dispose)`,不需要再手写。
- Widget 测试中用 `ProviderScope(overrides: [...])` 注入 mock 依赖。
- 测试里如果某个 provider 单独开了 retry,断言错误状态前记得覆盖掉,否则会遇到 pending timer(详见 [09-testing.md](./09-testing.md))。
## 附录:Riverpod 是什么,日常怎么用
给还没接触过 Riverpod 的同学看的入门说明。
### 要解决的问题
Flutter 官方最早推荐的状态管理方式是 [`InheritedWidget`](https://docs.flutter.dev/data-and-backend/state-mgmt/inherited-widget)——通过 widget 树往下传数据。写法繁琐,社区后来做了一层封装叫 [`Provider`](https://pub.dev/packages/provider),但 `Provider` 本质还是绑定在 widget 树上:拿依赖必须要有 `BuildContext`,写错了会在运行时才报错(比如 `ProviderNotFoundException`),而且没法很方便地在 widget 树之外(比如后台任务、单元测试)读取状态。
**Riverpod**`Provider` 的原作者 Remi Rousselet 重新设计的下一代方案:把状态容器从 widget 树里剥离出来,变成一套独立的依赖图,`BuildContext` 不再是拿依赖的必要条件,错误也从运行时提前到**编译期**发现(比如 provider 类型不匹配会直接编译报错,而不是运行时崩溃)。
### 核心概念
1. **`Provider`**:声明一个"如何创建某个值"的配方,值可以是同步的、异步的(`Future`/`Stream`)、也可以是可变的状态。
2. **`Notifier` / `AsyncNotifier`**:承载**可变**状态的载体,通过方法修改状态(类似过去 `StateNotifier` 的角色,3.x 里统一成 `Notifier`)。
3. **`ref.watch(xxxProvider)`**:在 widget 或另一个 provider 里订阅某个 provider,值变化时自动触发重建/重新计算。
4. **`ref.read(xxxProvider)`**:只读取一次当前值,不订阅变化(一般用在按钮点击等一次性事件回调里)。
5. **`@riverpod` 注解 + 代码生成**:手写 `Provider`/`NotifierProvider` 样板代码容易出错(尤其是泛型),项目统一用 `riverpod_generator` 的注解写法,跑 `build_runner` 自动生成对应的 provider。
### 使用示例(`feature_store`:拉取附近门店列表)
```dart
// presentation/store_list_notifier.dart
part 'store_list_notifier.g.dart';
@riverpod
class StoreListNotifier extends _$StoreListNotifier {
@override
Future<List<Store>> build() async {
final repository = ref.watch(storeRepositoryProvider);
final position = ref.watch(currentPositionProvider); // 定位也是一个 provider,不是 notifier 的字段
return repository.fetchNearbyStores(position.lat, position.lng);
}
Future<void> refresh() async {
// 让 Riverpod 重跑 build(),而不是自己去调 build()
ref.invalidateSelf();
await future; // 等这一轮重建完成,方便下拉刷新的 RefreshIndicator 收起动画
}
}
```
> **不要写成 `state = await AsyncValue.guard(() => build())`。** `build()` 里有 `ref.watch`,只有 Riverpod 自己在重建流程中调用它才能正确重建订阅关系;手动调用会让旧的订阅残留、新的订阅重复注册。需要重跑 `build()` 就用 `ref.invalidateSelf()`。
>
> 只想改一部分状态、不想重跑整个 `build()` 时,才用 `AsyncValue.guard`,而且里面调的是 repository 而不是 `build()`
>
> ```dart
> Future<void> loadMore() async {
> final current = state.valueOrNull ?? const [];
> state = await AsyncValue.guard(() async {
> final next = await ref.read(storeRepositoryProvider).fetchNearbyStores(/* ... */);
> return [...current, ...next];
> });
> }
> ```
```dart
// presentation/store_list_page.dart
class StoreListPage extends ConsumerWidget {
const StoreListPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final storesAsync = ref.watch(storeListNotifierProvider);
return storesAsync.when(
data: (stores) => ListView.builder(
itemCount: stores.length,
itemBuilder: (_, i) => ListTile(title: Text(stores[i].name)),
),
loading: () => const CircularProgressIndicator(),
error: (err, _) => Text('加载失败: $err'),
);
}
}
```
`storeRepositoryProvider` 定义在 `data` 层(见 [02-layering.md](./02-layering.md) 的跳过 domain 层示例),`StoreListNotifier` 通过 `ref.watch` 拿到接口类型,不关心具体实现——这就是 Riverpod 承担依赖注入职责的地方,不需要额外的 `get_it`
### 测试示例
```dart
test('刷新后状态应更新为最新门店列表', () async {
// ProviderContainer.test() 是 Riverpod 3 的测试专用构造,
// 自动注册 tearDown 做 dispose,不用再写 addTearDown(container.dispose)
final container = ProviderContainer.test(
overrides: [
storeRepositoryProvider.overrideWithValue(FakeStoreRepository()),
],
);
final stores = await container.read(storeListNotifierProvider.future);
expect(stores, isNotEmpty);
});
```
`ProviderContainer` 让整个依赖图脱离 widget 树单独运行,`overrides` 直接替换掉真实的 repository,这也是"编译期安全 + 好测试"这条评价的具体体现。
## 参考链接
- [Riverpod 官方文档](https://riverpod.dev/)
- [Riverpod 3 迁移指南](https://riverpod.dev/docs/whats_new)
- [Riverpod: Automatic retry](https://riverpod.dev/docs/whats_new#automatic-retry)
- [riverpod_generator | Dart package](https://pub.dev/packages/riverpod_generator)
- [flutter_riverpod | Dart package](https://pub.dev/packages/flutter_riverpod)
- [riverpod_lint | Dart package](https://pub.dev/packages/riverpod_lint)
- [InheritedWidgetFlutter 官方文档)](https://docs.flutter.dev/data-and-backend/state-mgmt/inherited-widget)
- [provider | Dart package](https://pub.dev/packages/provider)
+198
View File
@@ -0,0 +1,198 @@
# 04. 路由方案
## 决策
使用 **[go_router](https://pub.dev/packages/go_router)**`^17.5.0`2026-08 快照,Flutter 官方维护),声明式路由 + 嵌套 `ShellRoute`,不使用 `Navigator 1.0` 命令式 push/pop 作为主路由方式。
## 依赖
```yaml
dependencies:
go_router: ^17.5.0
```
## 路由注册规则
- 每个 `feature_*` 包在自己的 `feature_xxx.dart`(对外唯一导出文件)里暴露一个 `List<RouteBase> buildXxxRoutes()` 函数,只声明属于自己的路由,不感知其他 feature。
- `core_router` 包负责把所有 feature 的路由函数聚合成最终的 `GoRouter` 实例,是唯一知道"全部路由长什么样"的地方。
- 路径命名统一用 `kebab-case`,前缀按业务域分组,例如 `/store/:storeId``/payment/confirm`
- 底部导航等常驻 UI 用 `ShellRoute`/`StatefulShellRoute` 包裹对应的 feature 路由,不在每个页面里重复搭一遍导航栏。
- 登录态校验统一在 `core_router` 聚合层用 `redirect` 实现,不在每个页面里各自判断 token 是否过期。
- 跨 feature 跳转只能传**可序列化参数**(path 参数、query 参数,或可序列化的 `extra`),不允许把一个 feature 内部的 Dart 类实例通过 `extra` 传给另一个 feature——这是 [01-project-structure.md](./01-project-structure.md) "Feature 间通信" 规则在路由层的具体落地。
- `feature_*` 不直接依赖 `go_router`,而是依赖 `core_router`,由 `core_router` re-export `GoRoute`/`RouteBase`/`GoRouterState` 等类型。这样将来换路由库或升大版本时,只有 `core_router` 一个地方要动。
## `GoRouter` 实例不能因为登录态变化被重建
这是 go_router + Riverpod 组合里最常见的一个坑,写错了表现是"用户在三级页面停留时 token 刷新了一下,人被弹回首页"。
`GoRouter` 内部持有导航栈。如果 provider 里写 `ref.watch(authStateProvider)`,登录态一变整个 provider 重建、旧 `GoRouter` 被丢弃、新的从 `initialLocation` 开始——导航栈就没了。
**正确写法**`redirect` 里用 `ref.read` 读当前登录态,外面用 `ref.listen` 监听变化并调 `router.refresh()` 让 go_router 重跑一次 `redirect`
```dart
// packages/core_router/lib/src/app_router.dart
final rootNavigatorKey = GlobalKey<NavigatorState>();
final goRouterProvider = Provider<GoRouter>((ref) {
final router = GoRouter(
navigatorKey: rootNavigatorKey, // 全局 dialog / 顶层跳转需要它
initialLocation: '/home',
observers: [NavigationObserver(ref.read(crashReporterProvider))], // 崩溃前的页面路径,见 13
redirect: (context, state) {
// read 不是 watch:这里只要当前值,订阅由下面的 listen 负责
final auth = ref.read(authStateProvider);
final loggingIn = state.matchedLocation == '/login';
if (!auth.isLoggedIn) {
if (loggingIn) return null;
// 带上原目标,登录成功后回跳
return '/login?from=${Uri.encodeComponent(state.uri.toString())}';
}
if (loggingIn) {
final from = state.uri.queryParameters['from'];
return (from == null || from.isEmpty) ? '/home' : Uri.decodeComponent(from);
}
return null;
},
errorBuilder: (context, state) => RouteNotFoundPage(location: state.uri.toString()),
routes: [
GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) => MainShell(navigationShell: navigationShell),
branches: [
StatefulShellBranch(routes: buildHomeRoutes()),
StatefulShellBranch(routes: buildPurchaseRoutes()),
StatefulShellBranch(routes: buildProfileRoutes()),
],
),
],
);
// 登录态变化时只重跑 redirect,不重建 router,导航栈得以保留
ref.listen(authStateProvider, (_, __) => router.refresh());
ref.onDispose(router.dispose);
return router;
});
```
要点:
- `redirect` 里**只能 `ref.read`**,不能 `ref.watch``Provider``create` 已经跑完了,`watch` 在回调里语义也不对)。
- `ref.onDispose(router.dispose)` 不能漏:`GoRouter` 持有 `Listenable`,不 dispose 在热重载和测试里会泄漏。
-`ref.listen` 而不是 `refreshListenable`,是因为登录态本身是一个 Riverpod provider,用 `refreshListenable` 还要额外包一个 `ChangeNotifier` 适配层,没必要。
### `errorBuilder` 是必须的
不写 `errorBuilder`,遇到未注册的路径(深链接拼错、后端下发了一个 App 还不认识的菜单 code、H5 回跳的 URL 有问题)go_router 会显示一个英文的默认错误页,对门店一线员工来说等于崩溃。统一给一个"页面不存在,请检查是否需要升级 App"的兜底页,并把 `state.uri` 上报(见 [13-observability-analytics.md](./13-observability-analytics.md))——这个上报很有价值,能直接暴露出后端下发了 App 不支持的菜单。
## 后端动态菜单 → 本地路由的映射
PRD §22.2:工作台菜单由后端按角色权限下发,不是写死在 App 里的。但**路由表必须是编译期写死的**(页面是 Dart 代码,不可能动态下发)。所以中间需要一张映射表。
约定:后端下发的每个菜单项带一个稳定的 `code`(如 `PURCHASE_ORDER``INVENTORY_CHECK`),`core_router` 里维护 `code → 路由路径` 的映射。
```dart
// packages/core_router/lib/src/menu_route_map.dart
const menuRouteMap = <String, String>{
'PURCHASE_ORDER': '/purchase/orders',
'INVENTORY_CHECK': '/inventory/check',
'QUOTE_ORDER': '/webview?target=QUOTE_ORDER', // H5 承载的功能也走这张表
// ...
};
/// 未知 code 返回 null,调用方据此决定隐藏还是提示升级
String? resolveMenuRoute(String code) => menuRouteMap[code];
```
**未知 `code` 的兜底策略**:直接**隐藏**该菜单项,同时上报一条 `menu_code_unsupported` 事件(带 code 和 App 版本)。
- 不选"提示升级":老版本 App 上会因为后端加了新菜单就弹升级提示,对完全不需要这个新功能的门店是骚扰。
- 隐藏 + 上报的组合能让我们从数据上看到"有多少用户因为版本旧看不到新功能",需要推升级时再针对性推。
`code` 一旦定义就不能改含义(改了等于老版本 App 跳错页面),新增功能只能加新 `code`。这条要在后端接口评审时对齐。
## H5 页面的路由约定
PRD §7 的核心功能(报价开单、施工查车、结算收银)走 Embedded H5。这些页面在路由表里的形态统一为:
```
/webview?target=<TARGET_CODE>&title=<可选标题>
```
**只传目标标识,不传裸 URL。** 真实 URL 由 `core_webview``target` 去 App Backend 换票后拿到(见 [10-webview-h5.md](./10-webview-h5.md))。
理由:如果路由里能直接塞 URL,那么任何能构造深链接的地方(推送、H5 内跳转、剪贴板)都能让 App 打开任意网页,是一个明确的安全洞。`target` 是一个白名单枚举,能打开哪些页面完全由后端和 App 共同决定。
即便如此,`core_webview` 拿到后端返回的 URL 后**仍要做一次域名白名单校验**——纵深防御,后端被打穿或配置写错时还有一道。
## 门店切换后的路由重置
PRD §11.4:切换门店后所有业务上下文跟着切。导航栈是其中一部分——用户在 A 门店的"采购单详情 `/purchase/orders/123`"页面切到 B 门店,这个订单 ID 在 B 门店可能不存在,或者更糟,存在但是另一张单。
**规则:切换门店成功后,清空导航栈回工作台。**
```dart
// 门店切换成功的回调里
ref.read(goRouterProvider).go('/home'); // go 而不是 push:替换整个栈
```
`StatefulShellRoute` 的各 branch 栈也会跟着重置。这个动作和 provider 失效、缓存清理、H5 会话失效是一组,统一在 `11-store-context-and-session.md` 里编排,不散在各处调用。
## 参考链接
- [go_router 官方文档](https://pub.dev/packages/go_router)
- [go_router: Redirection](https://pub.dev/documentation/go_router/latest/topics/Redirection-topic.html)
- [go_router: Navigationgo vs push](https://pub.dev/documentation/go_router/latest/topics/Navigation-topic.html)
- [StatefulShellRoute API](https://pub.dev/documentation/go_router/latest/go_router/StatefulShellRoute-class.html)
## 附录:go_router 是什么,日常怎么用
给还没接触过声明式路由的同学看的入门说明。
### 要解决的问题
`Navigator 1.0` 的命令式写法(`Navigator.push(context, MaterialPageRoute(...))`)在页面不多的时候很直观,但规模上来后有几个明显问题:
1. **深链接(deep link/ Web URL 支持差**:命令式 push 本质是"从当前页面跳到下一个页面",很难直接根据一个 URL 字符串恢复出正确的页面栈——比如从推送通知直接打开"门店详情页",命令式写法需要手动拼一串 `push` 调用重建整个栈。
2. **没有统一的登录拦截点**:每个需要登录态的页面都要自己在 `initState` 里判断要不要跳转到登录页,逻辑散落在各处。
3. **底部导航这种"多个 tab 各自维护自己的页面栈"的场景很难优雅表达**
**go_router** 是 Flutter 官方团队维护的声明式路由方案:路由表是一份**声明式配置**(一棵 `GoRoute` 树),当前 URL 决定当前应该显示什么页面栈,而不是"一步步 push 出来的"。因为路由是声明式的、和 URL 强绑定,深链接、Web 浏览器前进/后退、登录拦截都能用同一套机制解决。
### 核心概念
1. **`GoRoute`**:一条路由规则,`path` 是路径模板(支持 `:id` 这种参数),`builder`/`pageBuilder` 返回对应页面。
2. **`ShellRoute` / `StatefulShellRoute`**:包一层常驻 UI(比如带底部导航栏的外壳),内部嵌套的子路由切换时,外壳本身不重建;`StatefulShellRoute` 还能让每个 tab 各自保留自己的页面栈(切 tab 不丢失之前的浏览位置)。
3. **`GoRouterState`**:在 `builder` 里能拿到当前路由的 path 参数(`state.pathParameters`)、query 参数(`state.uri.queryParameters`)、`extra` 对象。
4. **`redirect`**:每次路由变化前会先跑一遍 `redirect` 回调,返回非空字符串就强制跳转——这是实现"未登录访问需要登录的页面 → 自动跳登录页"的地方。
5. **`context.go()` / `context.push()`**`go` 是替换当前路由(浏览器前进后退语义),`push` 是在当前栈上叠加一层(可以 `pop` 回去)——日常最容易混淆的两个 API,选错会导致返回键行为不符合预期。
### 使用示例(底部导航 + 门店详情页)
> 完整的 `goRouterProvider`(含登录拦截、回跳、错误兜底)见上文「`GoRouter` 实例不能因为登录态变化被重建」,这里只演示 feature 侧怎么声明自己的路由。
```dart
// packages/feature_store_mgmt/lib/feature_store_mgmt.dart
List<RouteBase> buildStoreRoutes() => [
GoRoute(
path: '/store',
builder: (context, state) => const StoreListPage(),
routes: [
GoRoute(
path: ':storeId', // 完整路径 /store/:storeId
builder: (context, state) {
final storeId = state.pathParameters['storeId']!;
return StoreDetailPage(storeId: storeId);
},
),
],
),
];
```
```dart
// 从任意页面跳转到门店详情
context.push('/store/${store.id}');
```
`buildStoreRoutes()` 只在 `feature_store_mgmt` 包内声明,`app_router.dart` 里只 import 这个函数、不 import 该 feature 的任何页面 widget 类型——保持 [01-project-structure.md](./01-project-structure.md) 的编译期边界。
+447
View File
@@ -0,0 +1,447 @@
# 05. 网络层设计
## 决策
使用 **[dio](https://pub.dev/packages/dio)**`^5.11.0`2026-08 快照)作为唯一 HTTP client,统一封装在 `core_network` 包里,通过拦截器链处理鉴权、日志、响应解包、异常归一化,不允许各 `feature_*` 自建 `Dio` 实例。
`feature_*` 的 repository **不直接依赖 `Dio`,而是依赖 `core_network` 暴露的 `ApiClient`**——原因见下文「为什么要在 `Dio` 外面再包一层 `ApiClient`」。
## 依赖
```yaml
dependencies:
dio: ^5.11.0
uuid: ^4.5.1 # 生成客户端 traceId
```
## 使用规则
- `core_network` 暴露一个单例 `Dio` 实例和基于它的 `ApiClient`(通过 Riverpod provider 注入,见 [03-state-management.md](./03-state-management.md)),所有 `feature_*` 的 repository 只能通过依赖注入拿这个实例,不允许 `Dio()` 直接 new。
- 拦截器按固定顺序注册:`LogInterceptor`(仅 dev/staging 环境开启)→ `AuthInterceptor`(附加 token,401 时串行刷新)→ `ApiResultInterceptor`(解开后端统一响应包装)→ `ErrorMappingInterceptor`(把 `DioException` 统一转成项目自定义的 `AppException` 体系)。
- 业务代码只捕获 `AppException` 及其子类(如 `NetworkException``UnauthorizedException``BusinessException`),不直接处理 `DioException`——异常归一化只在 `core_network` 内部发生一次。
- 每个 feature 的 repository 只声明自己需要的接口方法,不直接拼接完整 URL 字符串到处写,`baseUrl` 和超时时间统一在 `core_network` 里按环境配置(见 [08-build-flavors.md](./08-build-flavors.md))。
- 需要取消请求的场景(比如页面销毁时中断未完成的请求)统一使用 `CancelToken`,在对应 provider 的 `ref.onDispose` 里调用 `cancel()`
## 后端契约:统一响应包装
后端所有接口返回 `ApiResult<T> { code, message, data, traceId }`(见 [backend/06-api-design.md](../../conti-backend/docs/06-api-design.md))。**解包只在 `ApiResultInterceptor` 里做一次**repository 拿到的 `response.data` 已经是里层的 `data`
```dart
// packages/core_network/lib/src/api_result_interceptor.dart
class ApiResultInterceptor extends Interceptor {
ApiResultInterceptor(this._logger);
final AppLogger _logger;
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
final body = response.data;
// 非 JSON 对象响应(如文件下载)不走解包
if (body is! Map<String, dynamic> || !body.containsKey('code')) {
return handler.next(response);
}
// 用 num 而不是 int:万一后端某个字段序列化成了 JSON 浮点数,as int 会直接抛
final code = (body['code'] as num?)?.toInt();
final traceId = body['traceId'] as String?;
_logger.d('[api] ${response.requestOptions.uri} code=$code traceId=$traceId');
if (code == 0) {
// 把外层包装剥掉,repository 的 fromJson 只需要认识 data 的结构
response.data = body['data'];
return handler.next(response);
}
// code != 0 一律转成业务异常,不让它以"成功响应"的形态流到业务层
handler.reject(
DioException(
requestOptions: response.requestOptions,
response: response,
error: BusinessException(
code: code ?? -1,
message: (body['message'] as String?) ?? '请求失败',
traceId: traceId,
),
),
true, // callFollowingErrorInterceptor
);
}
}
```
**`traceId` 必须留存**backend 06/08 明确指望"用户报一个 traceId,后端就能在日志里定位这次请求"。所以
- 每条 API 日志都带 `traceId`(成功失败都带)。
- 错误提示 UI 上要能看到 traceId(不用显眼,可以放在"详情"里或长按复制),具体展示形式见 [12-error-and-api-contract.md](./12-error-and-api-contract.md)。
- 崩溃/错误上报时把 traceId 作为 tag 带上(见 [13-observability-analytics.md](./13-observability-analytics.md))。
## 统一请求头
```dart
// packages/core_network/lib/src/header_interceptor.dart
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final env = _ref.read(appEnvProvider);
options.headers.addAll({
'X-Trace-Id': const Uuid().v4(), // 客户端生成,便于端到端串联
'X-App-Version': env.appVersion, // 如 1.4.0+142
'X-Platform': Platform.isIOS ? 'ios' : 'android',
'X-Device-Id': _ref.read(deviceIdProvider), // 安装级匿名 ID,不是 IMEI/IDFA
});
// 当前门店上下文;未登录/未选门店时不带
final storeId = _ref.read(currentStoreIdProvider.select((s) => s));
if (storeId != null) options.headers['X-Store-Id'] = '$storeId';
handler.next(options);
}
```
- `X-Store-Id` 是**冗余信息**access token 的 claims 里已经有 `storeId`backend 04),后端以 token 为准。带这个头只是为了日志排查时能一眼看出客户端当时认为自己在哪个门店——如果两者不一致,说明切换门店后 token 没换,是个 bug 信号。
- **`X-Trace-Id` 需要和后端对齐一次**backend 06 说 traceId 由后端入口 filter 生成。约定是**后端优先复用请求头里的 `X-Trace-Id`,没有才自己生成**,否则客户端日志和服务端日志会各用一套 ID 对不上。这条挂在待确认项里。
- 不采集 IMEI/IDFA/MAC 等设备唯一标识,`deviceId` 用首次安装时生成并存本地的随机 UUID,避免踩合规红线(见 [07-native-integration.md](./07-native-integration.md) 的隐私清单部分)。
## Token 刷新:必须串行,失败即登出
这一段是整个网络层最容易写错、错了后果最严重的地方,因为它和后端的 **refresh token 轮换策略**强耦合。
按 [backend/04-security-auth.md](../../conti-backend/docs/04-security-auth.md)
- refresh token 是**一次性**的,每次换 access token 都会签发新的、旧的立刻 `revokedAt`
- **旧 token 再被用一次 = 判定为泄漏重放,该用户名下所有 refresh token 全部撤销**。
由此推出三条客户端硬性约束:
1. **绝对不能并发刷新。** 两个请求同时 401、同时拿同一个旧 refresh token 去换,第二个必然被判为重放 → 用户被全设备强制登出。这就是刷新队列存在的真正原因,不是为了"省一次请求"。
2. **刷新失败不能重试。** 失败意味着 refresh token 已过期/已撤销/已被重放,再试一次结果一样。直接登出跳登录页。
3. **刷新请求本身不能走带 `AuthInterceptor` 的那个 `Dio`**,否则刷新接口返回 401 时会再次触发刷新,无限递归。`core_auth` 内部自建一个**裸 `Dio`**(不装任何拦截器)专门发刷新请求——这也是 [01-project-structure.md](./01-project-structure.md) 里 "`core_auth` 不依赖 `core_network`" 这条规则的由来。
```dart
// packages/core_network/lib/src/auth_interceptor.dart
class AuthInterceptor extends Interceptor {
AuthInterceptor(this._ref);
final Ref _ref;
/// 同一时刻最多一个刷新在跑;其他 401 请求 await 同一个 Future
Future<void>? _refreshing;
static const _retriedKey = 'x-retried';
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final token = _ref.read(authStateProvider).accessToken;
if (token != null) options.headers['Authorization'] = 'Bearer $token';
handler.next(options);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode != 401) return handler.next(err);
// 一次性重试标记:带着新 token 重放后又 401,说明不是 token 的问题,别再刷了
if (err.requestOptions.extra[_retriedKey] == true) {
_ref.read(authStateProvider.notifier).logout();
return handler.next(err);
}
try {
// 用一个共享的 Future 天然实现串行:先到的发起刷新,后到的复用同一个 Future
_refreshing ??= _ref.read(authRepositoryProvider).refreshToken();
await _refreshing;
} catch (e) {
// 刷新失败 = refresh token 已失效,不重试,直接登出
_ref.read(authStateProvider.notifier).logout();
return handler.next(err);
} finally {
_refreshing = null;
}
// 刷新成功,用新 token 重放原请求
try {
final options = err.requestOptions
..extra[_retriedKey] = true
..headers['Authorization'] =
'Bearer ${_ref.read(authStateProvider).accessToken}';
handler.resolve(await _ref.read(dioProvider).fetch(options));
} on DioException catch (e) {
handler.next(e);
}
}
}
```
> 对比:常见的"`bool _isRefreshing` + `List<Completer>` 队列"写法有个致命缺陷——`catch` 分支里如果忘了对队列里的 `Completer` 调 `completeError` 并清空,所有排队的请求会**永久挂起**`await completer.future` 永不返回),表现是 UI 一直转圈、用户只能杀进程。用共享 `Future` 的写法从结构上就不存在这个问题:刷新失败时 `await _refreshing` 对每个等待者都会抛异常,各自走各自的 `catch`,没有需要手动清理的队列。
`core_auth` 侧的刷新实现:
```dart
// packages/core_auth/lib/src/token_refresher.dart
class TokenRefresher {
// 裸 Dio:不装任何拦截器,避免刷新请求自己再触发一轮刷新
final _bare = Dio(BaseOptions(
baseUrl: AppEnv.current.apiBaseUrl,
connectTimeout: const Duration(seconds: 10),
));
Future<TokenPair> refresh(String refreshToken) async {
final res = await _bare.post('/api/v1/auth/refresh', data: {'refreshToken': refreshToken});
final data = res.data['data'] as Map<String, dynamic>; // 裸 Dio 没有解包拦截器,手动取
// 后端轮换:新的 refreshToken 必须立刻覆盖存储,旧的已经作废了
return TokenPair(
accessToken: data['accessToken'] as String,
refreshToken: data['refreshToken'] as String,
);
}
}
```
**新的 refresh token 一定要写回 secure storage**(见 [06-local-storage.md](./06-local-storage.md))。写回失败或写回前进程被杀,下次启动用旧 token 就会触发重放判定——所以写回要在"通知 `authState` 更新"之前完成。
## 异常归一化
```dart
// packages/core_network/lib/src/error_mapping_interceptor.dart
class ErrorMappingInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
// 已经是 AppException 的(比如 ApiResultInterceptor 抛的 BusinessException)直接放行,
// 不要二次包装成 NetworkException
if (err.error is AppException) return handler.next(err);
final mapped = switch (err.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.sendTimeout ||
DioExceptionType.receiveTimeout => NetworkException('网络超时,请检查网络后重试'),
DioExceptionType.cancel => RequestCancelledException(),
DioExceptionType.badResponse when err.response?.statusCode == 401 =>
UnauthorizedException(),
DioExceptionType.badResponse => HttpException(
statusCode: err.response?.statusCode ?? -1,
message: '服务异常(${err.response?.statusCode}',
),
_ => NetworkException('网络异常,请稍后重试'),
};
handler.next(DioException(
requestOptions: err.requestOptions,
response: err.response,
error: mapped,
));
}
}
```
### 为什么要在 `Dio` 外面再包一层 `ApiClient`
拦截器**没有办法让 `dio.get()` 抛出 `AppException`**。dio 的错误通道只认 `DioException``handler.reject(...)` 传进去的必须是 `DioException`,我们的 `AppException` 只能挂在它的 `error` 字段上。也就是说,如果 repository 直接调 `dio.get()`,业务层写
```dart
try { ... } on UnauthorizedException { ... } // ❌ 永远进不来
```
是**捕获不到的**——实际抛出来的仍然是 `DioException`
解决办法是在 `core_network` 的出口把 `DioException.error` 拆出来重抛:
```dart
// packages/core_network/lib/src/api_client.dart
class ApiClient {
ApiClient(this._dio);
final Dio _dio;
Future<T> get<T>(String path, {Map<String, dynamic>? query, CancelToken? cancelToken}) =>
_run(() => _dio.get<T>(path, queryParameters: query, cancelToken: cancelToken));
Future<T> post<T>(String path, {Object? data, CancelToken? cancelToken}) =>
_run(() => _dio.post<T>(path, data: data, cancelToken: cancelToken));
Future<T> _run<T>(Future<Response<T>> Function() send) async {
try {
final res = await send();
return res.data as T;
} on DioException catch (e, st) {
final error = e.error;
// 拦截器已经归一化过,这里只负责把它从 DioException 的壳里拿出来重抛
if (error is AppException) Error.throwWithStackTrace(error, st);
Error.throwWithStackTrace(NetworkException('网络异常,请稍后重试'), st);
}
}
}
```
**规则:repository 一律注入 `ApiClient`,不注入 `Dio`。** 只有 `core_network` 内部和 `core_auth` 的裸 Dio 会直接碰 `Dio` 类型。这样上面那段 `on UnauthorizedException` 才真的成立。
`Error.throwWithStackTrace` 保留原始堆栈,否则上报到崩溃平台的堆栈会全部指向 `_run` 这一行,等于没有堆栈。
## 超时、重试与幂等
```dart
BaseOptions(
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
sendTimeout: const Duration(seconds: 30), // 上传单独放宽,见下文
)
```
**默认不做自动重试。** 理由和 [03-state-management.md](./03-state-management.md) 里全局关掉 Riverpod retry 是同一条:多层重试叠加会让一次用户操作变成难以预测的 N 次请求,日志也没法看。需要重试的地方显式写、并且必须满足:
- **只重试 GET**,或后端明确支持幂等键(`Idempotency-Key` 头)的 POST。
- 只对超时/连接失败重试,业务错误码和 4xx 不重试。
- 最多 1 次。
`f6-integration` 侧后端已经配了重试和熔断([backend/05-integration-layer.md](../../conti-backend/docs/05-integration-layer.md)),客户端再叠一层意义不大,反而会把后端的熔断窗口打满。
## `CancelToken` 与 provider 生命周期
```dart
@riverpod
Future<List<PurchaseOrder>> purchaseOrders(Ref ref) async {
final cancelToken = CancelToken();
ref.onDispose(cancelToken.cancel); // 页面销毁 / 门店切换导致 provider 重建时自动中断
final storeId = ref.watch(currentStoreIdProvider);
return ref.watch(purchaseRepositoryProvider).fetchOrders(storeId, cancelToken: cancelToken);
}
```
被取消的请求会抛 `RequestCancelledException`。**UI 层必须把它当"什么都不做"处理,不能弹错误提示**——用户主动离开页面时看到"请求失败"是很糟的体验。这条在 [12-error-and-api-contract.md](./12-error-and-api-contract.md) 的错误展示规则里统一约定。
## 文件与图片上传
PRD §7.4(H5 桥接的图片选择/上传)和施工照片场景都要用到。
```dart
// packages/core_network/lib/src/api_client.dart
Future<T> upload<T>(
String path, {
required List<File> files,
Map<String, dynamic>? fields,
void Function(int sent, int total)? onProgress,
CancelToken? cancelToken,
}) async {
final formData = FormData.fromMap({
...?fields,
'files': [
for (final f in files)
await MultipartFile.fromFile(f.path, filename: p.basename(f.path)),
],
});
return _run(() => _dio.post<T>(
path,
data: formData,
cancelToken: cancelToken,
onSendProgress: onProgress,
// 上传单独放宽超时,用全局的 30s 传几张原图会超
options: Options(sendTimeout: const Duration(minutes: 3)),
));
}
```
约定:
- **上传前必须压缩**。门店员工用手机直接拍的照片通常 3–8 MB,原图上传在门店 WiFi 环境下大概率超时。统一压到长边 1600px、JPEG 质量 80,超过 2 MB 再降一档。
- **进度必须可见**:多图上传要有整体进度,否则用户会以为卡死反复点。
- **失败要能单张重传**,不能因为第 5 张失败就让前 4 张重来。所以 UI 上传状态按单张维护。
- `FormData` **不可重用**dio 的 `FormData` 是流,重试必须重新构造一个,直接复用会报 stream already listened。
## 传输安全
- **全环境强制 HTTPS**,包括 dev。Android 侧在 `network_security_config.xml` 里关掉明文流量(`cleartextTrafficPermitted="false"`),iOS 不放开 ATS 例外。这样"某个环境不小心配了 http 的 baseUrl"会在开发阶段就直接失败,而不是上线后才发现。
- **证书 pinning:首版不做。** 取舍如下——pinning 能防中间人抓包,但代价是证书轮换时必须发新版 App,否则全线不可用;而门店 App 走的是公司自有域名 + 标准 CA,主要威胁模型是"员工手机装了抓包工具看接口",这个用 pinning 挡的收益不高。如果后续有合规要求再加,届时用**双证书 pin(当前 + 备用)** 并且 pin 到中间 CA 而不是叶子证书,留出轮换空间。
- 日志脱敏:`LogInterceptor` 只在 dev/staging 开启,且 `Authorization` 头、密码、手机号在打日志前替换成掩码。这条同样适用于上报到崩溃平台的面包屑(见 [13-observability-analytics.md](./13-observability-analytics.md))。
## 待确认项
- `X-Trace-Id` 由客户端生成、后端复用——需与后端确认入口 filter 的实现。
- 分页参数字段名(backend 06 的「待补充」里也挂着这一项,见 [02-layering.md](./02-layering.md))。
- 错误码表(backend 06 待补充),拿到后补进 [12-error-and-api-contract.md](./12-error-and-api-contract.md) 的映射表。
- 上传接口的大小上限、允许的文件类型、是否走对象存储直传。
## 参考链接
- [dio 官方文档](https://pub.dev/packages/dio)
- [Dio Interceptors 文档](https://pub.dev/packages/dio#interceptors)
- [Dio CancelToken](https://pub.dev/packages/dio#cancellation)
- [Android network security config](https://developer.android.com/privacy-and-security/security-config)
## 附录:dio 是什么,日常怎么用
给还没接触过这套网络层封装方式的同学看的入门说明。
### 要解决的问题
Dart 内置的 `http` 包只提供最基础的请求能力,实际项目里几乎总会遇到这些需求:
1. **每个请求都要带 token**,token 过期了要先刷新再重试,不能让每个业务代码自己判断"要不要刷新 token"。
2. **统一的错误处理**:网络超时、服务端返回的业务错误码、HTTP 状态码错误,最终都要转换成 UI 层能直接判断的统一异常类型,而不是每个 feature 各自写一遍 `try/catch` 判断状态码。
3. **请求/响应日志**:调试时想看到完整的请求参数和返回值,上线后又不希望日志暴露敏感数据。
`http` 包本身没有"拦截器"这个概念,要实现上面这些都得自己在每次调用前后手动包一层逻辑,容易漏、容易不一致。**dio** 内置了 [`Interceptor`](https://pub.dev/packages/dio#interceptors) 机制,可以把这些横切逻辑集中写一次,所有请求自动生效。
### 核心概念
1. **`Dio` 实例**:一个 client 对象,带 `BaseOptions``baseUrl``connectTimeout` 等默认配置),项目里应该只有一个全局实例,而不是每个地方各建一个。
2. **`Interceptor`**:可以拦截请求发出前(`onRequest`)、响应回来后(`onResponse`)、出错时(`onError`)三个时机。**注意 dio 的执行顺序**:三个时机都是按注册顺序**正向**执行的,不是"请求正向、响应反向"的洋葱模型——这一点和很多人的直觉不同,配置拦截器顺序时要留意。
3. **`DioException`**:dio 所有的错误(超时、连接失败、非 2xx 状态码等)都会包装成这个类型,可以在拦截器里统一识别、转换。
4. **`CancelToken`**:用来主动取消一个还没完成的请求,常见场景是页面销毁或用户切走时中断请求,避免拿到结果时 widget 已经不存在了。
### 拦截器链的组装
```dart
// packages/core_network/lib/src/dio_client.dart
final dioProvider = Provider<Dio>((ref) {
final env = ref.watch(appEnvProvider);
final dio = Dio(BaseOptions(
baseUrl: env.apiBaseUrl, // 见 08-build-flavors.md
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
sendTimeout: const Duration(seconds: 30),
));
dio.interceptors.addAll([
HeaderInterceptor(ref),
if (env.enableLog) LogInterceptor(responseBody: false),
AuthInterceptor(ref),
ApiResultInterceptor(ref.watch(loggerProvider)),
ErrorMappingInterceptor(),
]);
return dio;
});
final apiClientProvider = Provider<ApiClient>((ref) => ApiClient(ref.watch(dioProvider)));
```
顺序的理由:`AuthInterceptor` 必须排在 `ErrorMappingInterceptor` 前面,才能在 401 被归一化成 `UnauthorizedException` **之前**先尝试刷新 token`ApiResultInterceptor` 排在 `ErrorMappingInterceptor` 前面,是因为它抛出的 `BusinessException` 需要能被后者识别并放行(后者第一行就是判断 `err.error is AppException`)。
### 业务层看到的样子
```dart
// data/repository/purchase_repository_impl.dart
class PurchaseRepositoryImpl implements PurchaseRepository {
PurchaseRepositoryImpl(this._api);
final ApiClient _api;
@override
Future<List<PurchaseOrder>> fetchOrders(int storeId, {CancelToken? cancelToken}) async {
// 返回的已经是 ApiResult 里的 data,外层包装由拦截器解开
final list = await _api.get<List<dynamic>>(
'/api/v1/purchase/orders',
query: {'storeId': storeId},
cancelToken: cancelToken,
);
return list.map((e) => PurchaseOrder.fromJson(e as Map<String, dynamic>)).toList();
}
}
```
```dart
// presentation 层
try {
final orders = await repository.fetchOrders(storeId);
} on UnauthorizedException {
// 已经被 AuthInterceptor 处理过登出,这里一般只需要静默
} on BusinessException catch (e) {
showToast('${e.message}${e.traceId}');
} on RequestCancelledException {
// 用户主动离开,什么都不做
} on AppException catch (e) {
showToast(e.message);
}
```
+323
View File
@@ -0,0 +1,323 @@
# 06. 本地存储方案
## 决策
按数据类型分三档存储,`feature_*` 不直接依赖底层存储库:
| 数据类型 | 方案 | 版本(2026-08 快照) | 归属包 |
|---|---|---|---|
| 结构化/关系型数据(门店列表缓存、订单历史等) | **[Drift](https://pub.dev/packages/drift)** | `^2.34.3` | `core_storage` |
| 敏感数据(token、refresh token | **[flutter_secure_storage](https://pub.dev/packages/flutter_secure_storage)** | `11.0.0`(锁死) | **`core_auth`** |
| 简单非敏感 KV(是否看过引导页、用户偏好设置) | **[shared_preferences](https://pub.dev/packages/shared_preferences)** | `^2.5.5` | `core_storage` |
> **secure storage 归 `core_auth` 独占,不放进 `core_storage`。** 唯一读写 token 的地方就是 `core_auth`,把它放进 `core_storage` 会逼出一条 `core_auth → core_storage` 的依赖,而 `core_storage` 里其他东西 `core_auth` 一样都用不上(见 [01-project-structure.md](./01-project-structure.md) 的依赖例外表)。代价是 `core_auth` 自己要依赖 `flutter_secure_storage`,这比多一条包间依赖划算。
## 依赖
```yaml
# core_storage
dependencies:
drift: ^2.34.3
drift_flutter: ^0.3.1 # 打开数据库的官方 Flutter 胶水包
path_provider: ^2.1.6
shared_preferences: ^2.5.5
dev_dependencies:
drift_dev: ^2.34.5
build_runner: ^2.15.2
# core_auth
dependencies:
flutter_secure_storage: 11.0.0 # 锁死,不用 ^,理由见下文
```
> **不要再写 `sqlite3_flutter_libs`。** 这个包已经 **EOL**(最新版本号就叫 `0.6.0+eol`),sqlite3 3.x 起不再需要它。drift 官方现在的推荐组合是 `drift_flutter` + `path_provider``driftDatabase()` 会帮你处理原生库加载、数据库文件路径、以及后台 isolate。
## 使用规则
- 全仓库**只有一个 Drift 数据库实例**,定义在 `core_storage` 里,不允许每个 `feature_*` 各自建一个 SQLite 文件——避免多个数据库文件之间做跨 feature 查询/事务的麻烦。
- 每个 feature 拥有自己的表(`Table` 类)和 DAO`DriftAccessor`),表名加 feature 前缀(如 `store_cache``payment_history`)避免命名冲突,但都注册进同一个 `AppDatabase`
- feature 的 `data``local_datasource` 只依赖自己的 DAO 类型,不直接操作 `AppDatabase` 或访问其他 feature 的表。
- token / refresh token 只能经过 `core_auth` 包里封装的 secure storage 读写方法,不允许其他 `core_*`/`feature_*` 直接调用 `FlutterSecureStorage` 实例。
- 数据库表结构变更必须写 migration(`onUpgrade` + `schemaVersion` 递增),不允许直接改字段定义后期望"重装了事"——线上用户已有数据需要平滑迁移。
## 所有业务缓存表必须带 `storeId`
PRD §11.4 要求切换门店后购物车、待办、预警、订单上下文全部跟着切。本地缓存是最容易漏的一环——provider 失效了,但 Drift 里 A 门店的数据还在,切到 B 门店离线打开页面就会读到 A 门店的数据。
**硬性规则**:任何缓存业务数据的表都必须有 `storeId` 列,并且
- 所有查询都带 `where(tbl.storeId.equals(currentStoreId))`,不允许无门店条件的全表查询;
- `storeId` 建索引;
- 表的主键包含 `storeId`(或用 `(storeId, businessId)` 联合主键),避免不同门店的同 ID 记录互相覆盖。
```dart
class PurchaseOrderCache extends Table {
IntColumn get storeId => integer()();
TextColumn get orderId => text()();
TextColumn get payload => text()();
DateTimeColumn get cachedAt => dateTime()();
@override
Set<Column> get primaryKey => {storeId, orderId}; // 联合主键,天然按门店隔离
}
```
不设 `storeId` 的表只有一类:**与门店无关的全局数据**(如 App 配置、引导页标记),这类应该放 `shared_preferences` 而不是 Drift。
## 登出 / 切换门店的清理策略
| 场景 | Drift 业务表 | shared_preferences | secure storage (token) | H5 会话 |
|---|---|---|---|---|
| **切换门店** | 删除**非当前门店**的行(或全清,见下) | 保留 | 保留 | 失效(见 [10-webview-h5.md](./10-webview-h5.md) |
| **登出** | **全部清空** | 只清与用户相关的键,保留 App 级偏好 | **全部清空** | 失效 + 清 Cookie/LocalStorage |
| **切换账号** | 同登出 | 同登出 | 同登出 | 同登出 |
**切换门店时是"只留当前门店"还是"全清"**:选**全清**。理由是保留其他门店的旧数据没有实际收益(用户切回去时数据早已过期,还是要重新拉),但会带来"用户看到的是几天前的数据却没有任何提示"这类问题;而全清的代价只是切回去时多一次 loading。
```dart
// packages/core_storage/lib/src/app_database.dart
extension StoreScopedCleanup on AppDatabase {
/// 切换门店 / 登出时调用;在一个事务里清,避免清一半被杀进程留下不一致状态
Future<void> clearBusinessCache() => transaction(() async {
for (final table in allTables.where(_isBusinessCache)) {
await delete(table).go();
}
});
}
```
**清理动作由谁触发**:统一在 `11-store-context-and-session.md` 定义的会话编排里调用,各 feature 不自己监听门店变化去清自己的表——分散清理必然会漏。
**清理顺序也有讲究**:先切断新写入(让 provider 失效、请求取消),再清库。反过来会出现"刚清完,一个在途请求的回调又把旧门店数据写回去了"。
## 缓存 TTL
Drift 里的缓存**默认都是"降级用"的,不是"优先用"的**:正常路径永远走网络,缓存只在网络失败或首屏加载时先垫一下。这样 TTL 的作用就不是"过期就不能用",而是"过期了就不要再拿它当有效内容展示"。
| 数据 | TTL | 过期后行为 |
|---|---|---|
| 门店列表 | 24h | 仍展示,但顶部提示"数据可能不是最新" |
| 工作台 tile 数据 | 5min | 不展示缓存,直接走 loading |
| 订单/采购单列表 | 10min | 展示缓存 + 下拉刷新 |
| 经营/财务分析数据 | 不缓存 | — |
每张缓存表都有 `cachedAt` 列,判断逻辑写在 `local_datasource` 里,不散落在 UI。
**经营/财务类数据不落本地**:这类是敏感数据,手机丢失或被拿去 root 后 SQLite 文件可以直接读。收益(离线可看)远小于风险,直接不缓存最省事——也就不需要引入 SQLCipher 这类数据库加密方案(引入的话要处理密钥存哪、密钥丢了怎么办、以及原生库体积增加)。这条如果后续业务要求离线查看经营数据,再重新评估。
## Migration 必须被验证,不能只靠"写了"
"必须写 migration"这条规则没有配套验证手段的话,等于没有——migration 写错的表现是**线上用户升级后 App 一启动就崩**,而开发机上因为是全新安装,永远测不出来。
Drift 官方提供了 schema 快照 + 验证工具链,纳入流程:
```bash
# 1. 每次 schemaVersion 递增后,导出当前 schema 快照(产物入库)
fvm dart run drift_dev schema dump lib/src/app_database.dart drift_schemas/
# 2. 生成迁移测试的辅助代码
fvm dart run drift_dev schema generate drift_schemas/ test/generated_migrations/
```
```dart
// packages/core_storage/test/migration_test.dart
void main() {
late SchemaVerifier verifier;
setUpAll(() => verifier = SchemaVerifier(GeneratedHelper()));
test('从 v1 到最新版本的迁移都能跑通', () async {
for (var from = 1; from < AppDatabase.latestSchemaVersion; from++) {
final connection = await verifier.startAt(from);
final db = AppDatabase.forTesting(connection);
await verifier.migrateAndValidate(db, AppDatabase.latestSchemaVersion);
await db.close();
}
});
}
```
规则:
- `drift_schemas/` 下的 JSON 快照**入 git**,每次改表结构必须跟着生成新快照,PR 里能直接看到 schema diff。
- 迁移测试进 `melos run test`CI 卡点(见 [14-conventions-and-ci-gates.md](./14-conventions-and-ci-gates.md))。
- `migrateAndValidate` 只验证**结构**,不验证数据。涉及数据搬迁(拆表、改语义)的迁移要额外写一个"造老数据 → 迁移 → 断言新数据"的用例。
## flutter_secure_storage 11.0.0 的升级风险
`flutter_secure_storage 11.0.0` 是 2026-08 才发的大版本,**改了 Android 侧的默认加密实现**RSA OAEP + AES-GCM)。这意味着:
- 用旧版本写入的数据,升级后有**读不出来**的风险(返回 null 或抛异常)。对我们来说就是"用户升级 App 后被登出"。
- 版本号在 `pubspec.yaml` 里**写死 `11.0.0`,不用 `^`**。这个包的历史上出现过 minor 版本改加密实现的情况,`^` 会让某次 `pub upgrade` 悄悄换掉加密方式,而问题只在真机升级路径上暴露,CI 和新装都测不出来。升级它必须是一次显式的、带回归验证的动作。
- **首版是新 App,不存在历史数据**,所以本次没有实际迁移风险;这条规则是为**后续升级**立的。
读取失败的兜底必须写:
```dart
Future<String?> readAccessToken() async {
try {
return await _storage.read(key: _kAccessToken);
} catch (e, st) {
// 读不出来一律当作未登录:清空 + 跳登录页,而不是抛异常让用户卡在启动页
_logger.e('secure storage 读取失败,按未登录处理', error: e, stackTrace: st);
await clear();
return null;
}
}
```
**绝对不能让 secure storage 的异常向上冒到启动流程**——那会变成"升级后一打开就白屏/崩溃",比重新登录严重得多。
## 数据库在后台 isolate 打开
大批量写入(比如一次同步几百条订单)在主 isolate 上跑会掉帧。`drift_flutter``driftDatabase()` **默认就用后台 isolate**,只要不手动关掉即可:
```dart
// packages/core_storage/lib/src/connection.dart
QueryExecutor openConnection() => driftDatabase(
name: 'conti_app',
native: const DriftNativeOptions(
databaseDirectory: getApplicationSupportDirectory, // iOS 上不要用 Documents,会被 iCloud 备份
),
);
```
iOS 上数据库文件放 `Application Support` 而不是 `Documents``Documents` 会被 iCloud 备份,缓存数据没必要占用户的 iCloud 空间,苹果审核也可能因此提意见。
## 参考链接
- [Drift 官方文档](https://drift.simonbinder.eu/)
- [drift_flutter | Dart package](https://pub.dev/packages/drift_flutter)
- [Drift: Migrations 与 schema 验证](https://drift.simonbinder.eu/Migrations/tests/)
- [flutter_secure_storage | Dart package](https://pub.dev/packages/flutter_secure_storage)
- [shared_preferences | Dart package](https://pub.dev/packages/shared_preferences)
## 附录:Drift 是什么,日常怎么用
给还没接触过这套本地数据库封装方式的同学看的入门说明。
### 要解决的问题
Flutter 生态里直接操作本地 SQLite 最常见的是 [`sqflite`](https://pub.dev/packages/sqflite),但它是纯 SQL 字符串拼接:
```dart
// sqflite 写法,容易手滑打错字段名/表名,编译期完全发现不了
await db.rawQuery('SELECT * FROM stroe WHERE nmae = ?', [name]);
```
字段名、表名全靠字符串,拼错了只有运行时才报错;查询结果是 `Map<String, Object?>`,还得手动转成业务对象;数据变化了想让 UI 自动刷新,也得自己手写一套通知机制。
**Drift**`sqflite`(或更底层的 `sqlite3`)之上加了一层代码生成:用 Dart 类定义表结构,`build_runner` 生成类型安全的查询代码,写错字段名/类型在编译期就会报错;查询结果直接是强类型的 Dart 对象;还内置了 `.watch()` 方法,数据变化时自动推送新结果,天然适合配合 Riverpod 的 `StreamProvider`/`AsyncNotifier` 做响应式 UI。
### 核心概念
1. **`Table` 类**:用 Dart 代码声明表结构(字段名、类型、约束),而不是手写 `CREATE TABLE` 语句。
2. **`DriftAccessor`(DAO)**:给一组相关表写查询/增删改方法的地方,业务代码只调用 DAO 方法,不直接写 SQL。
3. **`.watch()` vs `.get()`**`.get()` 是一次性查询,`.watch()` 返回一个 `Stream`,只要底层数据变化(哪怕是另一个页面改的)就会自动推送新结果——不需要手动刷新。
4. **`schemaVersion` + `onUpgrade`**:数据库版本号和迁移回调,改表结构时递增版本号并在 `onUpgrade` 里写迁移逻辑(加字段、建索引等),保证已安装用户的本地数据不会因为升级直接报错或丢失。
### 使用示例(`feature_store`:门店列表本地缓存)
```dart
// packages/core_storage/lib/src/tables/store_table.dart
class StoreCache extends Table {
TextColumn get id => text()();
TextColumn get name => text()();
RealColumn get lat => real()();
RealColumn get lng => real()();
DateTimeColumn get cachedAt => dateTime()();
@override
Set<Column> get primaryKey => {id};
}
```
```dart
// packages/core_storage/lib/src/daos/store_dao.dart
part 'store_dao.g.dart';
@DriftAccessor(tables: [StoreCache])
class StoreDao extends DatabaseAccessor<AppDatabase> with _$StoreDaoMixin {
StoreDao(super.db);
Future<void> upsertAll(List<StoreCacheCompanion> stores) =>
batch((b) => b.insertAllOnConflictUpdate(storeCache, stores));
Stream<List<StoreCacheData>> watchAll() => select(storeCache).watch();
}
```
```dart
// packages/core_storage/lib/src/app_database.dart
@DriftDatabase(tables: [StoreCache, PurchaseOrderCache], daos: [StoreDao, PurchaseOrderDao])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(openConnection());
AppDatabase.forTesting(super.connection); // 迁移测试用
static const latestSchemaVersion = 2;
@override
int get schemaVersion => latestSchemaVersion;
@override
MigrationStrategy get migration => MigrationStrategy(
onUpgrade: (m, from, to) async {
if (from < 2) {
await m.addColumn(storeCache, storeCache.cachedAt);
}
},
);
}
```
> 门店列表这张表存的是"当前用户能访问哪些门店",属于用户级而不是门店级数据,所以没有 `storeId` 列——它是上文那条"业务缓存表必须带 `storeId`"规则的合理例外。`PurchaseOrderCache` 那种才是典型的门店级数据。
```dart
// feature_store 的 local_datasource 只依赖 StoreDao,不直接碰 AppDatabase
class StoreLocalDataSource {
final StoreDao _dao;
StoreLocalDataSource(this._dao);
Stream<List<Store>> watchCachedStores() =>
_dao.watchAll().map((rows) => rows.map(Store.fromCacheRow).toList());
}
```
配合 Riverpod 做响应式 UI(离线也能展示上次缓存的门店列表,等网络数据回来再刷新):
```dart
@riverpod
Stream<List<Store>> cachedStores(Ref ref) {
final localDataSource = ref.watch(storeLocalDataSourceProvider);
return localDataSource.watchCachedStores();
}
```
### secure storage 使用示例(token 存取)
```dart
// packages/core_auth/lib/src/token_storage.dart
class TokenStorage {
final FlutterSecureStorage _storage;
TokenStorage(this._storage);
Future<void> saveTokens({required String accessToken, required String refreshToken}) =>
Future.wait([
_storage.write(key: 'access_token', value: accessToken),
_storage.write(key: 'refresh_token', value: refreshToken),
]);
Future<String?> readAccessToken() => _storage.read(key: 'access_token');
Future<void> clear() => _storage.deleteAll();
}
```
`TokenStorage` 是全仓库唯一直接持有 `FlutterSecureStorage` 实例的类,其他包只能通过 `core_auth` 暴露的 provider 间接读写 token。
## 待确认项
- 经营/财务数据是否需要离线查看。如果需要,要重新评估数据库加密(SQLCipher)方案,涉及密钥保管和原生库体积。
- 门店切换时"全清缓存"在门店数量多、切换频繁的用户上的实际体验,上线后看埋点再调。

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