修复用户端订单列表生命周期与刷新

This commit is contained in:
czl231
2026-09-05 20:49:50 +08:00
parent cec02c8887
commit 1dd21e6df1
8 changed files with 378 additions and 43 deletions

View File

@@ -9,7 +9,6 @@ import '../ui/features/home/home_page.dart';
import '../ui/features/orders/orders_page.dart';
import '../ui/features/profile/profile_page.dart';
import '../ui/features/shared/record_list_page.dart';
import '../ui/features/shared/record_list_view_model.dart';
import '../ui/features/shop/shop_page.dart';
import 'auth_navigation.dart';
import 'dependencies.dart';
@@ -56,7 +55,8 @@ GoRouter createRouter(
builder: (context, state) => RecordListPage(
title: '供气合同',
eyebrow: '可信履约',
viewModel: RecordListViewModel(dependencies.repository.contracts),
sourceKey: 'contracts',
loader: dependencies.repository.contracts,
),
),
GoRoute(
@@ -64,7 +64,8 @@ GoRouter createRouter(
builder: (context, state) => RecordListPage(
title: '钱包流水',
eyebrow: '资金记录',
viewModel: RecordListViewModel(dependencies.repository.walletRecords),
sourceKey: 'wallet_records',
loader: dependencies.repository.walletRecords,
),
),
StatefulShellRoute.indexedStack(

View File

@@ -9,13 +9,21 @@ import '../../../data/services/api_client.dart';
import '../../../data/services/payment_launcher.dart';
import '../../../domain/models/client_models.dart';
import '../shared/record_list_page.dart';
import '../shared/record_list_view_model.dart';
class OrdersPage extends StatelessWidget {
OrdersPage({required this.repository, super.key});
class OrdersPage extends StatefulWidget {
const OrdersPage({required this.repository, super.key});
final ClientRepository repository;
@override
State<OrdersPage> createState() => _OrdersPageState();
}
class _OrdersPageState extends State<OrdersPage> {
final PaymentLauncher _launcher = PaymentLauncher();
int _shopRefreshToken = 0;
int _gasRefreshToken = 0;
int _refundRefreshToken = 0;
Future<void> _openActions(
BuildContext context,
@@ -61,7 +69,7 @@ class OrdersPage extends StatelessWidget {
final reason = await _reason(context);
if (reason == null || !context.mounted) return;
final rawItems = record.raw['items'] as List? ?? const [];
await repository.createRefund(
await widget.repository.createRefund(
business: business,
identity: record.identity,
requestNo: const Uuid().v7(),
@@ -75,13 +83,21 @@ class OrdersPage extends StatelessWidget {
}).toList(),
);
if (context.mounted) {
setState(() {
if (business == 'shop') {
_shopRefreshToken += 1;
} else {
_gasRefreshToken += 1;
}
_refundRefreshToken += 1;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('退款申请已提交')),
);
}
return;
}
final payment = await repository.payOrder(
final payment = await widget.repository.payOrder(
business: business,
identity: record.identity,
requestNo: const Uuid().v7(),
@@ -106,29 +122,10 @@ class OrdersPage extends StatelessWidget {
}
Future<String?> _reason(BuildContext context) async {
final controller = TextEditingController();
final value = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('申请退款'),
content: TextField(
controller: controller,
maxLines: 3,
decoration: const InputDecoration(labelText: '退款原因'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, controller.text.trim()),
child: const Text('提交'),
),
],
),
builder: (context) => const _RefundReasonDialog(),
);
controller.dispose();
return value?.isEmpty == true ? null : value;
}
@@ -150,37 +147,86 @@ class OrdersPage extends StatelessWidget {
body: TabBarView(
children: [
RecordListPage(
key: const PageStorageKey('shop_orders'),
title: '商城订单',
eyebrow: '交易',
description: '查看商城订单并处理待支付或退款事项',
embedded: true,
viewModel: RecordListViewModel(repository.shopOrders),
sourceKey: 'shop_orders',
loader: widget.repository.shopOrders,
refreshToken: _shopRefreshToken,
onRecordTap: (record) => _openActions(context, record, 'shop'),
),
RecordListPage(
key: const PageStorageKey('gas_orders'),
title: '供气订单',
eyebrow: '履约',
description: '查看供气订单与当前履约状态',
embedded: true,
viewModel: RecordListViewModel(repository.gasOrders),
sourceKey: 'gas_orders',
loader: widget.repository.gasOrders,
refreshToken: _gasRefreshToken,
onRecordTap: (record) => _openActions(context, record, 'gas'),
),
RecordListPage(
key: const PageStorageKey('refunds'),
title: '退款记录',
eyebrow: '资金',
description: '退款结果以服务端审核和资金流水为准',
embedded: true,
viewModel: RecordListViewModel(repository.refunds),
sourceKey: 'refunds',
loader: widget.repository.refunds,
refreshToken: _refundRefreshToken,
),
RecordListPage(
key: const PageStorageKey('tickets'),
title: '服务工单',
eyebrow: '服务',
description: '查看维修、安装与其他服务工单',
embedded: true,
viewModel: RecordListViewModel(repository.tickets),
sourceKey: 'tickets',
loader: widget.repository.tickets,
),
],
),
),
);
}
/// 独立管理退款原因输入框生命周期,避免关闭动画期间提前释放控制器。
class _RefundReasonDialog extends StatefulWidget {
const _RefundReasonDialog();
@override
State<_RefundReasonDialog> createState() => _RefundReasonDialogState();
}
class _RefundReasonDialogState extends State<_RefundReasonDialog> {
final TextEditingController _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => AlertDialog(
title: const Text('申请退款'),
content: TextField(
controller: _controller,
maxLines: 3,
decoration: const InputDecoration(labelText: '退款原因'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, _controller.text.trim()),
child: const Text('提交'),
),
],
);
}

View File

@@ -8,45 +8,66 @@ class RecordListPage extends StatefulWidget {
const RecordListPage({
required this.title,
required this.eyebrow,
required this.viewModel,
required this.sourceKey,
required this.loader,
this.description,
this.floatingActionButton,
this.onRecordTap,
this.embedded = false,
this.refreshToken = 0,
super.key,
});
final String title;
final String eyebrow;
final String? description;
final RecordListViewModel viewModel;
final String sourceKey;
final RecordLoader loader;
final Widget? floatingActionButton;
final ValueChanged<ClientRecord>? onRecordTap;
final bool embedded;
final int refreshToken;
@override
State<RecordListPage> createState() => _RecordListPageState();
}
class _RecordListPageState extends State<RecordListPage> {
class _RecordListPageState extends State<RecordListPage>
with AutomaticKeepAliveClientMixin<RecordListPage> {
late RecordListViewModel _viewModel;
@override
void initState() {
super.initState();
widget.viewModel.load();
_viewModel = RecordListViewModel(widget.loader)..load();
}
@override
void didUpdateWidget(covariant RecordListPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.sourceKey != oldWidget.sourceKey) {
_viewModel.dispose();
_viewModel = RecordListViewModel(widget.loader)..load();
return;
}
if (widget.refreshToken != oldWidget.refreshToken) {
_viewModel.load();
}
}
@override
void dispose() {
widget.viewModel.dispose();
_viewModel.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
super.build(context);
final content = ListenableBuilder(
listenable: widget.viewModel,
listenable: _viewModel,
builder: (context, _) {
final state = widget.viewModel;
final state = _viewModel;
if (state.loading && state.records.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
@@ -123,4 +144,7 @@ class _RecordListPageState extends State<RecordListPage> {
body: content,
);
}
@override
bool get wantKeepAlive => true;
}

View File

@@ -14,23 +14,46 @@ class RecordListViewModel extends ChangeNotifier {
List<ClientRecord> _records = const [];
Object? _error;
bool _loading = false;
bool _disposed = false;
int _requestGeneration = 0;
List<ClientRecord> get records => List.unmodifiable(_records);
Object? get error => _error;
bool get loading => _loading;
Future<void> load() async {
final generation = ++_requestGeneration;
_loading = true;
_error = null;
notifyListeners();
_notifyIfActive();
try {
_records = await _loader();
final records = await _loader();
if (!_isCurrent(generation)) return;
_records = records;
} catch (error) {
if (!_isCurrent(generation)) return;
// 会话失效由统一路由接管,不在列表中重复展示网络错误。
if (error is! SessionExpiredException) _error = error;
} finally {
_loading = false;
notifyListeners();
if (_isCurrent(generation)) {
_loading = false;
_notifyIfActive();
}
}
}
/// 仅允许最后一次请求且组件仍存活时写入状态。
bool _isCurrent(int generation) => !_disposed && generation == _requestGeneration;
/// 避免异步请求在页面销毁后继续通知监听器。
void _notifyIfActive() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
_requestGeneration += 1;
super.dispose();
}
}

View File

@@ -0,0 +1,97 @@
// 功能描述:验证订单退款成功后的跨标签刷新行为。
// 版本1.0.0
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/data/repositories/client_repository.dart';
import 'package:user_app/data/services/api_client.dart';
import 'package:user_app/domain/models/client_models.dart';
import 'package:user_app/ui/features/orders/orders_page.dart';
/// 提供无需网络的订单数据并记录各列表刷新次数。
class _FakeRepository extends ClientRepository {
_FakeRepository() : super(ApiClient(() => '', baseUrl: 'https://api.example.com'));
int shopCalls = 0;
int gasCalls = 0;
int refundCalls = 0;
int ticketCalls = 0;
int createRefundCalls = 0;
@override
Future<List<ClientRecord>> shopOrders() async {
shopCalls += 1;
return const [
ClientRecord(
identity: 'shop-1',
title: '商城订单一',
subtitle: '',
status: 18,
raw: {
'items': [
{'identity': 'item-1', 'quantity': 1},
],
},
),
];
}
@override
Future<List<ClientRecord>> gasOrders() async {
gasCalls += 1;
return const [];
}
@override
Future<List<ClientRecord>> refunds() async {
refundCalls += 1;
return const [];
}
@override
Future<List<ClientRecord>> tickets() async {
ticketCalls += 1;
return const [];
}
@override
Future<void> createRefund({
required String business,
required String identity,
required String requestNo,
required String reason,
required List<Map<String, Object?>> items,
}) async {
createRefundCalls += 1;
}
}
void main() {
testWidgets('退款成功后刷新商城订单和退款记录', (tester) async {
final repository = _FakeRepository();
await tester.pumpWidget(
MaterialApp(home: OrdersPage(repository: repository)),
);
await tester.pumpAndSettle();
await tester.tap(find.text('退款'));
await tester.pumpAndSettle();
expect(repository.refundCalls, 1);
await tester.tap(find.text('商城'));
await tester.pumpAndSettle();
expect(repository.shopCalls, 1);
await tester.tap(find.text('商城订单一'));
await tester.pumpAndSettle();
await tester.tap(find.text('申请退款'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '商品问题');
await tester.tap(find.text('提交'));
await tester.pumpAndSettle();
expect(repository.createRefundCalls, 1);
expect(repository.shopCalls, 2);
expect(repository.refundCalls, 2);
expect(tester.takeException(), isNull);
});
}

View File

@@ -0,0 +1,45 @@
// 功能描述:验证记录列表在父组件重建和刷新标记变化时的生命周期。
// 版本1.0.0
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/domain/models/client_models.dart';
import 'package:user_app/ui/features/shared/record_list_page.dart';
void main() {
testWidgets('普通重建不重复请求,刷新标记变化才重新加载', (tester) async {
var calls = 0;
var refreshToken = 0;
late StateSetter rebuild;
Future<List<ClientRecord>> loader() async {
calls += 1;
return const [];
}
await tester.pumpWidget(
MaterialApp(
home: StatefulBuilder(
builder: (context, setState) {
rebuild = setState;
return RecordListPage(
title: '测试列表',
eyebrow: '测试',
sourceKey: 'records',
loader: loader,
refreshToken: refreshToken,
);
},
),
),
);
await tester.pumpAndSettle();
expect(calls, 1);
rebuild(() {});
await tester.pumpAndSettle();
expect(calls, 1);
rebuild(() => refreshToken += 1);
await tester.pumpAndSettle();
expect(calls, 2);
});
}

View File

@@ -0,0 +1,52 @@
// 功能描述:验证记录列表异步请求的代次与销毁保护。
// 版本1.0.0
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/domain/models/client_models.dart';
import 'package:user_app/ui/features/shared/record_list_view_model.dart';
ClientRecord _record(String identity) => ClientRecord(
identity: identity,
title: identity,
subtitle: '',
raw: const {},
);
void main() {
test('后发请求优先,旧结果不会覆盖新数据', () async {
final first = Completer<List<ClientRecord>>();
final second = Completer<List<ClientRecord>>();
var calls = 0;
final viewModel = RecordListViewModel(() {
calls += 1;
return calls == 1 ? first.future : second.future;
});
final firstLoad = viewModel.load();
final secondLoad = viewModel.load();
second.complete([_record('new')]);
await secondLoad;
first.complete([_record('old')]);
await firstLoad;
expect(viewModel.records.single.identity, 'new');
expect(viewModel.loading, isFalse);
viewModel.dispose();
});
test('销毁后异步结果不会继续通知监听器', () async {
final pending = Completer<List<ClientRecord>>();
final viewModel = RecordListViewModel(() => pending.future);
var notifications = 0;
viewModel.addListener(() => notifications += 1);
final load = viewModel.load();
expect(notifications, 1);
viewModel.dispose();
pending.complete([_record('late')]);
await load;
expect(notifications, 1);
});
}