Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions lib/core/i18n/app_strings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,23 @@ class AppStrings {
String get offlineIdentityMode =>
_zh ? '使用離線身份模式' : 'Using offline identity mode';
String get reportSafetyConcern => _zh ? '回報風險事件' : 'Report safety concern';
String get reportRiskSelectReasonTitle =>
_zh ? '請選擇回報原因' : 'Select a reason';
String get riskReasonPrivateLocation => _zh
? '要求改到私下地點面交'
: 'Asked to move pickup to a private location';
String get riskReasonSuspiciousBehavior =>
_zh ? '現場行為可疑 / 騷擾' : 'Suspicious behavior / harassment';
String get riskReasonNoShow =>
_zh ? '公開取餐點無人交付' : 'No handoff at the public pickup point';
String get riskReasonUnsafeCondition =>
_zh ? '食物狀態疑似不安全' : 'Food condition appears unsafe';
String get riskReasonOther => _zh ? '其他風險' : 'Other risk';
String get abuseReported => _zh ? '已送出風險回報。' : 'Safety report submitted.';
String get verifiedEnterprise => _zh ? '已驗證企業' : 'Verified enterprise';
String get highImpactEnterprise => _zh ? '高量捐贈企業' : 'High-impact donor';
String get flexiblePickupEnterprise => _zh ? '彈性取餐時段' : 'Flexible pickup window';
String get stableShelfLifeEnterprise => _zh ? '保存時效較穩定' : 'Stable shelf-life setup';
String get pendingConfirm => _zh ? '待確認' : 'Pending';
String get confirmedFilter => _zh ? '已確認' : 'Confirmed';
String get showPickupCodeHelp => _zh
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,75 @@ class ReservationConfirmationPage extends StatelessWidget {
final String listingId;
final String reservationId;

Future<String?> _pickRiskReason(BuildContext context) async {
final s = AppStrings.of(context);
final reasons = <({String code, String label})>[
(
code: 'recipient_report_private_location_request',
label: s.riskReasonPrivateLocation,
),
(
code: 'recipient_report_suspicious_behavior',
label: s.riskReasonSuspiciousBehavior,
),
(
code: 'recipient_report_public_handoff_no_show',
label: s.riskReasonNoShow,
),
(
code: 'recipient_report_food_condition_unsafe',
label: s.riskReasonUnsafeCondition,
),
(code: 'recipient_report_other', label: s.riskReasonOther),
];

return showDialog<String>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(s.reportRiskSelectReasonTitle),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: reasons
.map(
(reason) => ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text(reason.label),
onTap: () => Navigator.of(context).pop(reason.code),
),
)
.toList(),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(s.cancel),
),
],
);
},
);
}

