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
62 changes: 45 additions & 17 deletions docs/ops/verified_enterprises.seed.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,46 @@
[
{
"aliasNormalized": "acme booth a",
"venueId": "taipei-nangang-exhibition-center-hall-1",
"active": true,
"reviewedBy": "ops-leo",
"reviewedAt": "2026-04-02T00:00:00.000Z",
"notes": "manual verification passed"
},
{
"aliasNormalized": "demo drinks co",
"venueId": "taipei-nangang-exhibition-center-hall-2",
"active": true,
"reviewedBy": "ops-leo",
"reviewedAt": "2026-04-02T00:00:00.000Z",
"notes": "event organizer confirmation"
{
"verifiedEnterprises": [
{
"aliasNormalized": "acme booth a",
"venueId": "taipei-nangang-exhibition-center-hall-1",
"active": true,
"reviewedBy": "ops-leo",
"reviewedAt": "2026-04-02T00:00:00.000Z",
"notes": "manual verification passed"
},
{
"aliasNormalized": "demo drinks co",
"venueId": "taipei-nangang-exhibition-center-hall-2",
"active": true,
"reviewedBy": "ops-leo",
"reviewedAt": "2026-04-02T00:00:00.000Z",
"notes": "event organizer confirmation"
}
],
"badgeRules": {
"enabled": true,
"rules": {
"verified": {
"enabled": true
},
"quality_trusted": {
"enabled": true,
"minCompletedRate": 0.8,
"maxCancelledRate": 0.2,
"minReservations": 5
},
"high_impact": {
"enabled": true,
"minQuantityTotal": 20
},
"flexible_pickup": {
"enabled": true,
"minPickupWindowMinutes": 120
},
"stable_shelf_life": {
"enabled": true,
"minExpiryAfterPickupStartMinutes": 120
}
}
}
]
}
13 changes: 13 additions & 0 deletions lib/app/app_bootstrap.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer' as developer;

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';

import '../core/identity/firebase_recipient_identity_service.dart';
Expand Down Expand Up @@ -45,6 +47,7 @@ Future<AppDependencies> bootstrapApp() async {
},
);
await repository.ensureSeedData();
unawaited(_warmUpApi(apiBaseUrl));

final identity = FirebaseRecipientIdentityService(
FirebaseAuth.instance,
Expand Down Expand Up @@ -83,3 +86,13 @@ Future<AppDependencies> bootstrapApp() async {
);
}
}

Future<void> _warmUpApi(String apiBaseUrl) async {
final normalized = apiBaseUrl.replaceAll(RegExp(r'/+$'), '');
final uri = Uri.parse('$normalized/health');
try {
await http.get(uri).timeout(const Duration(seconds: 6));
} catch (_) {
// Best-effort warm-up only.
}
}
18 changes: 18 additions & 0 deletions lib/core/i18n/app_strings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ class AppStrings {
String get riskReasonOther => _zh ? '其他風險' : 'Other risk';
String get abuseReported => _zh ? '已送出風險回報。' : 'Safety report submitted.';
String get verifiedEnterprise => _zh ? '已驗證企業' : 'Verified enterprise';
String get trustedQualityEnterprise => _zh ? '交付品質穩定' : 'Trusted handoff quality';
String get highImpactEnterprise => _zh ? '高量捐贈企業' : 'High-impact donor';
String get flexiblePickupEnterprise => _zh ? '彈性取餐時段' : 'Flexible pickup window';
String get stableShelfLifeEnterprise => _zh ? '保存時效較穩定' : 'Stable shelf-life setup';
Expand Down Expand Up @@ -122,4 +123,21 @@ class AppStrings {
return _zh ? '已取消' : 'Cancelled';
}
}

