Skip to content

Commit 87d030e

Browse files
authored
Merge pull request #22 from LEO0331/lc/dev/newfeatures
improve test coverage to 90%
2 parents 12a363e + 0510011 commit 87d030e

File tree

8 files changed

+694
-26
lines changed

8 files changed

+694
-26
lines changed

lib/core/i18n/app_strings.dart

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,23 @@ class AppStrings {
7171
String get offlineIdentityMode =>
7272
_zh ? '使用離線身份模式' : 'Using offline identity mode';
7373
String get reportSafetyConcern => _zh ? '回報風險事件' : 'Report safety concern';
74+
String get reportRiskSelectReasonTitle =>
75+
_zh ? '請選擇回報原因' : 'Select a reason';
76+
String get riskReasonPrivateLocation => _zh
77+
? '要求改到私下地點面交'
78+
: 'Asked to move pickup to a private location';
79+
String get riskReasonSuspiciousBehavior =>
80+
_zh ? '現場行為可疑 / 騷擾' : 'Suspicious behavior / harassment';
81+
String get riskReasonNoShow =>
82+
_zh ? '公開取餐點無人交付' : 'No handoff at the public pickup point';
83+
String get riskReasonUnsafeCondition =>
84+
_zh ? '食物狀態疑似不安全' : 'Food condition appears unsafe';
85+
String get riskReasonOther => _zh ? '其他風險' : 'Other risk';
7486
String get abuseReported => _zh ? '已送出風險回報。' : 'Safety report submitted.';
7587
String get verifiedEnterprise => _zh ? '已驗證企業' : 'Verified enterprise';
88+
String get highImpactEnterprise => _zh ? '高量捐贈企業' : 'High-impact donor';
89+
String get flexiblePickupEnterprise => _zh ? '彈性取餐時段' : 'Flexible pickup window';
90+
String get stableShelfLifeEnterprise => _zh ? '保存時效較穩定' : 'Stable shelf-life setup';
7691
String get pendingConfirm => _zh ? '待確認' : 'Pending';
7792
String get confirmedFilter => _zh ? '已確認' : 'Confirmed';
7893
String get showPickupCodeHelp => _zh

lib/features/surplus/presentation/browse/reservation_confirmation_page.dart

Lines changed: 108 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,75 @@ class ReservationConfirmationPage extends StatelessWidget {
2020
final String listingId;
2121
final String reservationId;
2222

23+
Future<String?> _pickRiskReason(BuildContext context) async {
24+
final s = AppStrings.of(context);
25+
final reasons = <({String code, String label})>[
26+
(
27+
code: 'recipient_report_private_location_request',
28+
label: s.riskReasonPrivateLocation,
29+
),
30+
(
31+
code: 'recipient_report_suspicious_behavior',
32+
label: s.riskReasonSuspiciousBehavior,
33+
),
34+
(
35+
code: 'recipient_report_public_handoff_no_show',
36+
label: s.riskReasonNoShow,
37+
),
38+
(
39+
code: 'recipient_report_food_condition_unsafe',
40+
label: s.riskReasonUnsafeCondition,
41+
),
42+
(code: 'recipient_report_other', label: s.riskReasonOther),
43+
];
44+
45+
return showDialog<String>(
46+
context: context,
47+
builder: (context) {
48+
return AlertDialog(
49+
title: Text(s.reportRiskSelectReasonTitle),
50+
content: ConstrainedBox(
51+
constraints: const BoxConstraints(maxWidth: 420),
52+
child: SingleChildScrollView(
53+
child: Column(
54+
mainAxisSize: MainAxisSize.min,
55+
children: reasons
56+
.map(
57+
(reason) => ListTile(
58+
dense: true,
59+
contentPadding: EdgeInsets.zero,
60+
title: Text(reason.label),
61+
onTap: () => Navigator.of(context).pop(reason.code),
62+
),
63+
)
64+
.toList(),
65+
),
66+
),
67+
),
68+
actions: [
69+
TextButton(
70+
onPressed: () => Navigator.of(context).pop(),
71+
child: Text(s.cancel),
72+
),
73+
],
74+
);
75+
},
76+
);
77+
}
78+
2379
Future<void> _reportAbuse(BuildContext context) async {
2480
final deps = AppScope.of(context);
2581
final s = AppStrings.of(context);
82+
final reason = await _pickRiskReason(context);
83+
if (reason == null || reason.isEmpty || !context.mounted) {
84+
return;
85+
}
2686
try {
2787
final uid = await deps.identityService.ensureRecipientUid();
2888
await deps.repository.addAbuseSignal(
2989
listingId: listingId,
3090
claimerUid: uid,
31-
reason: 'recipient_report_private_location_request',
91+
reason: reason,
3292
);
3393
if (!context.mounted) {
3494
return;
@@ -46,6 +106,32 @@ class ReservationConfirmationPage extends StatelessWidget {
46106
}
47107
}
48108

109+
List<({IconData icon, String label})> _enterpriseBadges({
110+
required Listing listing,
111+
required AppStrings s,
112+
required bool isFrequentEnterprise,
113+
}) {
114+
final badges = <({IconData icon, String label})>[];
115+
if (listing.enterpriseVerified) {
116+
badges.add((icon: Icons.verified, label: s.verifiedEnterprise));
117+
}
118+
if (isFrequentEnterprise) {
119+
badges.add((icon: Icons.eco_outlined, label: s.frequentEnterprise));
120+
}
121+
if (listing.quantityTotal >= 20) {
122+
badges.add((icon: Icons.workspace_premium_outlined, label: s.highImpactEnterprise));
123+
}
124+
if (listing.pickupEndAt.difference(listing.pickupStartAt) >=
125+
const Duration(minutes: 120)) {
126+
badges.add((icon: Icons.schedule_outlined, label: s.flexiblePickupEnterprise));
127+
}
128+
if (listing.expiresAt.difference(listing.pickupStartAt) >=
129+
const Duration(minutes: 120)) {
130+
badges.add((icon: Icons.inventory_2_outlined, label: s.stableShelfLifeEnterprise));
131+
}
132+
return badges;
133+
}
134+
49135
@override
50136
Widget build(BuildContext context) {
51137
final dependencies = AppScope.of(context);
@@ -221,8 +307,11 @@ class ReservationConfirmationPage extends StatelessWidget {
221307
Text(
222308
'Enterprise: ${listing.displayNameOptional}',
223309
),
224-
if (listing.enterpriseVerified ||
225-
isFrequentEnterprise)
310+
if (_enterpriseBadges(
311+
listing: listing,
312+
s: s,
313+
isFrequentEnterprise: isFrequentEnterprise,
314+
).isNotEmpty)
226315
Padding(
227316
padding: const EdgeInsets.only(
228317
top: 6,
@@ -231,26 +320,23 @@ class ReservationConfirmationPage extends StatelessWidget {
231320
child: Wrap(
232321
spacing: 8,
233322
runSpacing: 6,
234-
children: [
235-
if (listing.enterpriseVerified)
236-
Chip(
237-
avatar: const Icon(
238-
Icons.verified,
239-
size: 16,
240-
color: Color(0xFF2D6A4F),
241-
),
242-
label: Text(s.verifiedEnterprise),
243-
),
244-
if (isFrequentEnterprise)
245-
Chip(
246-
avatar: const Icon(
247-
Icons.eco_outlined,
248-
size: 16,
249-
color: Color(0xFF2D6A4F),
323+
children: _enterpriseBadges(
324+
listing: listing,
325+
s: s,
326+
isFrequentEnterprise:
327+
isFrequentEnterprise,
328+
)
329+
.map(
330+
(badge) => Chip(
331+
avatar: Icon(
332+
badge.icon,
333+
size: 16,
334+
color: const Color(0xFF2D6A4F),
335+
),
336+
label: Text(badge.label),
250337
),
251-
label: Text(s.frequentEnterprise),
252-
),
253-
],
338+
)
339+
.toList(),
254340
),
255341
),
256342
Text(

test/core/app_strings_test.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ void main() {
3131
expect(strings.privacyFaqTitle, 'Privacy & FAQ');
3232
expect(strings.privacyNotice, contains('Boxmatch'));
3333
expect(strings.faqNotice, contains('Report safety concern'));
34+
expect(strings.reportRiskSelectReasonTitle, 'Select a reason');
35+
expect(strings.riskReasonSuspiciousBehavior, contains('Suspicious'));
36+
expect(strings.highImpactEnterprise, 'High-impact donor');
37+
expect(strings.flexiblePickupEnterprise, contains('Flexible'));
38+
expect(strings.stableShelfLifeEnterprise, contains('Stable'));
3439
});
3540

3641
testWidgets('app strings returns zh-TW labels', (tester) async {
@@ -48,6 +53,8 @@ void main() {
4853
Text(strings.navMap),
4954
Text(strings.frequentEnterprise),
5055
Text(strings.privacyFaqTitle),
56+
Text(strings.reportRiskSelectReasonTitle),
57+
Text(strings.highImpactEnterprise),
5158
],
5259
);
5360
},
@@ -59,5 +66,7 @@ void main() {
5966
expect(find.text('地圖'), findsOneWidget);
6067
expect(find.text('常態捐贈企業'), findsOneWidget);
6168
expect(find.text('隱私與常見問題'), findsOneWidget);
69+
expect(find.text('請選擇回報原因'), findsOneWidget);
70+
expect(find.text('高量捐贈企業'), findsOneWidget);
6271
});
6372
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import 'package:boxmatch/app/app_scope.dart';
2+
import 'package:boxmatch/core/i18n/language_menu_button.dart';
3+
import 'package:flutter/material.dart';
4+
import 'package:flutter_test/flutter_test.dart';
5+
6+
import '../test_helpers.dart';
7+
8+
void main() {
9+
testWidgets('language menu button switches locale label', (tester) async {
10+
final dependencies = await buildTestDependencies(language: 'en');
11+
12+
await tester.pumpWidget(
13+
AppScope(
14+
dependencies: dependencies,
15+
child: MaterialApp(
16+
home: Scaffold(
17+
appBar: AppBar(actions: const [LanguageMenuButton()]),
18+
),
19+
),
20+
),
21+
);
22+
await tester.pumpAndSettle();
23+
24+
expect(find.text('EN'), findsOneWidget);
25+
26+
await tester.tap(find.byIcon(Icons.language_outlined));
27+
await tester.pumpAndSettle();
28+
await tester.tap(find.text('繁體中文').last);
29+
await tester.pumpAndSettle();
30+
31+
expect(dependencies.localeController.isZhTw, isTrue);
32+
});
33+
}

test/presentation/browse/browse_pages_additional_test.dart

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:boxmatch/app/app.dart';
22
import 'package:boxmatch/app/app_scope.dart';
3+
import 'package:boxmatch/core/identity/recipient_identity_service.dart';
34
import 'package:boxmatch/features/surplus/data/in_memory_surplus_repository.dart';
45
import 'package:boxmatch/features/surplus/domain/listing.dart';
56
import 'package:boxmatch/features/surplus/domain/listing_input.dart';
@@ -19,6 +20,8 @@ class _InstrumentedRepository extends InMemorySurplusRepository {
1920
bool activeListingsStreamError = false;
2021
bool reservationStreamError = false;
2122
bool throwOnReserve = false;
23+
bool throwOnAbuseSignal = false;
24+
String? lastAbuseReason;
2225
int reconcileCalls = 0;
2326

2427
final Set<String> listingStreamErrorIds = <String>{};
@@ -88,6 +91,31 @@ class _InstrumentedRepository extends InMemorySurplusRepository {
8891
disclaimerAccepted: disclaimerAccepted,
8992
);
9093
}
94+
95+
@override
96+
Future<void> addAbuseSignal({
97+
required String listingId,
98+
required String claimerUid,
99+
required String reason,
100+
}) async {
101+
if (throwOnAbuseSignal) {
102+
throw const ValidationException('Abuse report failed for test.');
103+
}
104+
lastAbuseReason = reason;
105+
await super.addAbuseSignal(
106+
listingId: listingId,
107+
claimerUid: claimerUid,
108+
reason: reason,
109+
);
110+
}
111+
}
112+
113+
class _LocalFallbackIdentityService implements RecipientIdentityService {
114+
@override
115+
bool get isUsingLocalFallback => true;
116+
117+
@override
118+
Future<String> ensureRecipientUid() async => 'local-fallback-user';
91119
}
92120

93121
ListingInput _input(
@@ -194,8 +222,12 @@ Future<void> _pumpConfirmation(
194222
_InstrumentedRepository repo, {
195223
required String listingId,
196224
required String reservationId,
225+
RecipientIdentityService? identityService,
197226
}) async {
198-
final dependencies = await buildTestDependencies(repository: repo);
227+
final dependencies = await buildTestDependencies(
228+
repository: repo,
229+
identityService: identityService,
230+
);
199231
await tester.pumpWidget(
200232
AppScope(
201233
dependencies: dependencies,
@@ -511,4 +543,49 @@ void main() {
511543
expect(find.text('Cancelled'), findsWidgets);
512544
},
513545
);
546+
547+
testWidgets(
548+
'reservation confirmation shows local fallback hint and can go back',
549+
(tester) async {
550+
final now = DateTime.now();
551+
final repo = _InstrumentedRepository()
552+
..forcedListings['badge-listing'] = _forcedListing(
553+
now,
554+
id: 'badge-listing',
555+
status: ListingStatus.active,
556+
quantityRemaining: 1,
557+
expiresAt: now.add(const Duration(hours: 2)),
558+
displayNameOptional: 'Trusted Enterprise',
559+
enterpriseVerified: true,
560+
)
561+
..forcedListings['badge-listing-2'] = _forcedListing(
562+
now,
563+
id: 'badge-listing-2',
564+
status: ListingStatus.active,
565+
quantityRemaining: 1,
566+
expiresAt: now.add(const Duration(hours: 3)),
567+
displayNameOptional: 'Trusted Enterprise',
568+
enterpriseVerified: false,
569+
)
570+
..forcedReservations['badge-reservation'] = _forcedReservation(
571+
now,
572+
id: 'badge-reservation',
573+
listingId: 'badge-listing',
574+
status: ReservationStatus.reserved,
575+
);
576+
577+
await _pumpConfirmation(
578+
tester,
579+
repo,
580+
listingId: 'badge-listing',
581+
reservationId: 'badge-reservation',
582+
identityService: _LocalFallbackIdentityService(),
583+
);
584+
585+
expect(find.text('Using offline identity mode'), findsOneWidget);
586+
expect(find.text('1234'), findsOneWidget);
587+
expect(find.widgetWithText(OutlinedButton, 'Back to listings'), findsOneWidget);
588+
},
589+
);
590+
514591
}

test/presentation/browse/my_reservations_page_test.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,13 @@ void main() {
9797

9898
expect(find.text('Reservation cancelled.'), findsOneWidget);
9999
});
100+
101+
testWidgets('empty state shows browse CTA', (tester) async {
102+
final repo = InMemorySurplusRepository();
103+
104+
await _pumpPage(tester, repo: repo);
105+
106+
expect(find.text('No reservations yet.'), findsOneWidget);
107+
expect(find.widgetWithText(FilledButton, 'Browse listings'), findsOneWidget);
108+
});
100109
}

0 commit comments

Comments
 (0)