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
@@ -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],不注入 Dio05)。
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;
}
+19
View File
@@ -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 不依赖任何
/// featurefeature 之间也不互相 import。`GoRoute` 来自 core_router 的
/// re-export,本包 pubspec 里没有 go_router。
List<RouteBase> buildHomeRoutes() => <RouteBase>[
GoRoute(
path: AppRoutes.home,
builder: (BuildContext context, GoRouterState state) => const HomePage(),
),
];
+32
View File
@@ -0,0 +1,32 @@
name: feature_home
description: 工作台(首页)。
publish_to: none
version: 0.1.0
resolution: workspace
# feature_* 的 pubspec 是包边界的**执行现场**:
# - 这里不出现 feature_authfeature 间禁止互相依赖,见 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')));
});
}