String? enterpriseBadgeLabel(String badgeId) {
switch (badgeId) {
case 'verified':
return verifiedEnterprise;
case 'quality_trusted':
return trustedQualityEnterprise;
case 'high_impact':
return highImpactEnterprise;
case 'flexible_pickup':
return flexiblePickupEnterprise;
case 'stable_shelf_life':
return stableShelfLifeEnterprise;
default:
return null;
}
}
}
9 changes: 9 additions & 0 deletions lib/features/surplus/domain/listing.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ class Listing {
required this.displayNameOptional,
this.templateId,
this.enterpriseVerified = false,
this.enterpriseBadges = const <String>[],
required this.visibility,
required this.status,
required this.editTokenHash,
Expand All @@ -73,6 +74,7 @@ class Listing {
final String? displayNameOptional;
final String? templateId;
final bool enterpriseVerified;
final List<String> enterpriseBadges;
final ListingVisibility visibility;
final ListingStatus status;
final String editTokenHash;
Expand Down Expand Up @@ -116,6 +118,7 @@ class Listing {
String? displayNameOptional,
String? templateId,
bool? enterpriseVerified,
List<String>? enterpriseBadges,
ListingVisibility? visibility,
ListingStatus? status,
String? editTokenHash,
Expand All @@ -138,6 +141,7 @@ class Listing {
displayNameOptional: displayNameOptional ?? this.displayNameOptional,
templateId: templateId ?? this.templateId,
enterpriseVerified: enterpriseVerified ?? this.enterpriseVerified,
enterpriseBadges: enterpriseBadges ?? this.enterpriseBadges,
visibility: visibility ?? this.visibility,
status: status ?? this.status,
editTokenHash: editTokenHash ?? this.editTokenHash,
Expand All @@ -162,6 +166,7 @@ class Listing {
'displayNameOptional': displayNameOptional,
'templateId': templateId,
'enterpriseVerified': enterpriseVerified,
'enterpriseBadges': enterpriseBadges,
'visibility': visibility.name,
'status': status.name,
'editTokenHash': editTokenHash,
Expand All @@ -187,6 +192,10 @@ class Listing {
displayNameOptional: map['displayNameOptional'] as String?,
templateId: map['templateId'] as String?,
enterpriseVerified: map['enterpriseVerified'] == true,
enterpriseBadges: (map['enterpriseBadges'] as List?)
?.whereType<String>()
.toList() ??
const <String>[],
visibility: ListingVisibilityX.fromName(map['visibility'] as String?),
status: ListingStatusX.fromName(map['status'] as String?),
editTokenHash: map['editTokenHash'] as String? ?? '',
Expand Down
26 changes: 18 additions & 8 deletions lib/features/surplus/presentation/browse/listing_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -213,15 +213,25 @@ class _ListingDetailPageState extends State<ListingDetailPage> {
Text(
'Donor: ${listing.displayNameOptional?.trim().isNotEmpty == true ? listing.displayNameOptional : s.privateDonor}',
),
if (listing.enterpriseVerified) ...[
if (listing.enterpriseBadges.isNotEmpty) ...[
const SizedBox(height: 6),
Chip(
avatar: const Icon(
Icons.verified,
size: 16,
color: Color(0xFF2D6A4F),
),
label: Text(s.verifiedEnterprise),
Wrap(
spacing: 6,
runSpacing: 6,
children: listing.enterpriseBadges
.map(s.enterpriseBadgeLabel)
.whereType<String>()
.map(
(label) => Chip(
avatar: const Icon(
Icons.verified,
size: 16,
color: Color(0xFF2D6A4F),
),
label: Text(label),
),
)
.toList(),
),
],
const SizedBox(height: 8),
Expand Down
15 changes: 11 additions & 4 deletions lib/features/surplus/presentation/browse/listings_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -316,9 +316,16 @@ class _ListingsPageState extends State<ListingsPage> {
final isFavorite = favoritesStore.isFavorite(
listing.venueId,
);
final verifiedTag = listing.enterpriseVerified
? ' · ${s.verifiedEnterprise}'
: '';
final badgeLabels = listing.enterpriseBadges
.map(s.enterpriseBadgeLabel)
.whereType<String>()
.toList();
final badgeTag = badgeLabels.isEmpty
? null
: badgeLabels.first;
final donorSuffix = badgeTag == null
? ''
: ' · $badgeTag';

return Card(
child: ListTile(
Expand All @@ -328,7 +335,7 @@ class _ListingsPageState extends State<ListingsPage> {
subtitle: Text(
'${venue?.name ?? 'Venue'}\n'
'Pickup: ${formatDateTime(listing.pickupStartAt)} - ${formatDateTime(listing.pickupEndAt)}\n'
'By: $donorName$verifiedTag',
'By: $donorName$donorSuffix',
),
isThreeLine: true,
trailing: SizedBox(
Expand Down
57 changes: 34 additions & 23 deletions lib/features/surplus/presentation/browse/my_reservations_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class MyReservationsPage extends StatefulWidget {
}

class _MyReservationsPageState extends State<MyReservationsPage> {
static const Duration _loadRetryDelay = Duration(milliseconds: 700);
Future<String>? _uidFuture;
Future<List<_ReservationWithListing>>? _loadFuture;

Expand All @@ -39,6 +40,18 @@ class _MyReservationsPageState extends State<MyReservationsPage> {
}

Future<List<_ReservationWithListing>> _loadReservations() async {
try {
return await _loadReservationsOnce();
} on SurplusException {
await Future<void>.delayed(_loadRetryDelay);
return _loadReservationsOnce();
} catch (_) {
await Future<void>.delayed(_loadRetryDelay);
return _loadReservationsOnce();
}
}

Future<List<_ReservationWithListing>> _loadReservationsOnce() async {
final deps = AppScope.of(context);
final uid = await (_uidFuture ?? deps.identityService.ensureRecipientUid());
final reservations = await deps.repository.listRecipientReservations(
Expand Down Expand Up @@ -165,16 +178,6 @@ class _MyReservationsPageState extends State<MyReservationsPage> {
);
}

final aliasFrequency = <String, int>{};
for (final item in items) {
final alias = item.listing?.displayNameOptional?.trim() ?? '';
if (alias.isEmpty) {
continue;
}
final normalized = alias.toLowerCase();
aliasFrequency[normalized] = (aliasFrequency[normalized] ?? 0) + 1;
}

return ListView(
padding: const EdgeInsets.all(12),
children: [
Expand Down Expand Up @@ -207,10 +210,10 @@ class _MyReservationsPageState extends State<MyReservationsPage> {
...items.map((item) {
final reservation = item.reservation;
final listing = item.listing;
final alias = listing?.displayNameOptional?.trim() ?? '';
final isFrequentEnterprise =
alias.isNotEmpty &&
(aliasFrequency[alias.toLowerCase()] ?? 0) >= 2;
final badgeLabels = (listing?.enterpriseBadges ?? const <String>[])
.map(s.enterpriseBadgeLabel)
.whereType<String>()
.toList();
final statusLabel = s.statusLabel(switch (reservation.status) {
ReservationStatus.reserved => AppStatusLabel.reserved,
ReservationStatus.completed => AppStatusLabel.completed,
Expand Down Expand Up @@ -255,16 +258,24 @@ class _MyReservationsPageState extends State<MyReservationsPage> {
'Pickup: ${listing?.pickupPointText ?? '-'}\n'
'Expires: ${formatDateTime(reservation.expiresAt)}',
),
if (isFrequentEnterprise) ...[
if (badgeLabels.isNotEmpty) ...[
const SizedBox(height: 6),
Chip(
visualDensity: VisualDensity.compact,
avatar: const Icon(
Icons.verified,
size: 16,
color: Color(0xFF2D6A4F),
),
label: Text(s.frequentEnterprise),
Wrap(
spacing: 6,
runSpacing: 6,
children: badgeLabels
.map(
(label) => Chip(
visualDensity: VisualDensity.compact,
avatar: const Icon(
Icons.verified,
size: 16,
color: Color(0xFF2D6A4F),
),
label: Text(label),
),
)
.toList(),
),
],
],
Expand Down
Loading
Loading