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
+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},
);
}
}