app scaffold
This commit is contained in:
@@ -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),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user