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
+1
View File
@@ -0,0 +1 @@
include: ../../analysis_options.yaml
+9
View File
@@ -0,0 +1,9 @@
/// 共享 UI 层。来源:conti-docs/12-error-and-api-contract.md §三、§四。
///
/// 这一层只放**与业务无关**的东西:主题、三态视图、错误文案映射。任何带
/// 业务语义的 Widget(订单卡片、门店选择器)都属于对应的 `feature_*`。
library;
export 'src/error/error_presenter.dart';
export 'src/error/error_view.dart';
export 'src/theme/app_theme.dart';
@@ -0,0 +1,97 @@
/// 异常 → 用户可见文案的唯一映射点。来源:conti-docs/12-error-and-api-contract.md §三。
library;
import 'package:core_foundation/core_foundation.dart';
/// [ErrorPresenter.present] 的返回值。
///
/// 用 record 而不是类:这东西只在 build 方法里活几行,没有身份也没有行为。
typedef ErrorDisplay = ({String title, String? detail, bool retryable, bool showTraceId});
/// 全 App 唯一的错误文案映射。
///
/// ---------------------------------------------------------------------------
/// **不要在 feature 里自己写 `if (e is XxxException)`。** 文案散在各处的结果是
/// 同一个错误在订单页叫"网络开小差"、在首页叫"加载失败",用户反馈时对不上。
///
/// [AppException] 是 `sealed` 的,下面的 switch 是穷尽的——将来加一种异常类型,
/// 这里会编译报错,逼着人补文案,而不是悄悄落进"未知错误"。
/// ---------------------------------------------------------------------------
abstract final class ErrorPresenter {
/// 把异常映射成一组展示参数。
static ErrorDisplay present(AppException e) => switch (e) {
NetworkException(kind: NetworkErrorKind.noConnection) => (
title: '网络未连接',
detail: '请检查网络后重试',
retryable: true,
showTraceId: false,
),
NetworkException() => (
title: '网络不太稳定',
detail: '请稍后重试',
retryable: true,
// 请求根本没到后端,traceId 在服务端日志里查不到,展示出来只会误导。
showTraceId: false,
),
ServerException() => (
title: '系统繁忙',
detail: '请稍后重试',
retryable: true,
// 这正是 traceId 存在的意义:把一次投诉定位到一条服务端日志。
showTraceId: true,
),
// retryable 必须是 false:库存不足、订单已支付这类错误重试没有意义,
// 给一个重试按钮只会让用户反复点。
BusinessException(:final String message) => (
title: message,
detail: null,
retryable: false,
showTraceId: false,
),
// 文档 12 的 switch 里漏了这一支,sealed 穷尽会直接编译不过。
// 本地前置条件(如切店时有未完成的写操作)的 message 本身就是给用户看的。
PreconditionException(:final String message) => (
title: message,
detail: null,
retryable: false,
showTraceId: false,
),
StorageException() => (
title: '本地数据异常',
detail: '请重启 App',
retryable: false,
showTraceId: false,
),
NativeException(code: NativeErrorCode.permissionDenied, :final String message) => (
title: message,
detail: '可在系统设置中开启',
retryable: false,
showTraceId: false,
),
NativeException(:final String message) => (
title: message,
detail: null,
retryable: false,
showTraceId: false,
),
// 不展示:登出流程本身会把用户送回登录页;取消是用户自己触发的。
UnauthorizedException() ||
RequestCancelledException() => (title: '', detail: null, retryable: false, showTraceId: false),
};
/// 这个错误是否应当**完全不出现在 UI 上**。
///
/// [RequestCancelledException]:用户返回上一页导致在途请求被取消,
/// 弹"请求已取消"是纯噪音。
/// [UnauthorizedException]:登出跳转已经是最强的反馈了。
static bool isSilent(Object error) =>
error is UnauthorizedException || error is RequestCancelledException;
/// 非 [AppException] 的兜底。
///
/// 正常情况下不该走到这里——网络层出口已经把一切归一化成 [AppException]。
/// 走到这里说明是一个 bug(空指针、类型转换失败),文案上不能暴露技术细节。
static ErrorDisplay presentUnknown(Object error) => error is AppException
? present(error)
: (title: '出了点问题', detail: '请稍后重试', retryable: true, showTraceId: false);
}
@@ -0,0 +1,239 @@
/// 三态视图与错误 Widget。来源:conti-docs/12-error-and-api-contract.md §三、§四。
library;
import 'package:core_foundation/core_foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'error_presenter.dart';
/// `AsyncValue` 的统一三态渲染。
///
/// ---------------------------------------------------------------------------
/// **每个 feature 自己写 `switch (asyncValue)` 是最常见的重复劳动,也是三态处理
/// 不一致的根源**(12 §三)。所有页面级异步数据都走这里。
///
/// 内部统一处理:
/// - loading → 居中转圈
/// - error → [ErrorPresenter.present] → 整页错误态 + 重试
/// - [RequestCancelledException] / [UnauthorizedException] → **静默**,退回 loading 态
/// - 空数据([isEmpty] 判定)→ 空态
/// - 刷新失败但有旧数据 → 继续渲染旧数据(不把用户已经看到的内容换成错误页)
/// ---------------------------------------------------------------------------
class AsyncValueView<T> extends StatelessWidget {
/// [data] 只在有数据时调用;[onRetry] 一般是 `() => ref.invalidate(xxxProvider)`。
const AsyncValueView({
required this.value,
required this.data,
this.onRetry,
this.isEmpty,
this.empty,
super.key,
});
/// 来自 `ref.watch(someProvider)`。
final AsyncValue<T> value;
/// 有数据时的渲染。
final Widget Function(T data) data;
/// 重试回调。为 null 时错误态不显示重试按钮。
final VoidCallback? onRetry;
/// 判定"有数据但是空的"。默认不判定(即永远不显示空态)。
final bool Function(T data)? isEmpty;
/// 空态。不传时用一段默认文案。
final Widget? empty;
@override
Widget build(BuildContext context) {
// 注意顺序:先看有没有数据。刷新失败时 AsyncError 也可能带着上一次的
// 数据(hasValue),这时候必须继续展示旧数据——把用户正在看的列表换成
// 一整页错误,比什么都不做更糟。
if (value.hasValue) {
final T current = value.value as T;
if (isEmpty?.call(current) ?? false) {
return empty ?? const _EmptyView();
}
return data(current);
}
if (value.hasError && !ErrorPresenter.isSilent(value.error!)) {
return ErrorView(error: value.error!, onRetry: onRetry);
}
// 静默错误也走这里:用户看到的是"还在加载",而不是一个他不需要理解的错误。
return const Center(child: CircularProgressIndicator());
}
}
/// 整页错误态。
class ErrorView extends StatelessWidget {
/// [error] 通常是 `AsyncValue.error`,非 [AppException] 会走兜底文案。
const ErrorView({required this.error, this.onRetry, super.key});
/// 原始错误对象。
final Object error;
/// 重试回调。
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
final ErrorDisplay display = ErrorPresenter.presentUnknown(error);
final String? traceId = error is AppException ? (error as AppException).traceId : null;
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(display.title, style: Theme.of(context).textTheme.titleMedium),
if (display.detail != null) ...<Widget>[
const SizedBox(height: 8),
Text(display.detail!, style: Theme.of(context).textTheme.bodySmall),
],
if (display.retryable && onRetry != null) ...<Widget>[
const SizedBox(height: 16),
FilledButton(onPressed: onRetry, child: const Text('重试')),
],
// traceId 不印在主文案里——用户看到一串乱码只会更慌。
// 折叠在「问题反馈」后面,客服话术是"把那串编号发给我"。
if (display.showTraceId && traceId != null) ...<Widget>[
const SizedBox(height: 12),
_TraceIdSection(traceId: traceId),
],
],
),
),
);
}
}
/// 局部(tile 级)错误态。
///
/// 首页某个区块失败时用这个,**尺寸自适应,不撑破布局**——它会被塞进一个
/// 高度有限的 tile 里,不能像 [ErrorView] 那样撑满。
class TileErrorView extends StatelessWidget {
/// 构造。
const TileErrorView({required this.error, this.onRetry, super.key});
/// 原始错误对象。
final Object error;
/// 重试回调。
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
final ErrorDisplay display = ErrorPresenter.presentUnknown(error);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: <Widget>[
Expanded(
child: Text(
display.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
),
if (display.retryable && onRetry != null)
TextButton(onPressed: onRetry, child: const Text('重试')),
],
),
);
}
}
/// 「展示的是缓存数据」的顶部提示条。
///
/// 门店里网络不稳是常态,网络失败但本地有缓存时展示缓存 + 这条提示,比展示
/// 一个错误页好得多。**但必须带时间戳**:展示旧数据却不告诉用户是旧的,
/// 比展示错误更危险——尤其是库存和价格(12 §四)。
class StaleDataBanner extends StatelessWidget {
/// [updatedAt] 是缓存写入时间,必须真实。
const StaleDataBanner({required this.updatedAt, this.onRefresh, super.key});
/// 缓存写入时间。
final DateTime updatedAt;
/// 刷新回调。
final VoidCallback? onRefresh;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
return Material(
color: theme.colorScheme.secondaryContainer,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(
children: <Widget>[
Expanded(
child: Text(
'更新于 ${formatElapsed(updatedAt)}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSecondaryContainer,
),
),
),
if (onRefresh != null) TextButton(onPressed: onRefresh, child: const Text('刷新')),
],
),
),
);
}
}
/// 把时间点格式化成"10 分钟前"这类相对文案。
///
/// [now] 只给测试用;生产代码不要传。
String formatElapsed(DateTime updatedAt, {DateTime? now}) {
final Duration d = (now ?? DateTime.now()).difference(updatedAt);
if (d.inMinutes < 1) {
return '刚刚';
}
if (d.inMinutes < 60) {
return '${d.inMinutes} 分钟前';
}
if (d.inHours < 24) {
return '${d.inHours} 小时前';
}
return '${d.inDays} 天前';
}
class _TraceIdSection extends StatefulWidget {
const _TraceIdSection({required this.traceId});
final String traceId;
@override
State<_TraceIdSection> createState() => _TraceIdSectionState();
}
class _TraceIdSectionState extends State<_TraceIdSection> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
if (!_expanded) {
return TextButton(
onPressed: () => setState(() => _expanded = true),
child: const Text('问题反馈 '),
);
}
return SelectableText(widget.traceId, style: Theme.of(context).textTheme.bodySmall);
}
}
class _EmptyView extends StatelessWidget {
const _EmptyView();
@override
Widget build(BuildContext context) =>
Center(child: Text('暂无数据', style: Theme.of(context).textTheme.bodySmall));
}
@@ -0,0 +1,28 @@
/// 主题。
///
/// **这是一个占位实现。** conti-docs 的 `15-ui-design-system.md` 还没写,色板、
/// 字号阶梯、间距规范都未定。这里只把结构搭出来(一个集中定义点 + 一个
/// seed color),等设计规范落地后在这个文件里补,**不要在各 feature 里
/// 自己 `ThemeData(...)`**——那正是这个文件存在的目的。
library;
import 'package:flutter/material.dart';
/// App 主题。
abstract final class AppTheme {
/// TODO(design): 待 15-ui-design-system.md 确定品牌主色后替换。
static const Color _seed = Color(0xFFFF6A13);
/// 亮色主题。
static ThemeData get light => _build(Brightness.light);
/// 暗色主题。
///
/// 门店场景基本用不到,但 `MaterialApp` 需要一个,跟随系统即可。
static ThemeData get dark => _build(Brightness.dark);
static ThemeData _build(Brightness brightness) => ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: _seed, brightness: brightness),
);
}
+19
View File
@@ -0,0 +1,19 @@
name: core_ui
description: 共享 UI。主题、三态视图(AsyncValueView)、错误展示映射。
publish_to: none
version: 0.1.0
resolution: workspace
environment:
sdk: ^3.12.0
dependencies:
core_foundation: ^0.1.0
flutter:
sdk: flutter
flutter_riverpod: ^3.3.2
dev_dependencies:
flutter_lints: ^6.0.0
flutter_test:
sdk: flutter
+133
View File
@@ -0,0 +1,133 @@
// core_ui 的高价值断言:
// 1. 取消/未授权必须静默——这两条一旦回归,用户每次返回上一页都会看到错误页。
// 2. 刷新失败时旧数据不能被错误页顶掉。
// 3. BusinessException 不给重试按钮。
import 'package:core_foundation/core_foundation.dart';
import 'package:core_ui/core_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
Widget _host(Widget child) => MaterialApp(home: Scaffold(body: child));
void main() {
group('ErrorPresenter', () {
test('BusinessException 不可重试——重试一个"库存不足"没有意义', () {
final ErrorDisplay d = ErrorPresenter.present(const BusinessException(40001, '库存不足'));
expect(d.title, '库存不足');
expect(d.retryable, isFalse);
expect(d.showTraceId, isFalse);
});
test('只有 ServerException 展示 traceId', () {
expect(ErrorPresenter.present(const ServerException('x')).showTraceId, isTrue);
// 请求没到后端,服务端日志里查不到这个 id。
expect(
ErrorPresenter.present(
const NetworkException('x', kind: NetworkErrorKind.noConnection),
).showTraceId,
isFalse,
);
});
test('取消与未授权必须静默', () {
expect(ErrorPresenter.isSilent(const RequestCancelledException()), isTrue);
expect(ErrorPresenter.isSilent(const UnauthorizedException()), isTrue);
expect(ErrorPresenter.isSilent(const ServerException('x')), isFalse);
});
test('非 AppException 走兜底,不泄露技术细节', () {
final ErrorDisplay d = ErrorPresenter.presentUnknown(StateError('null check'));
expect(d.title, '出了点问题');
});
});
group('AsyncValueView', () {
testWidgets('error 态渲染文案与重试按钮', (WidgetTester tester) async {
await tester.pumpWidget(
_host(
AsyncValueView<int>(
value: AsyncValue<int>.error(const ServerException('x'), StackTrace.empty),
onRetry: () {},
data: (int v) => Text('$v'),
),
),
);
expect(find.text('系统繁忙'), findsOneWidget);
expect(find.text('重试'), findsOneWidget);
});
testWidgets('取消错误不显示错误态', (WidgetTester tester) async {
await tester.pumpWidget(
_host(
AsyncValueView<int>(
value: AsyncValue<int>.error(const RequestCancelledException(), StackTrace.empty),
data: (int v) => Text('$v'),
),
),
);
expect(find.text('请求已取消'), findsNothing);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('刷新失败但有旧数据时继续渲染旧数据,而不是换成整页错误', (WidgetTester tester) async {
// riverpod 在重建时会把上一次的值带进新的 AsyncErrorhasValue 仍为 true)。
// AsyncValueView 必须先看 hasValue——把用户正在看的列表换成一整页错误,
// 比什么都不做更糟。
bool shouldFail = false;
final FutureProvider<int> provider = FutureProvider<int>((Ref ref) async {
if (shouldFail) {
throw const ServerException('x');
}
return 7;
});
final ProviderContainer container = ProviderContainer();
addTearDown(container.dispose);
// riverpod 3 默认 autoDispose,没有监听者的话读完就被回收了。
container.listen<AsyncValue<int>>(provider, (_, _) {});
// runAsynctestWidgets 默认跑在 fake async 区里,真实的 Future 永远不会
// 完成(会挂到 10 分钟超时)。碰真 provider 生命周期必须包这一层。
final AsyncValue<int> state = (await tester.runAsync(() async {
expect(await container.read(provider.future), 7);
shouldFail = true;
await expectLater(container.refresh(provider.future), throwsA(isA<ServerException>()));
return container.read(provider);
}))!;
expect(state.hasError, isTrue);
expect(state.hasValue, isTrue, reason: '上一次的数据必须被保留');
await tester.pumpWidget(
_host(AsyncValueView<int>(value: state, data: (int v) => Text('$v'))),
);
expect(find.text('7'), findsOneWidget);
expect(find.text('系统繁忙'), findsNothing);
});
testWidgets('isEmpty 判定为真时走空态', (WidgetTester tester) async {
await tester.pumpWidget(
_host(
AsyncValueView<List<int>>(
value: const AsyncValue<List<int>>.data(<int>[]),
isEmpty: (List<int> v) => v.isEmpty,
data: (List<int> v) => Text('${v.length}'),
),
),
);
expect(find.text('暂无数据'), findsOneWidget);
});
});
test('formatElapsed 给出人类可读的相对时间', () {
final DateTime now = DateTime(2026, 8, 17, 12);
expect(formatElapsed(now.subtract(const Duration(seconds: 30)), now: now), '刚刚');
expect(formatElapsed(now.subtract(const Duration(minutes: 10)), now: now), '10 分钟前');
expect(formatElapsed(now.subtract(const Duration(hours: 3)), now: now), '3 小时前');
expect(formatElapsed(now.subtract(const Duration(days: 2)), now: now), '2 天前');
});
}