app scaffold
This commit is contained in:
@@ -0,0 +1 @@
|
||||
include: ../../analysis_options.yaml
|
||||
@@ -0,0 +1,9 @@
|
||||
/// 工作台(首页)。
|
||||
///
|
||||
/// 对外只暴露 [buildHomeRoutes](给 app/ 拼路由表)和 [homeRepositoryProvider]
|
||||
/// (给测试/后续替换实现用)。页面本身不导出。
|
||||
library;
|
||||
|
||||
export 'src/data/home_models.dart';
|
||||
export 'src/data/home_repository.dart' show HomeRepository, homeRepositoryProvider;
|
||||
export 'src/routes.dart';
|
||||
@@ -0,0 +1,36 @@
|
||||
/// 工作台的数据模型。
|
||||
///
|
||||
/// 字段按 PRD 的工作台描述反推,**待与后端接口对齐**——这里只保证结构和
|
||||
/// 降级逻辑成立,字段名后面照着真接口改即可。
|
||||
library;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 待办条目。
|
||||
@immutable
|
||||
class TodoItem {
|
||||
/// 构造。
|
||||
const TodoItem({required this.code, required this.title, required this.count});
|
||||
|
||||
/// 业务编码,和菜单 `code` 同一套字典——工作台的角标靠它对上菜单。
|
||||
final String code;
|
||||
|
||||
/// 展示名。
|
||||
final String title;
|
||||
|
||||
/// 待处理数量。
|
||||
final int count;
|
||||
}
|
||||
|
||||
/// 预警条目。
|
||||
@immutable
|
||||
class AlertItem {
|
||||
/// 构造。
|
||||
const AlertItem({required this.title, required this.detail});
|
||||
|
||||
/// 标题。
|
||||
final String title;
|
||||
|
||||
/// 描述。
|
||||
final String detail;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/// 工作台的服务端调用。
|
||||
///
|
||||
/// 02 §Repository 接口的位置规则:本 feature 没有 `domain` 层,接口直接声明在
|
||||
/// `data/repository/`,presentation 只依赖接口。
|
||||
library;
|
||||
|
||||
import 'package:core_foundation/core_foundation.dart';
|
||||
import 'package:core_network/core_network.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'home_models.dart';
|
||||
|
||||
/// 工作台数据。
|
||||
///
|
||||
/// ---------------------------------------------------------------------------
|
||||
/// **两个方法,两个请求,不合并。** 合并成一个 `fetchHome()` 会把降级粒度也
|
||||
/// 一起合并掉:待办挂了就连预警一起看不见(12 §四)。接口层面就分开,
|
||||
/// presentation 才有分开降级的可能。
|
||||
/// ---------------------------------------------------------------------------
|
||||
abstract interface class HomeRepository {
|
||||
/// 待办列表。
|
||||
Future<List<TodoItem>> fetchTodos();
|
||||
|
||||
/// 预警列表。
|
||||
Future<List<AlertItem>> fetchAlerts();
|
||||
}
|
||||
|
||||
/// [HomeRepository] 的实现。
|
||||
///
|
||||
/// TODO(backend): 路径和字段名按 PRD 工作台描述推的,待接口契约确认后校准。
|
||||
class HomeRepositoryImpl implements HomeRepository {
|
||||
/// repository 一律注入 [ApiClient],不注入 Dio(05)。
|
||||
const HomeRepositoryImpl(this._api);
|
||||
|
||||
final ApiClient _api;
|
||||
|
||||
@override
|
||||
Future<List<TodoItem>> fetchTodos() async {
|
||||
final List<dynamic> raw = await _api.get<List<dynamic>>('/api/v1/home/todos');
|
||||
return raw.map((dynamic e) {
|
||||
final Map<String, dynamic> m = e as Map<String, dynamic>;
|
||||
return TodoItem(
|
||||
code: _requireString(m, 'code'),
|
||||
title: _requireString(m, 'title'),
|
||||
count: (m['count'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<AlertItem>> fetchAlerts() async {
|
||||
final List<dynamic> raw = await _api.get<List<dynamic>>('/api/v1/home/alerts');
|
||||
return raw.map((dynamic e) {
|
||||
final Map<String, dynamic> m = e as Map<String, dynamic>;
|
||||
return AlertItem(title: _requireString(m, 'title'), detail: _requireString(m, 'detail'));
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// 缺字段直接当服务异常,不给默认值——理由同 feature_auth。
|
||||
// count 是例外:角标缺失降级为不显示,不值得整个待办区块挂掉。
|
||||
static String _requireString(Map<String, dynamic> json, String key) {
|
||||
final Object? v = json[key];
|
||||
if (v is! String) {
|
||||
throw ServerException('响应缺少字段 $key');
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
/// presentation 通过它拿接口类型。
|
||||
final Provider<HomeRepository> homeRepositoryProvider = Provider<HomeRepository>(
|
||||
(Ref ref) => HomeRepositoryImpl(ref.watch(apiClientProvider)),
|
||||
);
|
||||
@@ -0,0 +1,235 @@
|
||||
/// 工作台(首页)。降级粒度来源:conti-docs/12-error-and-api-contract.md §四。
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
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_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/home_models.dart';
|
||||
import 'home_providers.dart';
|
||||
|
||||
/// 工作台。
|
||||
///
|
||||
/// ---------------------------------------------------------------------------
|
||||
/// 三档降级在这个文件里的对应关系(12 §四):
|
||||
/// 1. 门店上下文、菜单挂了 → [HomePage] 整页 [ErrorView] + 重试
|
||||
/// 2. 待办、预警挂了 → 各自的 [_SectionAsync] 显示 [TileErrorView],其余照常
|
||||
/// 3. 菜单 tile 的角标挂了 → [_MenuSection] 里不显示角标,**不显示任何错误 UI**
|
||||
///
|
||||
/// 每一档都由"谁 watch 谁"决定,不是由 try-catch 决定——所以这三个 section
|
||||
/// 必须各 watch 各的 provider,不能在上层合并(见 `home_providers.dart`)。
|
||||
/// ---------------------------------------------------------------------------
|
||||
class HomePage extends ConsumerWidget {
|
||||
/// 构造。
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final AsyncValue<AppSession> session = ref.watch(sessionProvider);
|
||||
final AppSession? value = session.value;
|
||||
final StoreContext? store = value is SessionActive ? value.store : null;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(store?.storeName ?? '工作台'),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
key: const Key('home_store_switch'),
|
||||
icon: const Icon(Icons.store_outlined),
|
||||
tooltip: '切换门店',
|
||||
onPressed: () => context.push(AppRoutes.storePicker),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: store == null
|
||||
// 第 1 档:没有门店上下文,首页整体没有意义。
|
||||
? AsyncValueView<AppSession>(
|
||||
value: session,
|
||||
onRetry: () => unawaited(ref.read(sessionProvider.notifier).retryStoreLoad()),
|
||||
data: (AppSession _) => const Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: const <Widget>[_MenuSection(), _TodoSection(), _AlertSection()],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MenuSection extends ConsumerWidget {
|
||||
const _MenuSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final List<MenuEntry> entries = ref.watch(homeMenuEntriesProvider);
|
||||
|
||||
// 第 3 档降级:角标是锦上添花,拿不到就不显示。
|
||||
// 这里**故意**只取 .value 而不处理 error——待办接口挂了,菜单入口照常
|
||||
// 能点,用户仍然能进去干活。给角标加一个错误 UI 只会挡住入口。
|
||||
final Map<String, int> badges = <String, int>{
|
||||
for (final TodoItem todo in ref.watch(homeTodosProvider).value ?? const <TodoItem>[])
|
||||
todo.code: todo.count,
|
||||
};
|
||||
|
||||
if (entries.isEmpty) {
|
||||
return const _Section(title: '常用功能', child: Text('当前门店没有可用功能'));
|
||||
}
|
||||
|
||||
return _Section(
|
||||
title: '常用功能',
|
||||
child: GridView.count(
|
||||
crossAxisCount: 3,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: <Widget>[
|
||||
for (final MenuEntry entry in entries) _MenuTile(entry: entry, badge: badges[entry.code]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MenuTile extends StatelessWidget {
|
||||
const _MenuTile({required this.entry, this.badge});
|
||||
|
||||
final MenuEntry entry;
|
||||
final int? badge;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
key: Key('home_menu_${entry.code}'),
|
||||
// 路由来自 menuRouteMap,绝不会是后端下发的 URL(04 的安全约定)。
|
||||
onTap: () => context.push(entry.route),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Badge(
|
||||
isLabelVisible: badge != null && badge! > 0,
|
||||
label: Text('$badge'),
|
||||
child: const Icon(Icons.widgets_outlined, size: 32),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(entry.name, textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TodoSection extends ConsumerWidget {
|
||||
const _TodoSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return _Section(
|
||||
title: '待办',
|
||||
child: _SectionAsync<List<TodoItem>>(
|
||||
value: ref.watch(homeTodosProvider),
|
||||
onRetry: () => ref.invalidate(homeTodosProvider),
|
||||
empty: '暂无待办',
|
||||
data: (List<TodoItem> todos) => Column(
|
||||
children: <Widget>[
|
||||
for (final TodoItem todo in todos)
|
||||
ListTile(
|
||||
key: Key('home_todo_${todo.code}'),
|
||||
title: Text(todo.title),
|
||||
trailing: Text('${todo.count}'),
|
||||
onTap: () {
|
||||
final String? route = resolveMenuRoute(todo.code);
|
||||
if (route != null) {
|
||||
unawaited(context.push(route));
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlertSection extends ConsumerWidget {
|
||||
const _AlertSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return _Section(
|
||||
title: '预警',
|
||||
child: _SectionAsync<List<AlertItem>>(
|
||||
value: ref.watch(homeAlertsProvider),
|
||||
onRetry: () => ref.invalidate(homeAlertsProvider),
|
||||
empty: '暂无预警',
|
||||
data: (List<AlertItem> alerts) => Column(
|
||||
children: <Widget>[
|
||||
for (final AlertItem alert in alerts)
|
||||
ListTile(title: Text(alert.title), subtitle: Text(alert.detail)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 区块级三态。
|
||||
///
|
||||
/// 和 core_ui 的 [AsyncValueView] 唯一的区别:错误态用 [TileErrorView] 而不是
|
||||
/// 整页 [ErrorView]——这就是第 2 档降级。判定顺序保持一致(先看有没有数据,
|
||||
/// 刷新失败时继续渲染旧数据)。
|
||||
class _SectionAsync<T> extends StatelessWidget {
|
||||
const _SectionAsync({
|
||||
required this.value,
|
||||
required this.data,
|
||||
required this.onRetry,
|
||||
required this.empty,
|
||||
});
|
||||
|
||||
final AsyncValue<T> value;
|
||||
final Widget Function(T data) data;
|
||||
final VoidCallback onRetry;
|
||||
final String empty;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (value.hasValue) {
|
||||
final T current = value.value as T;
|
||||
if (current is List<Object?> && current.isEmpty) {
|
||||
return Text(empty);
|
||||
}
|
||||
return data(current);
|
||||
}
|
||||
if (value.hasError && !ErrorPresenter.isSilent(value.error!)) {
|
||||
return TileErrorView(error: value.error!, onRetry: onRetry);
|
||||
}
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Section extends StatelessWidget {
|
||||
const _Section({required this.title, required this.child});
|
||||
|
||||
final String title;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/// 工作台的 provider。来源:conti-docs/12-error-and-api-contract.md §四。
|
||||
library;
|
||||
|
||||
import 'package:core_analytics/core_analytics.dart';
|
||||
import 'package:core_auth/core_auth.dart';
|
||||
import 'package:core_router/core_router.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../data/home_models.dart';
|
||||
import '../data/home_repository.dart';
|
||||
|
||||
part 'home_providers.g.dart';
|
||||
|
||||
/// 一个已经解析到本端路由的菜单入口。
|
||||
typedef MenuEntry = ({String code, String name, String route});
|
||||
|
||||
/// 待办。
|
||||
///
|
||||
/// ---------------------------------------------------------------------------
|
||||
/// **为什么每个区块一个 provider,而不是一个 `Future.wait` 拉全部?**
|
||||
///
|
||||
/// `Future.wait` 的语义是"全部成功才算成功"——四个请求里任意一个挂了,整个
|
||||
/// 工作台就是错误态。但门店现场最常见的情况恰恰是某一个下游服务抖动,
|
||||
/// 这时把已经拿到的待办、菜单一起藏起来,用户就什么都干不了了。
|
||||
///
|
||||
/// 12 §四把降级粒度定死成三档:
|
||||
/// 1. 门店上下文、菜单 → 整页错误态 + 重试(没有它首页无意义)
|
||||
/// 2. 待办、预警、公告、促销位 → 该区块局部错误态,其余正常
|
||||
/// 3. tile 上的数字/角标 → 降级为不显示角标,不显示错误 UI
|
||||
///
|
||||
/// 只有"一个区块一个 provider、各自 watch 各自的"这种写法能表达第 2 档。
|
||||
/// ---------------------------------------------------------------------------
|
||||
///
|
||||
/// `ref.watch(currentStoreIdProvider)` 不是为了用返回值,而是为了**订阅门店**:
|
||||
/// 切店后本 provider 自动失效重拉(11 §切店级联的第 4 步)。漏了这一句,
|
||||
/// 切完店首页还显示上一家店的待办。
|
||||
@riverpod
|
||||
Future<List<TodoItem>> homeTodos(Ref ref) {
|
||||
ref.watch(currentStoreIdProvider);
|
||||
return ref.watch(homeRepositoryProvider).fetchTodos();
|
||||
}
|
||||
|
||||
/// 预警。失败时只影响预警区块,见 [homeTodos] 的说明。
|
||||
@riverpod
|
||||
Future<List<AlertItem>> homeAlerts(Ref ref) {
|
||||
ref.watch(currentStoreIdProvider);
|
||||
return ref.watch(homeRepositoryProvider).fetchAlerts();
|
||||
}
|
||||
|
||||
/// 当前门店的菜单,已按本端路由表过滤。
|
||||
///
|
||||
/// ---------------------------------------------------------------------------
|
||||
/// 后端下发了本端没有的编码时:**隐藏该入口 + 上报**(04)。不能弹错、不能
|
||||
/// 留一个点了没反应的格子——灰度期后端先配菜单、App 后发版是常态。
|
||||
///
|
||||
/// 解析和上报放在 provider 里而不是 `build()` 里:`build()` 每帧都可能重跑,
|
||||
/// 埋点会被刷爆;provider 只在门店(菜单随门店下发)变化时重算一次。
|
||||
/// ---------------------------------------------------------------------------
|
||||
@riverpod
|
||||
List<MenuEntry> homeMenuEntries(Ref ref) {
|
||||
final AppSession? session = ref.watch(sessionProvider).value;
|
||||
final List<MenuItem> menus = session is SessionActive ? session.store.menus : const <MenuItem>[];
|
||||
|
||||
final List<MenuEntry> entries = <MenuEntry>[];
|
||||
final List<String> unsupported = <String>[];
|
||||
for (final MenuItem item in menus) {
|
||||
final String? route = resolveMenuRoute(item.code);
|
||||
if (route == null) {
|
||||
unsupported.add(item.code);
|
||||
continue;
|
||||
}
|
||||
entries.add((code: item.code, name: item.name, route: route));
|
||||
}
|
||||
|
||||
if (unsupported.isNotEmpty) {
|
||||
final Analytics analytics = ref.read(analyticsProvider);
|
||||
for (final String code in unsupported) {
|
||||
analytics.track(AnalyticsEvent.menuCodeUnsupported, <String, Object?>{
|
||||
AnalyticsParam.code: code,
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/// 本 feature 对外暴露的路由。来源:conti-docs/04-routing.md。
|
||||
library;
|
||||
|
||||
import 'package:core_router/core_router.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'presentation/home_page.dart';
|
||||
|
||||
/// 工作台路由。
|
||||
///
|
||||
/// 拼装在 `app/` 的 `appRoutesProvider` 里完成——core_router 不依赖任何
|
||||
/// feature,feature 之间也不互相 import。`GoRoute` 来自 core_router 的
|
||||
/// re-export,本包 pubspec 里没有 go_router。
|
||||
List<RouteBase> buildHomeRoutes() => <RouteBase>[
|
||||
GoRoute(
|
||||
path: AppRoutes.home,
|
||||
builder: (BuildContext context, GoRouterState state) => const HomePage(),
|
||||
),
|
||||
];
|
||||
@@ -0,0 +1,32 @@
|
||||
name: feature_home
|
||||
description: 工作台(首页)。
|
||||
publish_to: none
|
||||
version: 0.1.0
|
||||
resolution: workspace
|
||||
|
||||
# feature_* 的 pubspec 是包边界的**执行现场**:
|
||||
# - 这里不出现 feature_auth(feature 间禁止互相依赖,见 01)——工作台看起来
|
||||
# "需要" 登录信息,但它拿的是 core_auth 的会话状态,不是 feature_auth 的页面
|
||||
# - 这里不直接出现 go_router(路由类型由 core_router re-export,见 04)
|
||||
environment:
|
||||
sdk: ^3.12.0
|
||||
|
||||
dependencies:
|
||||
core_analytics: ^0.1.0
|
||||
core_auth: ^0.1.0
|
||||
core_foundation: ^0.1.0
|
||||
core_network: ^0.1.0
|
||||
core_router: ^0.1.0
|
||||
core_ui: ^0.1.0
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_riverpod: ^3.3.2
|
||||
riverpod_annotation: ^4.0.3
|
||||
|
||||
dev_dependencies:
|
||||
build_runner: ^2.4.13
|
||||
flutter_lints: ^6.0.0
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
mocktail: ^1.0.5
|
||||
riverpod_generator: ^4.0.4
|
||||
@@ -0,0 +1,158 @@
|
||||
// feature_home 的高价值断言全部围绕**降级粒度**(12 §四)——工作台的复杂度
|
||||
// 不在于渲染,而在于"四个数据源里挂了一个的时候,屏幕上还剩下什么"。
|
||||
// 1. 待办挂了:菜单入口必须还在、角标消失、不能整页报错。
|
||||
// 2. 后端下发了本端没有的菜单编码:隐藏入口 + 上报,而不是留个死格子。
|
||||
|
||||
import 'package:core_analytics/core_analytics.dart';
|
||||
import 'package:core_auth/core_auth.dart';
|
||||
import 'package:core_foundation/core_foundation.dart';
|
||||
import 'package:core_ui/core_ui.dart';
|
||||
import 'package:feature_home/feature_home.dart';
|
||||
// 页面和内部 provider 不对外导出,测试走 src。
|
||||
import 'package:feature_home/src/presentation/home_page.dart';
|
||||
import 'package:feature_home/src/presentation/home_providers.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
const UserContext _user = UserContext(
|
||||
userId: 'U1',
|
||||
employeeId: 'E1',
|
||||
phone: '13800000000',
|
||||
roleCode: 'CLERK',
|
||||
channel: 'RETAIL',
|
||||
permissions: <String>{},
|
||||
);
|
||||
|
||||
StoreContext _store(List<MenuItem> menus) =>
|
||||
StoreContext(storeId: 1, storeCode: 'S001', storeName: '朝阳店', orgId: 9, menus: menus);
|
||||
|
||||
class _FakeSession extends SessionNotifier {
|
||||
_FakeSession(this.session);
|
||||
|
||||
final AppSession session;
|
||||
|
||||
@override
|
||||
Future<AppSession> build() async => session;
|
||||
}
|
||||
|
||||
class _FakeHomeRepository implements HomeRepository {
|
||||
_FakeHomeRepository({this.todosError = false});
|
||||
|
||||
final bool todosError;
|
||||
|
||||
@override
|
||||
Future<List<TodoItem>> fetchTodos() async {
|
||||
if (todosError) {
|
||||
throw const ServerException('boom');
|
||||
}
|
||||
return const <TodoItem>[TodoItem(code: 'PURCHASE_ORDER', title: '待收货', count: 3)];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<AlertItem>> fetchAlerts() async => const <AlertItem>[
|
||||
AlertItem(title: '库存不足', detail: '3 个 SKU'),
|
||||
];
|
||||
}
|
||||
|
||||
class _RecordingAnalytics implements Analytics {
|
||||
final List<(String, Map<String, Object?>)> events = <(String, Map<String, Object?>)>[];
|
||||
|
||||
@override
|
||||
void track(String event, [Map<String, Object?> params = const <String, Object?>{}]) =>
|
||||
events.add((event, params));
|
||||
|
||||
@override
|
||||
void registerSuperProperties(Map<String, Object?> props) {}
|
||||
|
||||
@override
|
||||
void identify(String userId) {}
|
||||
|
||||
@override
|
||||
void reset() {}
|
||||
}
|
||||
|
||||
Widget _host(AppSession session, HomeRepository repo, {Analytics? analytics}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
sessionProvider.overrideWith(() => _FakeSession(session)),
|
||||
homeRepositoryProvider.overrideWithValue(repo),
|
||||
if (analytics != null) analyticsProvider.overrideWithValue(analytics),
|
||||
],
|
||||
child: const MaterialApp(home: HomePage()),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('待办接口挂了,菜单入口和预警照常显示,只有待办区块降级', (WidgetTester tester) async {
|
||||
final AppSession session = SessionActive(
|
||||
user: _user,
|
||||
store: _store(const <MenuItem>[MenuItem(code: 'PURCHASE_ORDER', name: '采购下单')]),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_host(session, _FakeHomeRepository(todosError: true)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 第 2 档:待办自己显示局部错误态。
|
||||
expect(find.byType(TileErrorView), findsOneWidget);
|
||||
// 第 1 档没有触发:整页错误态不能出现。
|
||||
expect(find.byType(ErrorView), findsNothing);
|
||||
|
||||
// 菜单入口还在——这是这条用例的重点,用户仍然能进去干活。
|
||||
expect(find.byKey(const Key('home_menu_PURCHASE_ORDER')), findsOneWidget);
|
||||
// 第 3 档:角标拿不到就不显示,不是显示 0,更不是显示错误。
|
||||
expect(find.text('3'), findsNothing);
|
||||
|
||||
// 相邻区块不受牵连——这正是不能用 Future.wait 的原因。
|
||||
expect(find.text('库存不足'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('后端下发本端没有的菜单编码时隐藏入口并上报', (WidgetTester tester) async {
|
||||
final _RecordingAnalytics analytics = _RecordingAnalytics();
|
||||
final AppSession session = SessionActive(
|
||||
user: _user,
|
||||
store: _store(const <MenuItem>[
|
||||
MenuItem(code: 'PURCHASE_ORDER', name: '采购下单'),
|
||||
MenuItem(code: 'BRAND_NEW_THING', name: '未来功能'),
|
||||
]),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_host(session, _FakeHomeRepository(), analytics: analytics));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('home_menu_PURCHASE_ORDER')), findsOneWidget);
|
||||
// 点了没反应的格子比看不见更糟:灰度期后端先配菜单、App 后发版是常态。
|
||||
expect(find.text('未来功能'), findsNothing);
|
||||
|
||||
expect(analytics.events, hasLength(1));
|
||||
expect(analytics.events.single.$1, AnalyticsEvent.menuCodeUnsupported);
|
||||
expect(analytics.events.single.$2[AnalyticsParam.code], 'BRAND_NEW_THING');
|
||||
});
|
||||
|
||||
test('菜单解析只保留本端路由表里有的编码', () async {
|
||||
final ProviderContainer container = ProviderContainer(
|
||||
overrides: [
|
||||
sessionProvider.overrideWith(
|
||||
() => _FakeSession(
|
||||
SessionActive(
|
||||
user: _user,
|
||||
store: _store(const <MenuItem>[
|
||||
MenuItem(code: 'QUOTE_ORDER', name: '报价单'),
|
||||
MenuItem(code: 'NOPE', name: '不存在'),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
// sessionProvider 是异步的:不等它 resolve 就读,菜单是空的。
|
||||
await container.read(sessionProvider.future);
|
||||
|
||||
final List<MenuEntry> entries = container.read(homeMenuEntriesProvider);
|
||||
expect(entries.map((MenuEntry e) => e.code), <String>['QUOTE_ORDER']);
|
||||
// 路由里只带 target 编码,绝不出现后端下发的 URL(04 的安全约定)。
|
||||
expect(entries.single.route, isNot(contains('http')));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user