app scaffold
This commit is contained in:
@@ -0,0 +1 @@
|
||||
include: ../../analysis_options.yaml
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 在重建时会把上一次的值带进新的 AsyncError(hasValue 仍为 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, (_, _) {});
|
||||
|
||||
// runAsync:testWidgets 默认跑在 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 天前');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user