feat(client): 全模块 API 对接 + 修复登陆跳转 + 菜单 UI 优化
API 对接: - 入库/出库/库存/财务/往来单位/基础数据全部对接后端 REST API - 新增 repositories、providers、models 层,统一分层架构 - auth 从 flutter_secure_storage 迁移到 shared_preferences 登陆跳转修复: - 将 _RouterNotifier 提取为独立 Riverpod provider,appRouterProvider 使用 ref.read 避免依赖链导致 router 重建后跳回 /login - redirect 函数新增 initialized 守卫,防止 auth 未恢复时误重定向 - 添加调试日志(Router/Auth/ApiClient)定位 401 触发的 logout 链路 退出菜单 UI: - 去掉 ListTile,改用 Row + 自定义 padding,文字左对齐 - MouseRegion + AnimatedContainer 实现 hover 高亮(普通项蓝底/退出红底) - 菜单圆角 6px,elevation 8,分割线高度 1px Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:jiu_client/core/models/page_result.dart';
|
||||
import 'package:jiu_client/models/stock_in.dart';
|
||||
import 'package:jiu_client/providers/stock_in_provider.dart';
|
||||
import 'package:jiu_client/screens/stock_in/stock_in_list_screen.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake notifier
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _FakeStockInNotifier extends StockInListNotifier {
|
||||
final AsyncValue<PageResult<StockInOrder>> _fixed;
|
||||
|
||||
_FakeStockInNotifier(this._fixed);
|
||||
|
||||
@override
|
||||
Future<PageResult<StockInOrder>> build() async {
|
||||
state = _fixed;
|
||||
return state.value ??
|
||||
const PageResult(data: [], total: 0, page: 1, pageSize: 20);
|
||||
}
|
||||
|
||||
@override
|
||||
void reload() {}
|
||||
|
||||
@override
|
||||
void setPage(int page) {}
|
||||
|
||||
@override
|
||||
void setStatus(String status) {}
|
||||
|
||||
@override
|
||||
void setDateRange(String? startDate, String? endDate) {}
|
||||
|
||||
@override
|
||||
Future<void> createOrder(Map<String, dynamic> data) async {}
|
||||
|
||||
@override
|
||||
Future<void> submitOrder(int id) async {}
|
||||
|
||||
@override
|
||||
Future<void> approveOrder(int id) async {}
|
||||
|
||||
@override
|
||||
Future<void> rejectOrder(int id) async {}
|
||||
}
|
||||
|
||||
/// A notifier that stays permanently in loading state (build never completes).
|
||||
class _FakeLoadingStockInNotifier extends StockInListNotifier {
|
||||
@override
|
||||
Future<PageResult<StockInOrder>> build() {
|
||||
// Never completes — keeps the widget in CircularProgressIndicator state.
|
||||
return Completer<PageResult<StockInOrder>>().future;
|
||||
}
|
||||
|
||||
@override
|
||||
void reload() {}
|
||||
|
||||
@override
|
||||
void setPage(int page) {}
|
||||
|
||||
@override
|
||||
void setStatus(String status) {}
|
||||
|
||||
@override
|
||||
void setDateRange(String? startDate, String? endDate) {}
|
||||
|
||||
@override
|
||||
Future<void> createOrder(Map<String, dynamic> data) async {}
|
||||
|
||||
@override
|
||||
Future<void> submitOrder(int id) async {}
|
||||
|
||||
@override
|
||||
Future<void> approveOrder(int id) async {}
|
||||
|
||||
@override
|
||||
Future<void> rejectOrder(int id) async {}
|
||||
}
|
||||
|
||||
/// Build a testable app. GoRouter is needed because the screen uses
|
||||
/// `context.go('/stock-in/new')`.
|
||||
Widget _buildApp(AsyncValue<PageResult<StockInOrder>> state) {
|
||||
final router = GoRouter(
|
||||
initialLocation: '/stock-in',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/stock-in',
|
||||
builder: (_, __) => const Scaffold(body: StockInListScreen()),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/stock-in/new',
|
||||
builder: (_, __) => const Scaffold(body: Text('new order form')),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
stockInListProvider.overrideWith(() => _FakeStockInNotifier(state)),
|
||||
],
|
||||
child: MaterialApp.router(routerConfig: router),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingApp() {
|
||||
final router = GoRouter(
|
||||
initialLocation: '/stock-in',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/stock-in',
|
||||
builder: (_, __) => const Scaffold(body: StockInListScreen()),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/stock-in/new',
|
||||
builder: (_, __) => const Scaffold(body: Text('new order form')),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
stockInListProvider.overrideWith(() => _FakeLoadingStockInNotifier()),
|
||||
],
|
||||
child: MaterialApp.router(routerConfig: router),
|
||||
);
|
||||
}
|
||||
|
||||
StockInOrder _makeOrder({
|
||||
int id = 1,
|
||||
String orderNo = 'SI2024010001',
|
||||
String status = 'draft',
|
||||
String? partnerName = '张家酒水',
|
||||
String? warehouseName = '主仓库',
|
||||
}) =>
|
||||
StockInOrder(
|
||||
id: id,
|
||||
orderNo: orderNo,
|
||||
warehouseId: 1,
|
||||
warehouseName: warehouseName,
|
||||
partnerName: partnerName,
|
||||
status: status,
|
||||
totalAmount: 1000.0,
|
||||
orderDate: '2024-01-10',
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
void main() {
|
||||
group('StockInListScreen', () {
|
||||
testWidgets('shows CircularProgressIndicator while loading', (tester) async {
|
||||
tester.view.physicalSize = const Size(1280, 800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
|
||||
await tester.pumpWidget(_buildLoadingApp());
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows error message and retry button on failure', (tester) async {
|
||||
tester.view.physicalSize = const Size(1280, 800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildApp(AsyncValue.error('连接超时', StackTrace.empty)),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.textContaining('加载失败'), findsOneWidget);
|
||||
expect(find.text('重试'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows 暂无入库单 when list is empty', (tester) async {
|
||||
tester.view.physicalSize = const Size(1280, 800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
|
||||
const empty =
|
||||
PageResult<StockInOrder>(data: [], total: 0, page: 1, pageSize: 20);
|
||||
|
||||
await tester.pumpWidget(_buildApp(const AsyncValue.data(empty)));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('暂无入库单'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows order list with order number and supplier', (tester) async {
|
||||
tester.view.physicalSize = const Size(1280, 800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
|
||||
final orders = [
|
||||
_makeOrder(id: 1, orderNo: 'SI2024010001', partnerName: '张家酒水'),
|
||||
_makeOrder(id: 2, orderNo: 'SI2024010002', partnerName: '李记酒行'),
|
||||
];
|
||||
|
||||
final result =
|
||||
PageResult<StockInOrder>(data: orders, total: 2, page: 1, pageSize: 20);
|
||||
|
||||
await tester.pumpWidget(_buildApp(AsyncValue.data(result)));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('SI2024010001'), findsOneWidget);
|
||||
expect(find.text('SI2024010002'), findsOneWidget);
|
||||
expect(find.text('张家酒水'), findsOneWidget);
|
||||
expect(find.text('李记酒行'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows 新建入库单 button in toolbar', (tester) async {
|
||||
tester.view.physicalSize = const Size(1280, 800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
|
||||
const empty =
|
||||
PageResult<StockInOrder>(data: [], total: 0, page: 1, pageSize: 20);
|
||||
|
||||
await tester.pumpWidget(_buildApp(const AsyncValue.data(empty)));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('新建入库单'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tapping 新建入库单 navigates to new order route', (tester) async {
|
||||
tester.view.physicalSize = const Size(1280, 800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
|
||||
const empty =
|
||||
PageResult<StockInOrder>(data: [], total: 0, page: 1, pageSize: 20);
|
||||
|
||||
await tester.pumpWidget(_buildApp(const AsyncValue.data(empty)));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('新建入库单'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('new order form'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('draft order shows 提交 button', (tester) async {
|
||||
tester.view.physicalSize = const Size(1280, 800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
|
||||
final orders = [_makeOrder(id: 1, status: 'draft')];
|
||||
final result =
|
||||
PageResult<StockInOrder>(data: orders, total: 1, page: 1, pageSize: 20);
|
||||
|
||||
await tester.pumpWidget(_buildApp(AsyncValue.data(result)));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('提交'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('pending order shows 通过 and 拒绝 buttons', (tester) async {
|
||||
tester.view.physicalSize = const Size(1280, 800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
|
||||
final orders = [_makeOrder(id: 1, status: 'pending')];
|
||||
final result =
|
||||
PageResult<StockInOrder>(data: orders, total: 1, page: 1, pageSize: 20);
|
||||
|
||||
await tester.pumpWidget(_buildApp(AsyncValue.data(result)));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(const Key('btn_approve_1')), findsOneWidget);
|
||||
expect(find.byKey(const Key('btn_reject_1')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('approved order shows no action buttons', (tester) async {
|
||||
tester.view.physicalSize = const Size(1280, 800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
|
||||
final orders = [_makeOrder(id: 1, status: 'approved')];
|
||||
final result =
|
||||
PageResult<StockInOrder>(data: orders, total: 1, page: 1, pageSize: 20);
|
||||
|
||||
await tester.pumpWidget(_buildApp(AsyncValue.data(result)));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(const Key('btn_approve_1')), findsNothing);
|
||||
expect(find.byKey(const Key('btn_reject_1')), findsNothing);
|
||||
expect(find.text('提交'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('tapping approve shows confirmation dialog', (tester) async {
|
||||
tester.view.physicalSize = const Size(1280, 800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
|
||||
final orders = [
|
||||
_makeOrder(id: 1, status: 'pending', orderNo: 'SI2024010001')
|
||||
];
|
||||
final result =
|
||||
PageResult<StockInOrder>(data: orders, total: 1, page: 1, pageSize: 20);
|
||||
|
||||
await tester.pumpWidget(_buildApp(AsyncValue.data(result)));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byKey(const Key('btn_approve_1')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('审核确认'), findsOneWidget);
|
||||
expect(find.textContaining('SI2024010001'), findsAtLeastNWidgets(1));
|
||||
expect(find.text('通过'), findsAtLeastNWidgets(1));
|
||||
});
|
||||
|
||||
testWidgets('tapping reject shows confirmation dialog', (tester) async {
|
||||
tester.view.physicalSize = const Size(1280, 800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
|
||||
final orders = [
|
||||
_makeOrder(id: 1, status: 'pending', orderNo: 'SI2024010001')
|
||||
];
|
||||
final result =
|
||||
PageResult<StockInOrder>(data: orders, total: 1, page: 1, pageSize: 20);
|
||||
|
||||
await tester.pumpWidget(_buildApp(AsyncValue.data(result)));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byKey(const Key('btn_reject_1')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('拒绝确认'), findsOneWidget);
|
||||
expect(find.textContaining('SI2024010001'), findsAtLeastNWidgets(1));
|
||||
expect(find.text('拒绝'), findsAtLeastNWidgets(1));
|
||||
});
|
||||
|
||||
testWidgets('shows status filter dropdown in first tab toolbar', (tester) async {
|
||||
tester.view.physicalSize = const Size(1280, 800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
|
||||
const empty =
|
||||
PageResult<StockInOrder>(data: [], total: 0, page: 1, pageSize: 20);
|
||||
|
||||
await tester.pumpWidget(_buildApp(const AsyncValue.data(empty)));
|
||||
await tester.pump();
|
||||
|
||||
// The dropdown shows 全部状态 by default
|
||||
expect(find.text('全部状态'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows total record count in pagination bar', (tester) async {
|
||||
tester.view.physicalSize = const Size(1280, 800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
|
||||
final orders = [_makeOrder(id: 1)];
|
||||
final result =
|
||||
PageResult<StockInOrder>(data: orders, total: 35, page: 1, pageSize: 20);
|
||||
|
||||
await tester.pumpWidget(_buildApp(AsyncValue.data(result)));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.textContaining('35'), findsAtLeastNWidgets(1));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user