Future<void> _reportAbuse(BuildContext context) async {
final deps = AppScope.of(context);
final s = AppStrings.of(context);
final reason = await _pickRiskReason(context);
if (reason == null || reason.isEmpty || !context.mounted) {
return;
}
try {
final uid = await deps.identityService.ensureRecipientUid();
await deps.repository.addAbuseSignal(
listingId: listingId,
claimerUid: uid,
reason: 'recipient_report_private_location_request',
reason: reason,
);
if (!context.mounted) {
return;
Expand All @@ -46,6 +106,32 @@ class ReservationConfirmationPage extends StatelessWidget {
}
}

List<({IconData icon, String label})> _enterpriseBadges({
required Listing listing,
required AppStrings s,
required bool isFrequentEnterprise,
}) {
final badges = <({IconData icon, String label})>[];
if (listing.enterpriseVerified) {
badges.add((icon: Icons.verified, label: s.verifiedEnterprise));
}
if (isFrequentEnterprise) {
badges.add((icon: Icons.eco_outlined, label: s.frequentEnterprise));
}
if (listing.quantityTotal >= 20) {
badges.add((icon: Icons.workspace_premium_outlined, label: s.highImpactEnterprise));
}
if (listing.pickupEndAt.difference(listing.pickupStartAt) >=
const Duration(minutes: 120)) {
badges.add((icon: Icons.schedule_outlined, label: s.flexiblePickupEnterprise));
}
if (listing.expiresAt.difference(listing.pickupStartAt) >=
const Duration(minutes: 120)) {
badges.add((icon: Icons.inventory_2_outlined, label: s.stableShelfLifeEnterprise));
}
return badges;
}

@override
Widget build(BuildContext context) {
final dependencies = AppScope.of(context);
Expand Down Expand Up @@ -221,8 +307,11 @@ class ReservationConfirmationPage extends StatelessWidget {
Text(
'Enterprise: ${listing.displayNameOptional}',
),
if (listing.enterpriseVerified ||
isFrequentEnterprise)
if (_enterpriseBadges(
listing: listing,
s: s,
isFrequentEnterprise: isFrequentEnterprise,
).isNotEmpty)
Padding(
padding: const EdgeInsets.only(
top: 6,
Expand All @@ -231,26 +320,23 @@ class ReservationConfirmationPage extends StatelessWidget {
child: Wrap(
spacing: 8,
runSpacing: 6,
children: [
if (listing.enterpriseVerified)
Chip(
avatar: const Icon(
Icons.verified,
size: 16,
color: Color(0xFF2D6A4F),
),
label: Text(s.verifiedEnterprise),
),
if (isFrequentEnterprise)
Chip(
avatar: const Icon(
Icons.eco_outlined,
size: 16,
color: Color(0xFF2D6A4F),
children: _enterpriseBadges(
listing: listing,
s: s,
isFrequentEnterprise:
isFrequentEnterprise,
)
.map(
(badge) => Chip(
avatar: Icon(
badge.icon,
size: 16,
color: const Color(0xFF2D6A4F),
),
label: Text(badge.label),
),
label: Text(s.frequentEnterprise),
),
],
)
.toList(),
),
),
Text(
Expand Down
9 changes: 9 additions & 0 deletions test/core/app_strings_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ void main() {
expect(strings.privacyFaqTitle, 'Privacy & FAQ');
expect(strings.privacyNotice, contains('Boxmatch'));
expect(strings.faqNotice, contains('Report safety concern'));
expect(strings.reportRiskSelectReasonTitle, 'Select a reason');
expect(strings.riskReasonSuspiciousBehavior, contains('Suspicious'));
expect(strings.highImpactEnterprise, 'High-impact donor');
expect(strings.flexiblePickupEnterprise, contains('Flexible'));
expect(strings.stableShelfLifeEnterprise, contains('Stable'));
});

testWidgets('app strings returns zh-TW labels', (tester) async {
Expand All @@ -48,6 +53,8 @@ void main() {
Text(strings.navMap),
Text(strings.frequentEnterprise),
Text(strings.privacyFaqTitle),
Text(strings.reportRiskSelectReasonTitle),
Text(strings.highImpactEnterprise),
],
);
},
Expand All @@ -59,5 +66,7 @@ void main() {
expect(find.text('地圖'), findsOneWidget);
expect(find.text('常態捐贈企業'), findsOneWidget);
expect(find.text('隱私與常見問題'), findsOneWidget);
expect(find.text('請選擇回報原因'), findsOneWidget);
expect(find.text('高量捐贈企業'), findsOneWidget);
});
}
33 changes: 33 additions & 0 deletions test/core/language_menu_button_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import 'package:boxmatch/app/app_scope.dart';
import 'package:boxmatch/core/i18n/language_menu_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

import '../test_helpers.dart';

void main() {
testWidgets('language menu button switches locale label', (tester) async {
final dependencies = await buildTestDependencies(language: 'en');

await tester.pumpWidget(
AppScope(
dependencies: dependencies,
child: MaterialApp(
home: Scaffold(
appBar: AppBar(actions: const [LanguageMenuButton()]),
),
),
),
);
await tester.pumpAndSettle();

expect(find.text('EN'), findsOneWidget);

await tester.tap(find.byIcon(Icons.language_outlined));
await tester.pumpAndSettle();
await tester.tap(find.text('繁體中文').last);
await tester.pumpAndSettle();

expect(dependencies.localeController.isZhTw, isTrue);
});
}
79 changes: 78 additions & 1 deletion test/presentation/browse/browse_pages_additional_test.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:boxmatch/app/app.dart';
import 'package:boxmatch/app/app_scope.dart';
import 'package:boxmatch/core/identity/recipient_identity_service.dart';
import 'package:boxmatch/features/surplus/data/in_memory_surplus_repository.dart';
import 'package:boxmatch/features/surplus/domain/listing.dart';
import 'package:boxmatch/features/surplus/domain/listing_input.dart';
Expand All @@ -19,6 +20,8 @@ class _InstrumentedRepository extends InMemorySurplusRepository {
bool activeListingsStreamError = false;
bool reservationStreamError = false;
bool throwOnReserve = false;
bool throwOnAbuseSignal = false;
String? lastAbuseReason;
int reconcileCalls = 0;

final Set<String> listingStreamErrorIds = <String>{};
Expand Down Expand Up @@ -88,6 +91,31 @@ class _InstrumentedRepository extends InMemorySurplusRepository {
disclaimerAccepted: disclaimerAccepted,
);
}

@override
Future<void> addAbuseSignal({
required String listingId,
required String claimerUid,
required String reason,
}) async {
if (throwOnAbuseSignal) {
throw const ValidationException('Abuse report failed for test.');
}
lastAbuseReason = reason;
await super.addAbuseSignal(
listingId: listingId,
claimerUid: claimerUid,
reason: reason,
);
}
}

class _LocalFallbackIdentityService implements RecipientIdentityService {
@override
bool get isUsingLocalFallback => true;

@override
Future<String> ensureRecipientUid() async => 'local-fallback-user';
}

ListingInput _input(
Expand Down Expand Up @@ -194,8 +222,12 @@ Future<void> _pumpConfirmation(
_InstrumentedRepository repo, {
required String listingId,
required String reservationId,
RecipientIdentityService? identityService,
}) async {
final dependencies = await buildTestDependencies(repository: repo);
final dependencies = await buildTestDependencies(
repository: repo,
identityService: identityService,
);
await tester.pumpWidget(
AppScope(
dependencies: dependencies,
Expand Down Expand Up @@ -511,4 +543,49 @@ void main() {
expect(find.text('Cancelled'), findsWidgets);
},
);

testWidgets(
'reservation confirmation shows local fallback hint and can go back',
(tester) async {
final now = DateTime.now();
final repo = _InstrumentedRepository()
..forcedListings['badge-listing'] = _forcedListing(
now,
id: 'badge-listing',
status: ListingStatus.active,
quantityRemaining: 1,
expiresAt: now.add(const Duration(hours: 2)),
displayNameOptional: 'Trusted Enterprise',
enterpriseVerified: true,
)
..forcedListings['badge-listing-2'] = _forcedListing(
now,
id: 'badge-listing-2',
status: ListingStatus.active,
quantityRemaining: 1,
expiresAt: now.add(const Duration(hours: 3)),
displayNameOptional: 'Trusted Enterprise',
enterpriseVerified: false,
)
..forcedReservations['badge-reservation'] = _forcedReservation(
now,
id: 'badge-reservation',
listingId: 'badge-listing',
status: ReservationStatus.reserved,
);

await _pumpConfirmation(
tester,
repo,
listingId: 'badge-listing',
reservationId: 'badge-reservation',
identityService: _LocalFallbackIdentityService(),
);

expect(find.text('Using offline identity mode'), findsOneWidget);
expect(find.text('1234'), findsOneWidget);
expect(find.widgetWithText(OutlinedButton, 'Back to listings'), findsOneWidget);
},
);

}
9 changes: 9 additions & 0 deletions test/presentation/browse/my_reservations_page_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,13 @@ void main() {

expect(find.text('Reservation cancelled.'), findsOneWidget);
});

testWidgets('empty state shows browse CTA', (tester) async {
final repo = InMemorySurplusRepository();

await _pumpPage(tester, repo: repo);

expect(find.text('No reservations yet.'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Browse listings'), findsOneWidget);
});
}
Loading
Loading