From 8939a9b96b30d3f9d4f93335a50b7e4ca6026e59 Mon Sep 17 00:00:00 2001 From: TachibanaLolo Date: Fri, 23 Jan 2026 09:54:57 +0800 Subject: [PATCH 01/12] feat: detect Alipay payment notifications --- .../android/app/src/main/AndroidManifest.xml | 8 + .../kotlin/com/secgo/kiosk/MainActivity.kt | 185 +++++++++++++- .../kiosk/SecgoNotificationListenerService.kt | 127 ++++++++++ Kiosk/integration_test/app_test.dart | 115 --------- Kiosk/lib/db/database_helper.dart | 77 +++++- Kiosk/lib/models/order.dart | 35 +++ Kiosk/lib/screens/main_screen.dart | 118 ++++++++- Kiosk/lib/screens/payment_screen.dart | 76 ++++++ .../alipay_payment_watch_service.dart | 239 ++++++++++++++++++ ...android_notification_listener_service.dart | 76 ++++++ Kiosk/lib/services/server/kiosk_server.dart | 33 +++ Kiosk/test/alipay_amount_parser_test.dart | 28 ++ Manager/integration_test/app_test.dart | 163 ------------ .../services/kiosk_client/kiosk_client.dart | 50 ++++ .../services/kiosk_connection_service.dart | 12 + Manager/pubspec.yaml | 2 - 16 files changed, 1059 insertions(+), 285 deletions(-) create mode 100644 Kiosk/android/app/src/main/kotlin/com/secgo/kiosk/SecgoNotificationListenerService.kt delete mode 100644 Kiosk/integration_test/app_test.dart create mode 100644 Kiosk/lib/services/alipay_payment_watch_service.dart create mode 100644 Kiosk/lib/services/android_notification_listener_service.dart create mode 100644 Kiosk/test/alipay_amount_parser_test.dart delete mode 100644 Manager/integration_test/app_test.dart diff --git a/Kiosk/android/app/src/main/AndroidManifest.xml b/Kiosk/android/app/src/main/AndroidManifest.xml index 7937524..8748df4 100644 --- a/Kiosk/android/app/src/main/AndroidManifest.xml +++ b/Kiosk/android/app/src/main/AndroidManifest.xml @@ -7,6 +7,14 @@ android:label="kiosk" android:name="${applicationName}" android:icon="@mipmap/ic_launcher"> + + + + + + when (call.method) { + "isNotificationListenerEnabled" -> result.success(isNotificationListenerEnabled()) + "getAlipayNotificationState" -> result.success(getAlipayNotificationState()) + "getLatestAlipayNotification" -> result.success(getLatestAlipayNotification()) + "getLatestAlipayPaymentNotification" -> result.success(getLatestAlipayPaymentNotification()) + "getActiveAlipayNotificationsSnapshot" -> result.success(getActiveAlipayNotificationsSnapshot()) + "openNotificationListenerSettings" -> { + startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + result.success(true) + } + else -> result.notImplemented() + } + } + } + + override fun onCreate(savedInstanceState: android.os.Bundle?) { + super.onCreate(savedInstanceState) + + receiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + SecgoNotificationListenerService.ACTION_NOTIFICATION_STATE -> { + val hasAlipay = intent.getBooleanExtra(SecgoNotificationListenerService.KEY_HAS_ALIPAY, false) + val updatedAt = intent.getLongExtra(SecgoNotificationListenerService.KEY_UPDATED_AT_MS, 0L) + val latestAlipayJson = intent.getStringExtra(SecgoNotificationListenerService.KEY_LAST_ALIPAY_JSON) + val latestAlipayPaymentJson = + intent.getStringExtra(SecgoNotificationListenerService.KEY_LAST_ALIPAY_PAYMENT_JSON) + eventSink?.success( + mapOf( + "type" to "state", + "hasAlipay" to hasAlipay, + "updatedAtMs" to updatedAt, + "alipay" to (parseJsonToMap(latestAlipayJson) ?: emptyMap()), + "alipayPayment" to (parseJsonToMap(latestAlipayPaymentJson) ?: emptyMap()), + ), + ) + } + SecgoNotificationListenerService.ACTION_NOTIFICATION_POSTED -> { + val postedJson = intent.getStringExtra(SecgoNotificationListenerService.KEY_POSTED_JSON) + Log.i("SecgoNotif", "ACTION_NOTIFICATION_POSTED eventSink=${eventSink != null} postedJson=${postedJson != null}") + eventSink?.success( + mapOf( + "type" to "posted", + "notification" to (parseJsonToMap(postedJson) ?: emptyMap()), + ), + ) + } + else -> { + } + } + } + } + + val filter = + IntentFilter().apply { + addAction(SecgoNotificationListenerService.ACTION_NOTIFICATION_STATE) + addAction(SecgoNotificationListenerService.ACTION_NOTIFICATION_POSTED) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + @Suppress("DEPRECATION") + registerReceiver(receiver, filter) + } + } + + override fun onDestroy() { + receiver?.let { + try { + unregisterReceiver(it) + } catch (_: Exception) { + } + } + receiver = null + super.onDestroy() + } + + private fun isNotificationListenerEnabled(): Boolean { + val enabled = Settings.Secure.getString(contentResolver, "enabled_notification_listeners") ?: return false + return enabled.contains(packageName) + } + + private fun getAlipayNotificationState(): Map { + val prefs = getSharedPreferences(SecgoNotificationListenerService.PREFS_NAME, Context.MODE_PRIVATE) + val hasAlipay = prefs.getBoolean(SecgoNotificationListenerService.KEY_HAS_ALIPAY, false) + val updatedAt = prefs.getLong(SecgoNotificationListenerService.KEY_UPDATED_AT_MS, 0L) + return mapOf( + "enabled" to isNotificationListenerEnabled(), + "hasAlipay" to hasAlipay, + "updatedAtMs" to updatedAt, + ) + } + + private fun getLatestAlipayNotification(): Map? { + val prefs = getSharedPreferences(SecgoNotificationListenerService.PREFS_NAME, Context.MODE_PRIVATE) + val json = prefs.getString(SecgoNotificationListenerService.KEY_LAST_ALIPAY_JSON, null) ?: return null + return parseJsonToMap(json) + } + + private fun getLatestAlipayPaymentNotification(): Map? { + val prefs = getSharedPreferences(SecgoNotificationListenerService.PREFS_NAME, Context.MODE_PRIVATE) + val json = prefs.getString(SecgoNotificationListenerService.KEY_LAST_ALIPAY_PAYMENT_JSON, null) ?: return null + return parseJsonToMap(json) + } + + private fun getActiveAlipayNotificationsSnapshot(): List> { + val prefs = getSharedPreferences(SecgoNotificationListenerService.PREFS_NAME, Context.MODE_PRIVATE) + val json = prefs.getString(SecgoNotificationListenerService.KEY_ACTIVE_ALIPAY_SNAPSHOT_JSON, null) ?: return emptyList() + return parseJsonToListOfMaps(json) + } + + private fun parseJsonToMap(json: String?): Map? { + if (json == null || json.isBlank()) return null + return try { + val obj = JSONObject(json) + val map = mutableMapOf() + val it = obj.keys() + while (it.hasNext()) { + val k = it.next() + val v = obj.opt(k) + if (v == null || v == JSONObject.NULL) continue + map[k] = v + } + map + } catch (_: Exception) { + null + } + } + + private fun parseJsonToListOfMaps(json: String?): List> { + if (json == null || json.isBlank()) return emptyList() + return try { + val arr = JSONArray(json) + val result = mutableListOf>() + for (i in 0 until arr.length()) { + val obj = arr.optJSONObject(i) ?: continue + result.add(parseJsonToMap(obj.toString()) ?: continue) + } + result + } catch (_: Exception) { + emptyList() + } + } + + companion object { + private const val METHOD_CHANNEL = "com.secgo.kiosk/notification_listener" + private const val EVENTS_CHANNEL = "com.secgo.kiosk/notifications" + + @Volatile + private var eventSink: EventChannel.EventSink? = null + } +} diff --git a/Kiosk/android/app/src/main/kotlin/com/secgo/kiosk/SecgoNotificationListenerService.kt b/Kiosk/android/app/src/main/kotlin/com/secgo/kiosk/SecgoNotificationListenerService.kt new file mode 100644 index 0000000..4b36a0e --- /dev/null +++ b/Kiosk/android/app/src/main/kotlin/com/secgo/kiosk/SecgoNotificationListenerService.kt @@ -0,0 +1,127 @@ +package com.secgo.kiosk + +import android.app.Notification +import android.content.Context +import android.content.Intent +import android.service.notification.NotificationListenerService +import android.service.notification.StatusBarNotification +import org.json.JSONArray +import org.json.JSONObject + +class SecgoNotificationListenerService : NotificationListenerService() { + override fun onListenerConnected() { + super.onListenerConnected() + updateState() + } + + override fun onNotificationPosted(sbn: StatusBarNotification) { + if (sbn.packageName == ALIPAY_PACKAGE) { + sendBroadcast( + Intent(ACTION_NOTIFICATION_POSTED).apply { + putExtra(KEY_POSTED_JSON, buildNotificationJson(sbn).toString()) + }, + ) + } + updateState() + } + + override fun onNotificationRemoved(sbn: StatusBarNotification) { + updateState() + } + + private fun updateState() { + val hasAlipay = activeNotifications?.any { it.packageName == ALIPAY_PACKAGE } ?: false + val latestAlipay = getLatestAlipay(activeNotifications) + val latestAlipayPayment = getLatestAlipayPayment(activeNotifications) + val activeSnapshot = buildActiveAlipaySnapshot(activeNotifications) + val latestAlipayJson = latestAlipay?.let { buildNotificationJson(it).toString() } + val latestAlipayPaymentJson = latestAlipayPayment?.let { buildNotificationJson(it).toString() } + val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit() + .putBoolean(KEY_HAS_ALIPAY, hasAlipay) + .putString(KEY_LAST_ALIPAY_JSON, latestAlipayJson) + .putString(KEY_LAST_ALIPAY_PAYMENT_JSON, latestAlipayPaymentJson) + .putString(KEY_ACTIVE_ALIPAY_SNAPSHOT_JSON, activeSnapshot) + .putLong(KEY_UPDATED_AT_MS, System.currentTimeMillis()) + .apply() + + sendBroadcast( + Intent(ACTION_NOTIFICATION_STATE).apply { + putExtra(KEY_HAS_ALIPAY, hasAlipay) + putExtra(KEY_LAST_ALIPAY_JSON, latestAlipayJson) + putExtra(KEY_LAST_ALIPAY_PAYMENT_JSON, latestAlipayPaymentJson) + putExtra(KEY_ACTIVE_ALIPAY_SNAPSHOT_JSON, activeSnapshot) + putExtra(KEY_UPDATED_AT_MS, System.currentTimeMillis()) + }, + ) + } + + private fun getLatestAlipay(notifications: Array?): StatusBarNotification? { + if (notifications == null || notifications.isEmpty()) return null + return notifications + .filter { it.packageName == ALIPAY_PACKAGE } + .maxByOrNull { it.postTime } + } + + private fun getLatestAlipayPayment(notifications: Array?): StatusBarNotification? { + if (notifications == null || notifications.isEmpty()) return null + return notifications + .filter { it.packageName == ALIPAY_PACKAGE } + .filter { isPaymentLike(it) } + .maxByOrNull { it.postTime } + } + + private fun isPaymentLike(sbn: StatusBarNotification): Boolean { + val extras = sbn.notification.extras + val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() ?: "" + val text = extras.getCharSequence(Notification.EXTRA_TEXT)?.toString() ?: "" + val bigText = extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString() ?: "" + val combined = "$title $text $bigText" + if (combined.contains("成功收款")) return true + if (combined.contains("收款") && combined.contains("元")) return true + return false + } + + private fun buildNotificationJson(sbn: StatusBarNotification): JSONObject { + val n = sbn.notification + val extras = n.extras + val json = + JSONObject() + .put("package", sbn.packageName) + .put("key", sbn.key) + .put("id", sbn.id) + .put("channelId", n.channelId ?: JSONObject.NULL) + .put("postTime", sbn.postTime) + .put("when", n.`when`) + .put("category", n.category ?: JSONObject.NULL) + .put("title", extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() ?: JSONObject.NULL) + .put("text", extras.getCharSequence(Notification.EXTRA_TEXT)?.toString() ?: JSONObject.NULL) + .put("subText", extras.getCharSequence(Notification.EXTRA_SUB_TEXT)?.toString() ?: JSONObject.NULL) + .put("bigText", extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString() ?: JSONObject.NULL) + .put("infoText", extras.getCharSequence(Notification.EXTRA_INFO_TEXT)?.toString() ?: JSONObject.NULL) + return json + } + + private fun buildActiveAlipaySnapshot(notifications: Array?): String { + val list = JSONArray() + if (notifications == null || notifications.isEmpty()) return list.toString() + notifications + .filter { it.packageName == ALIPAY_PACKAGE } + .sortedByDescending { it.postTime } + .forEach { list.put(buildNotificationJson(it)) } + return list.toString() + } + + companion object { + const val ACTION_NOTIFICATION_STATE = "com.secgo.kiosk.NOTIFICATION_STATE" + const val ACTION_NOTIFICATION_POSTED = "com.secgo.kiosk.NOTIFICATION_POSTED" + const val PREFS_NAME = "secgo_notification_listener" + const val KEY_HAS_ALIPAY = "has_alipay" + const val KEY_LAST_ALIPAY_JSON = "last_alipay_json" + const val KEY_LAST_ALIPAY_PAYMENT_JSON = "last_alipay_payment_json" + const val KEY_ACTIVE_ALIPAY_SNAPSHOT_JSON = "active_alipay_snapshot_json" + const val KEY_POSTED_JSON = "posted_json" + const val KEY_UPDATED_AT_MS = "updated_at_ms" + const val ALIPAY_PACKAGE = "com.eg.android.AlipayGphone" + } +} diff --git a/Kiosk/integration_test/app_test.dart b/Kiosk/integration_test/app_test.dart deleted file mode 100644 index fcb4f86..0000000 --- a/Kiosk/integration_test/app_test.dart +++ /dev/null @@ -1,115 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:hive_flutter/hive_flutter.dart'; -import 'package:integration_test/integration_test.dart'; -import 'package:kiosk/main.dart'; -import 'package:kiosk/models/product.dart'; -import 'package:kiosk/screens/main_screen.dart'; -import 'package:kiosk/screens/pin_setup_screen.dart'; -import 'package:kiosk/services/settings_service.dart'; -import 'package:kiosk/l10n/app_localizations.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - late SettingsService settingsService; - - setUpAll(() async { - await Hive.initFlutter(); - if (!Hive.isAdapterRegistered(0)) { - Hive.registerAdapter(ProductAdapter()); - } - try { - await dotenv.load(fileName: '.env'); - } catch (e) { - debugPrint('Warning: .env not loaded: $e'); - } - settingsService = SettingsService(); - await settingsService.init(); - }); - - testWidgets('Kiosk App Start Test - Verify Initial Screen', (WidgetTester tester) async { - // Launch App - await tester.pumpWidget(KioskApp(hasPin: settingsService.hasPin())); - await tester.pumpAndSettle(); - - // Verify Screen - Either PinSetup or MainScreen should be visible - final pinSetupFinder = find.byType(PinSetupScreen); - final mainScreenFinder = find.byType(MainScreen); - - final hasPinSetup = tester.widgetList(pinSetupFinder).isNotEmpty; - final hasMainScreen = tester.widgetList(mainScreenFinder).isNotEmpty; - - expect(hasPinSetup || hasMainScreen, true, - reason: "Either PinSetupScreen or MainScreen should be visible"); - - if (hasPinSetup) { - final l10n = AppLocalizations.of(tester.element(pinSetupFinder))!; - expect(find.text(l10n.setAdminPin), findsWidgets); - } else { - final l10n = AppLocalizations.of(tester.element(mainScreenFinder))!; - expect(find.text(l10n.checkout), findsOneWidget); - } - }); - - testWidgets('Kiosk PIN Setup Flow Test', (WidgetTester tester) async { - // Clear PIN to force PinSetupScreen - SharedPreferences.setMockInitialValues({}); - final freshSettingsService = SettingsService(); - await freshSettingsService.init(); - - // Launch App without PIN - await tester.pumpWidget(KioskApp(hasPin: false)); - await tester.pumpAndSettle(); - - // Verify PinSetupScreen is shown - expect(find.byType(PinSetupScreen), findsOneWidget); - - // Find PIN input fields - final textFields = find.byType(TextFormField); - expect(textFields, findsNWidgets(2)); // PIN and Confirm PIN - - // Enter PIN - await tester.enterText(textFields.first, '1234'); - await tester.pumpAndSettle(); - - // Enter Confirm PIN - await tester.enterText(textFields.last, '1234'); - await tester.pumpAndSettle(); - - // Find and tap save button - final saveButton = find.byType(ElevatedButton); - expect(saveButton, findsOneWidget); - - await tester.tap(saveButton); - await tester.pumpAndSettle(); - - // After successful PIN setup, should navigate to MainScreen - expect(find.byType(MainScreen), findsOneWidget); - final l10n = AppLocalizations.of(tester.element(find.byType(MainScreen)))!; - expect(find.text(l10n.checkout), findsOneWidget); - }); - - testWidgets('Kiosk MainScreen UI Elements Test', (WidgetTester tester) async { - // Launch App with PIN already set - await tester.pumpWidget(KioskApp(hasPin: true)); - await tester.pumpAndSettle(); - - // If still on PinSetupScreen, we need to skip this test - if (find.byType(PinSetupScreen).evaluate().isNotEmpty) { - return; - } - - // Verify MainScreen is shown - expect(find.byType(MainScreen), findsOneWidget); - - // Verify settings icon exists - expect(find.byIcon(Icons.settings), findsOneWidget); - expect(find.byIcon(Icons.image_search), findsOneWidget); - - final l10n = AppLocalizations.of(tester.element(find.byType(MainScreen)))!; - expect(find.text(l10n.clearCart), findsOneWidget); - }); -} diff --git a/Kiosk/lib/db/database_helper.dart b/Kiosk/lib/db/database_helper.dart index 7bc4173..80c6a76 100644 --- a/Kiosk/lib/db/database_helper.dart +++ b/Kiosk/lib/db/database_helper.dart @@ -21,7 +21,7 @@ class DatabaseHelper { return await openDatabase( path, - version: 2, // Incremented version + version: 3, onCreate: _createDB, onUpgrade: _upgradeDB, ); @@ -46,7 +46,14 @@ class DatabaseHelper { items TEXT NOT NULL, -- JSON string total_amount REAL NOT NULL, timestamp INTEGER NOT NULL, - synced INTEGER NOT NULL DEFAULT 0 + synced INTEGER NOT NULL DEFAULT 0, + alipay_notify_checked_amount INTEGER NOT NULL DEFAULT 0, + alipay_checkout_time_ms INTEGER, + alipay_matched_key TEXT, + alipay_matched_post_time_ms INTEGER, + alipay_matched_title TEXT, + alipay_matched_text TEXT, + alipay_matched_parsed_amount_fen INTEGER ) '''); } @@ -57,6 +64,17 @@ class DatabaseHelper { await db.execute('ALTER TABLE products ADD COLUMN size TEXT'); await db.execute('ALTER TABLE products ADD COLUMN type TEXT'); } + if (oldVersion < 3) { + await db.execute( + 'ALTER TABLE orders ADD COLUMN alipay_notify_checked_amount INTEGER NOT NULL DEFAULT 0', + ); + await db.execute('ALTER TABLE orders ADD COLUMN alipay_checkout_time_ms INTEGER'); + await db.execute('ALTER TABLE orders ADD COLUMN alipay_matched_key TEXT'); + await db.execute('ALTER TABLE orders ADD COLUMN alipay_matched_post_time_ms INTEGER'); + await db.execute('ALTER TABLE orders ADD COLUMN alipay_matched_title TEXT'); + await db.execute('ALTER TABLE orders ADD COLUMN alipay_matched_text TEXT'); + await db.execute('ALTER TABLE orders ADD COLUMN alipay_matched_parsed_amount_fen INTEGER'); + } } Future upsertProduct(Product product) async { @@ -109,6 +127,61 @@ class DatabaseHelper { return maps.map((map) => Order.fromMap(map)).toList(); } + Future getLatestPendingAlipayOrder({Duration maxAge = const Duration(minutes: 30)}) async { + final db = await instance.database; + final maps = await db.query( + 'orders', + where: 'alipay_notify_checked_amount = 0 AND alipay_checkout_time_ms IS NOT NULL', + orderBy: 'timestamp DESC', + limit: 1, + ); + if (maps.isEmpty) return null; + final order = Order.fromMap(maps.first); + final checkoutTimeMs = order.alipayCheckoutTimeMs; + if (checkoutTimeMs == null) return null; + final nowMs = DateTime.now().millisecondsSinceEpoch; + if (nowMs - checkoutTimeMs > maxAge.inMilliseconds) return null; + return order; + } + + Future updateOrderAlipayMatch({ + required String orderId, + required int checkoutTimeMs, + required String matchedKey, + required int matchedPostTimeMs, + required String? matchedTitle, + required String? matchedText, + required int matchedParsedAmountFen, + }) async { + final db = await instance.database; + await db.update( + 'orders', + { + 'alipay_notify_checked_amount': 1, + 'alipay_checkout_time_ms': checkoutTimeMs, + 'alipay_matched_key': matchedKey, + 'alipay_matched_post_time_ms': matchedPostTimeMs, + 'alipay_matched_title': matchedTitle, + 'alipay_matched_text': matchedText, + 'alipay_matched_parsed_amount_fen': matchedParsedAmountFen, + }, + where: 'id = ?', + whereArgs: [orderId], + ); + } + + Future isAlipayNotificationKeyAlreadyUsed(String key) async { + final db = await instance.database; + final result = await db.query( + 'orders', + columns: ['id'], + where: 'alipay_notify_checked_amount = 1 AND alipay_matched_key = ?', + whereArgs: [key], + limit: 1, + ); + return result.isNotEmpty; + } + // Restore Logic Future restoreProductsFromBackup(String backupPath) async { final db = await instance.database; diff --git a/Kiosk/lib/models/order.dart b/Kiosk/lib/models/order.dart index 1af70b8..d86eb65 100644 --- a/Kiosk/lib/models/order.dart +++ b/Kiosk/lib/models/order.dart @@ -38,6 +38,13 @@ class Order { final double totalAmount; final int timestamp; final bool synced; + final int? alipayCheckoutTimeMs; + final bool alipayNotifyCheckedAmount; + final String? alipayMatchedKey; + final int? alipayMatchedPostTimeMs; + final String? alipayMatchedTitle; + final String? alipayMatchedText; + final int? alipayMatchedParsedAmountFen; Order({ required this.id, @@ -45,6 +52,13 @@ class Order { required this.totalAmount, required this.timestamp, this.synced = false, + this.alipayCheckoutTimeMs, + this.alipayNotifyCheckedAmount = false, + this.alipayMatchedKey, + this.alipayMatchedPostTimeMs, + this.alipayMatchedTitle, + this.alipayMatchedText, + this.alipayMatchedParsedAmountFen, }); Map toMap() { @@ -54,6 +68,13 @@ class Order { 'total_amount': totalAmount, 'timestamp': timestamp, 'synced': synced ? 1 : 0, + 'alipay_checkout_time_ms': alipayCheckoutTimeMs, + 'alipay_notify_checked_amount': alipayNotifyCheckedAmount ? 1 : 0, + 'alipay_matched_key': alipayMatchedKey, + 'alipay_matched_post_time_ms': alipayMatchedPostTimeMs, + 'alipay_matched_title': alipayMatchedTitle, + 'alipay_matched_text': alipayMatchedText, + 'alipay_matched_parsed_amount_fen': alipayMatchedParsedAmountFen, }; } @@ -66,6 +87,13 @@ class Order { totalAmount: (map['total_amount'] as num).toDouble(), timestamp: map['timestamp'], synced: (map['synced'] as int) == 1, + alipayCheckoutTimeMs: map['alipay_checkout_time_ms'], + alipayNotifyCheckedAmount: (map['alipay_notify_checked_amount'] ?? 0) == 1, + alipayMatchedKey: map['alipay_matched_key'], + alipayMatchedPostTimeMs: map['alipay_matched_post_time_ms'], + alipayMatchedTitle: map['alipay_matched_title'], + alipayMatchedText: map['alipay_matched_text'], + alipayMatchedParsedAmountFen: map['alipay_matched_parsed_amount_fen'], ); } @@ -76,6 +104,13 @@ class Order { 'total_amount': totalAmount, 'timestamp': timestamp, 'synced': synced, + 'alipay_checkout_time_ms': alipayCheckoutTimeMs, + 'alipay_notify_checked_amount': alipayNotifyCheckedAmount, + 'alipay_matched_key': alipayMatchedKey, + 'alipay_matched_post_time_ms': alipayMatchedPostTimeMs, + 'alipay_matched_title': alipayMatchedTitle, + 'alipay_matched_text': alipayMatchedText, + 'alipay_matched_parsed_amount_fen': alipayMatchedParsedAmountFen, }; } } diff --git a/Kiosk/lib/screens/main_screen.dart b/Kiosk/lib/screens/main_screen.dart index c4175ae..b980a0a 100644 --- a/Kiosk/lib/screens/main_screen.dart +++ b/Kiosk/lib/screens/main_screen.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; // Add intl for date formatting import 'package:kiosk/db/database_helper.dart'; @@ -9,6 +11,7 @@ import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:kiosk/l10n/app_localizations.dart'; import 'package:kiosk/config/store_config.dart'; import 'package:kiosk/services/restore_notifier.dart'; +import 'package:kiosk/services/android_notification_listener_service.dart'; // Helper class for cart items class CartItem { @@ -85,6 +88,8 @@ class _MainScreenState extends State { final Map _cartItems = {}; // Use Map for O(1) lookups bool _isProcessing = false; final RestoreNotifier _restoreNotifier = RestoreNotifier.instance; + final AndroidNotificationListenerService _notificationListenerService = + AndroidNotificationListenerService(); // Use the front camera as requested. final MobileScannerController _scannerController = MobileScannerController( @@ -112,6 +117,9 @@ class _MainScreenState extends State { void initState() { super.initState(); _restoreNotifier.addListener(_handleRestore); + WidgetsBinding.instance.addPostFrameCallback((_) { + _resumePendingPaymentIfAny(); + }); } @override @@ -164,6 +172,38 @@ class _MainScreenState extends State { ); } + Future _resumePendingPaymentIfAny() async { + if (!mounted) return; + final pending = await _db.getLatestPendingAlipayOrder(); + if (pending == null) return; + if (!mounted) return; + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PaymentScreen( + totalAmount: pending.totalAmount, + orderId: pending.id, + checkoutTimeMs: pending.alipayCheckoutTimeMs ?? pending.timestamp, + baselineKeys: const [], + autoConfirmEnabled: true, + onPaymentConfirmed: () { + if (!mounted) return; + final l10n = AppLocalizations.of(context)!; + final navigator = Navigator.of(context); + final messenger = ScaffoldMessenger.of(context); + navigator.popUntil((route) => route.isFirst); + messenger.showSnackBar( + SnackBar( + content: Text(l10n.paymentSuccess), + duration: const Duration(seconds: 2), + ), + ); + }, + ), + ), + ); + } + Future _handleBarcodeDetect(BarcodeCapture capture) async { // Only process logic if not already processing if (_isProcessing) return; @@ -252,8 +292,11 @@ class _MainScreenState extends State { Future _processPayment() async { if (_cartItems.isEmpty) return; + if (_isProcessing) return; + _isProcessing = true; + final checkoutTimeMs = DateTime.now().millisecondsSinceEpoch; final order = model.Order( - id: DateTime.now().millisecondsSinceEpoch.toString(), + id: checkoutTimeMs.toString(), items: _cartItems.values .map((item) => model.OrderItem( barcode: item.product.barcode, @@ -263,14 +306,81 @@ class _MainScreenState extends State { )) .toList(), totalAmount: _totalAmount, - timestamp: DateTime.now().millisecondsSinceEpoch, + timestamp: checkoutTimeMs, + alipayCheckoutTimeMs: checkoutTimeMs, ); + var autoConfirmEnabled = false; + var baselineKeys = {}; + + if (Platform.isAndroid) { + autoConfirmEnabled = await _notificationListenerService.isEnabled(); + if (!autoConfirmEnabled && mounted) { + final l10n = AppLocalizations.of(context)!; + final action = await showDialog( + context: context, + barrierDismissible: true, + builder: (context) { + return AlertDialog( + title: Text(l10n.payment), + content: const Text( + 'Auto-confirm requires Notification Access. Enable it to confirm Alipay payment automatically.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, 'continue'), + child: Text(l10n.confirm), + ), + TextButton( + onPressed: () => Navigator.pop(context, 'open'), + child: const Text('Open Settings'), + ), + TextButton( + onPressed: () => Navigator.pop(context, 'retry'), + child: const Text('Retry'), + ), + ], + ); + }, + ); + + if (action == 'open') { + await _notificationListenerService.openSettings(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Enable notification access, then tap Retry.')), + ); + } + autoConfirmEnabled = await _notificationListenerService.isEnabled(); + } else if (action == 'retry') { + autoConfirmEnabled = await _notificationListenerService.isEnabled(); + } else { + autoConfirmEnabled = false; + } + } + + if (autoConfirmEnabled) { + final snapshot = await _notificationListenerService.getActiveAlipayNotificationsSnapshot(); + for (final n in snapshot) { + final key = n['key']; + final postTime = n['postTime']; + if (key is! String || key.isEmpty) continue; + if (postTime is int && postTime > checkoutTimeMs) continue; + baselineKeys.add(key); + } + } else if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Auto confirmation unavailable. Manual confirmation required.')), + ); + } + } + try { await _db.insertOrder(order); } catch (e) { debugPrint('Failed to save order: $e'); } + _isProcessing = false; if (!mounted) return; Navigator.push( @@ -278,6 +388,10 @@ class _MainScreenState extends State { MaterialPageRoute( builder: (_) => PaymentScreen( totalAmount: _totalAmount, + orderId: order.id, + checkoutTimeMs: checkoutTimeMs, + baselineKeys: baselineKeys.toList(), + autoConfirmEnabled: autoConfirmEnabled, onPaymentConfirmed: () { final l10n = AppLocalizations.of(context)!; final navigator = Navigator.of(context); diff --git a/Kiosk/lib/screens/payment_screen.dart b/Kiosk/lib/screens/payment_screen.dart index 5b57206..4a1da4f 100644 --- a/Kiosk/lib/screens/payment_screen.dart +++ b/Kiosk/lib/screens/payment_screen.dart @@ -1,17 +1,27 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:kiosk/services/alipay_payment_watch_service.dart'; +import 'package:kiosk/services/android_notification_listener_service.dart'; import 'package:kiosk/services/settings_service.dart'; // import 'package:qr_flutter/qr_flutter.dart'; import 'package:kiosk/l10n/app_localizations.dart'; class PaymentScreen extends StatefulWidget { final double totalAmount; + final String orderId; + final int checkoutTimeMs; + final List baselineKeys; + final bool autoConfirmEnabled; final VoidCallback onPaymentConfirmed; const PaymentScreen({ super.key, required this.totalAmount, + required this.orderId, + required this.checkoutTimeMs, + required this.baselineKeys, + required this.autoConfirmEnabled, required this.onPaymentConfirmed, }); @@ -21,14 +31,80 @@ class PaymentScreen extends StatefulWidget { class _PaymentScreenState extends State { final SettingsService _settingsService = SettingsService(); + final AlipayPaymentWatchService _watchService = AlipayPaymentWatchService(); + final AndroidNotificationListenerService _notificationListenerService = + AndroidNotificationListenerService(); String? _qrData; bool _isLoading = true; int _adminTapCount = 0; + bool _autoConfirmStarted = false; @override void initState() { super.initState(); _loadQrCode(); + _startAutoConfirm(); + } + + Future _startAutoConfirm() async { + if (_autoConfirmStarted) return; + _autoConfirmStarted = true; + final enabled = await _notificationListenerService.isEnabled(); + if (!enabled) return; + final baseline = widget.baselineKeys.toSet(); + final snapshot = await _notificationListenerService.getActiveAlipayNotificationsSnapshot(); + for (final n in snapshot) { + final key = n['key']; + final postTime = n['postTime']; + if (key is! String || key.isEmpty) continue; + if (postTime is int && postTime <= widget.checkoutTimeMs) { + baseline.add(key); + } + } + debugPrint( + 'PaymentScreen autoConfirm start orderId=${widget.orderId} amount=${widget.totalAmount} checkoutTimeMs=${widget.checkoutTimeMs} baselineKeys=${widget.baselineKeys.length}', + ); + + await _watchService.start( + orderId: widget.orderId, + orderAmount: widget.totalAmount, + checkoutTimeMs: widget.checkoutTimeMs, + baselineKeys: baseline, + onMatched: () { + if (!mounted) return; + widget.onPaymentConfirmed(); + }, + onMismatch: (message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Amount mismatch, still waiting for payment...')), + ); + }, + onTimeout: () async { + if (!mounted) return; + await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Payment timeout'), + content: const Text('Auto confirmation timed out. Manual/admin handling required.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), + ], + ); + }, + ); + }, + ); + } + + @override + void dispose() { + _watchService.stop(); + super.dispose(); } Future _loadQrCode() async { diff --git a/Kiosk/lib/services/alipay_payment_watch_service.dart b/Kiosk/lib/services/alipay_payment_watch_service.dart new file mode 100644 index 0000000..f3aa150 --- /dev/null +++ b/Kiosk/lib/services/alipay_payment_watch_service.dart @@ -0,0 +1,239 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:kiosk/db/database_helper.dart'; +import 'package:kiosk/services/android_notification_listener_service.dart'; + +class AlipayPaymentWatchResult { + final bool matched; + final bool timedOut; + final bool amountMismatched; + final String? mismatchText; + + const AlipayPaymentWatchResult._({ + required this.matched, + required this.timedOut, + required this.amountMismatched, + this.mismatchText, + }); + + const AlipayPaymentWatchResult.matched() + : this._(matched: true, timedOut: false, amountMismatched: false); + + const AlipayPaymentWatchResult.timedOut() + : this._(matched: false, timedOut: true, amountMismatched: false); + + const AlipayPaymentWatchResult.amountMismatched(String text) + : this._( + matched: false, + timedOut: false, + amountMismatched: true, + mismatchText: text, + ); +} + +class AlipayPaymentWatchService { + AlipayPaymentWatchService({ + AndroidNotificationListenerService? androidNotificationListenerService, + DatabaseHelper? databaseHelper, + }) : _notificationListenerService = + androidNotificationListenerService ?? AndroidNotificationListenerService(), + _db = databaseHelper ?? DatabaseHelper.instance; + + final AndroidNotificationListenerService _notificationListenerService; + final DatabaseHelper _db; + + StreamSubscription>? _sub; + Timer? _timeoutTimer; + Timer? _pollTimer; + bool _active = false; + final Map _seenMaxPostTimeByKey = {}; + final Set _loggedParseFailureKeys = {}; + + Future stop() async { + _active = false; + await _sub?.cancel(); + _sub = null; + _timeoutTimer?.cancel(); + _timeoutTimer = null; + _pollTimer?.cancel(); + _pollTimer = null; + _seenMaxPostTimeByKey.clear(); + _loggedParseFailureKeys.clear(); + } + + Future> buildBaselineKeys() async { + final snapshot = await _notificationListenerService.getActiveAlipayNotificationsSnapshot(); + final keys = {}; + for (final n in snapshot) { + final key = n['key']; + if (key is String && key.isNotEmpty) { + keys.add(key); + } + } + return keys; + } + + Future start({ + required String orderId, + required double orderAmount, + required int checkoutTimeMs, + required Set baselineKeys, + required void Function() onMatched, + required void Function(String message) onMismatch, + required void Function() onTimeout, + }) async { + await stop(); + _active = true; + + final expectedFen = _amountToFen(orderAmount); + debugPrint( + 'AlipayWatch start orderId=$orderId expectedFen=$expectedFen checkoutTimeMs=$checkoutTimeMs baselineKeys=${baselineKeys.length}', + ); + _timeoutTimer = Timer(const Duration(minutes: 5), () async { + if (!_active) return; + await stop(); + debugPrint('AlipayWatch timeout orderId=$orderId'); + onTimeout(); + }); + + _sub = _notificationListenerService.events().listen((event) async { + if (!_active) return; + + final type = event['type']; + if (type != 'posted') return; + + final payload = event['notification']; + if (payload is! Map) return; + + await _evaluateNotification( + notification: Map.from(payload), + expectedFen: expectedFen, + orderId: orderId, + checkoutTimeMs: checkoutTimeMs, + baselineKeys: baselineKeys, + onMatched: onMatched, + onMismatch: onMismatch, + ); + }); + + _pollTimer = Timer.periodic(const Duration(seconds: 2), (_) async { + if (!_active) return; + final snapshot = await _notificationListenerService.getActiveAlipayNotificationsSnapshot(); + for (final n in snapshot) { + if (!_active) return; + await _evaluateNotification( + notification: n, + expectedFen: expectedFen, + orderId: orderId, + checkoutTimeMs: checkoutTimeMs, + baselineKeys: baselineKeys, + onMatched: onMatched, + onMismatch: onMismatch, + ); + } + }); + } + + Future _evaluateNotification({ + required Map notification, + required int expectedFen, + required String orderId, + required int checkoutTimeMs, + required Set baselineKeys, + required void Function() onMatched, + required void Function(String message) onMismatch, + }) async { + if (!_active) return; + final packageName = notification['package']; + if (packageName != 'com.eg.android.AlipayGphone') return; + + final key = notification['key']; + if (key is! String || key.isEmpty) return; + + final postTime = notification['postTime']; + if (postTime is! int) return; + if (postTime <= checkoutTimeMs) return; + + final seenPostTime = _seenMaxPostTimeByKey[key]; + if (seenPostTime != null && postTime <= seenPostTime) return; + _seenMaxPostTimeByKey[key] = postTime; + + if (baselineKeys.contains(key)) return; + + final title = _toStrOrNull(notification['title']); + final text = _toStrOrNull(notification['text']); + final bigText = _toStrOrNull(notification['bigText']); + final combined = [title, text, bigText].whereType().join(' '); + if (!combined.contains('成功收款')) return; + + final parsedFen = parseSuccessAmountFen(combined); + if (parsedFen == null) { + if (_loggedParseFailureKeys.add(key)) { + debugPrint( + 'AlipayWatch parseFailed orderId=$orderId key=$key postTime=$postTime combined=$combined', + ); + } + return; + } + + if (parsedFen != expectedFen) { + debugPrint( + 'AlipayWatch mismatch orderId=$orderId key=$key postTime=$postTime expectedFen=$expectedFen parsedFen=$parsedFen text=${text ?? bigText ?? ""}', + ); + onMismatch('mismatch: expectedFen=$expectedFen parsedFen=$parsedFen'); + return; + } + + final used = await _db.isAlipayNotificationKeyAlreadyUsed(key); + if (used) return; + + debugPrint( + 'AlipayWatch matched orderId=$orderId key=$key postTime=$postTime fen=$parsedFen', + ); + await _db.updateOrderAlipayMatch( + orderId: orderId, + checkoutTimeMs: checkoutTimeMs, + matchedKey: key, + matchedPostTimeMs: postTime, + matchedTitle: title, + matchedText: text ?? bigText, + matchedParsedAmountFen: parsedFen, + ); + + await stop(); + onMatched(); + } + + static String? _toStrOrNull(Object? v) { + if (v == null) return null; + if (v is String) return v; + return v.toString(); + } + + static int _amountToFen(double amount) { + final s = amount.toStringAsFixed(2); + return _decimalStringToFen(s); + } + + static int _decimalStringToFen(String s) { + final cleaned = s.trim().replaceAll(',', ''); + final parts = cleaned.split('.'); + final intPart = parts.isEmpty ? '0' : parts[0].isEmpty ? '0' : parts[0]; + final frac = parts.length > 1 ? parts[1] : ''; + final frac2 = '${frac}00'.substring(0, 2); + final fenStr = '$intPart$frac2'; + return int.parse(fenStr); + } + + static int? parseSuccessAmountFen(String s) { + final m = RegExp(r'成功收款\s*[¥¥]?\s*([0-9][0-9,]*)(?:\.([0-9]{1,2}))?\s*元') + .firstMatch(s); + if (m == null) return null; + final intPart = (m.group(1) ?? '').replaceAll(',', ''); + if (intPart.isEmpty) return null; + final frac = m.group(2) ?? ''; + final frac2 = '${frac}00'.substring(0, 2); + return int.parse('$intPart$frac2'); + } +} diff --git a/Kiosk/lib/services/android_notification_listener_service.dart b/Kiosk/lib/services/android_notification_listener_service.dart new file mode 100644 index 0000000..9e98206 --- /dev/null +++ b/Kiosk/lib/services/android_notification_listener_service.dart @@ -0,0 +1,76 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/services.dart'; + +class AndroidNotificationListenerService { + static const MethodChannel _methodChannel = + MethodChannel('com.secgo.kiosk/notification_listener'); + static const EventChannel _eventChannel = + EventChannel('com.secgo.kiosk/notifications'); + + Stream>? _events; + + Stream> events() { + if (!Platform.isAndroid) { + return const Stream.empty(); + } + _events ??= _eventChannel + .receiveBroadcastStream() + .map((e) => Map.from(e as Map)); + return _events!; + } + + Future isEnabled() async { + if (!Platform.isAndroid) return false; + final enabled = + await _methodChannel.invokeMethod('isNotificationListenerEnabled'); + return enabled ?? false; + } + + Future> getAlipayState() async { + if (!Platform.isAndroid) { + return { + 'enabled': false, + 'hasAlipay': false, + 'updatedAtMs': 0, + }; + } + final state = await _methodChannel + .invokeMethod('getAlipayNotificationState') + .then((v) => Map.from(v ?? const {})); + state.putIfAbsent('enabled', () => false); + state.putIfAbsent('hasAlipay', () => false); + state.putIfAbsent('updatedAtMs', () => 0); + return state; + } + + Future?> getLatestAlipayNotification() async { + if (!Platform.isAndroid) return null; + final data = await _methodChannel + .invokeMethod('getLatestAlipayNotification') + .then((v) => v == null ? null : Map.from(v)); + return data; + } + + Future?> getLatestAlipayPaymentNotification() async { + if (!Platform.isAndroid) return null; + final data = await _methodChannel + .invokeMethod('getLatestAlipayPaymentNotification') + .then((v) => v == null ? null : Map.from(v)); + return data; + } + + Future>> getActiveAlipayNotificationsSnapshot() async { + if (!Platform.isAndroid) return const []; + final data = await _methodChannel + .invokeMethod('getActiveAlipayNotificationsSnapshot') + .then((v) => (v ?? const []).map((e) => Map.from(e as Map)).toList()); + return data; + } + + Future openSettings() async { + if (!Platform.isAndroid) return; + await _methodChannel.invokeMethod('openNotificationListenerSettings'); + } +} diff --git a/Kiosk/lib/services/server/kiosk_server.dart b/Kiosk/lib/services/server/kiosk_server.dart index d388c2c..994d6c9 100644 --- a/Kiosk/lib/services/server/kiosk_server.dart +++ b/Kiosk/lib/services/server/kiosk_server.dart @@ -11,6 +11,7 @@ import 'package:kiosk/db/database_helper.dart'; import 'package:kiosk/models/product.dart'; import 'package:network_info_plus/network_info_plus.dart'; +import 'package:kiosk/services/android_notification_listener_service.dart'; import 'package:kiosk/services/settings_service.dart'; class KioskServerService { @@ -22,6 +23,8 @@ class KioskServerService { final int _port = 8081; String? _deviceId; final SettingsService _settingsService = SettingsService(); + final AndroidNotificationListenerService _notificationListenerService = + AndroidNotificationListenerService(); final VoidCallback? onRestoreComplete; String? get ipAddress => _ipAddress; @@ -113,6 +116,36 @@ class KioskServerService { })); }); + router.get('/notifications/alipay', (Request request) async { + final state = await _notificationListenerService.getAlipayState(); + return Response.ok( + jsonEncode(state), + headers: {'Content-Type': 'application/json'}, + ); + }); + + router.get('/notifications/alipay/latest', (Request request) async { + final latest = await _notificationListenerService.getLatestAlipayNotification(); + if (latest == null) { + return Response.notFound(jsonEncode({'message': 'No Alipay notification captured'})); + } + return Response.ok( + jsonEncode(latest), + headers: {'Content-Type': 'application/json'}, + ); + }); + + router.get('/notifications/alipay/payment/latest', (Request request) async { + final latest = await _notificationListenerService.getLatestAlipayPaymentNotification(); + if (latest == null) { + return Response.notFound(jsonEncode({'message': 'No Alipay payment notification captured'})); + } + return Response.ok( + jsonEncode(latest), + headers: {'Content-Type': 'application/json'}, + ); + }); + // Endpoint: Sync Products (Push from Manager) router.post('/sync/products', (Request request) async { try { diff --git a/Kiosk/test/alipay_amount_parser_test.dart b/Kiosk/test/alipay_amount_parser_test.dart new file mode 100644 index 0000000..33ca1df --- /dev/null +++ b/Kiosk/test/alipay_amount_parser_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kiosk/services/alipay_payment_watch_service.dart'; + +void main() { + group('AlipayAmountParser', () { + test('parses common success format', () { + final fen = AlipayPaymentWatchService.parseSuccessAmountFen( + '支付宝成功收款0.01元,点击查看。', + ); + expect(fen, 1); + }); + + test('parses with currency symbol and spaces', () { + final fen = AlipayPaymentWatchService.parseSuccessAmountFen( + '店员通 支付宝成功收款 ¥3.00 元', + ); + expect(fen, 300); + }); + + test('parses integer amount', () { + final fen = AlipayPaymentWatchService.parseSuccessAmountFen( + '支付宝成功收款3元,点击查看。', + ); + expect(fen, 300); + }); + }); +} + diff --git a/Manager/integration_test/app_test.dart b/Manager/integration_test/app_test.dart deleted file mode 100644 index 1f03693..0000000 --- a/Manager/integration_test/app_test.dart +++ /dev/null @@ -1,163 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:hive_flutter/hive_flutter.dart'; -import 'package:integration_test/integration_test.dart'; -import 'package:manager/main.dart'; -import 'package:manager/models/product.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:manager/screens/product_list_screen.dart'; -import 'package:manager/screens/qr_upload_screen.dart'; -import 'package:manager/l10n/app_localizations.dart'; -import 'package:manager/screens/home_screen.dart'; -import 'package:manager/services/kiosk_connection_service.dart'; -import 'package:manager/models/kiosk.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - late MockKioskConnectionService mockConnectionService; - - setUpAll(() async { - await Hive.initFlutter(); - if (!Hive.isAdapterRegistered(0)) { - Hive.registerAdapter(ProductAdapter()); - } - - try { - await dotenv.load(fileName: ".env"); - } catch (e) { - debugPrint('Warning: .env not loaded: $e'); - } - - mockConnectionService = MockKioskConnectionService(); - KioskConnectionService.mockInstance = mockConnectionService; - }); - - tearDownAll(() { - KioskConnectionService.mockInstance = null; - }); - - testWidgets('Manager App Navigation Test - Product List', (WidgetTester tester) async { - // 1. Pump App - await tester.pumpWidget(const MyApp()); - await tester.pumpAndSettle(); - final l10n = AppLocalizations.of(tester.element(find.byType(HomeScreen)))!; - - // 2. Verify Home Screen Buttons - final addProductBtn = find.byIcon(Icons.add_shopping_cart); - expect(addProductBtn, findsOneWidget); - expect(find.byIcon(Icons.qr_code_2), findsOneWidget); - expect(find.text(l10n.addProduct), findsOneWidget); - - // 3. Navigate to Product List - await tester.tap(addProductBtn); - await tester.pumpAndSettle(); - - // 4. Verify Product List Screen - expect(find.byType(ProductListScreen), findsOneWidget); - - // 5. Go Back - final backTooltip = MaterialLocalizations.of( - tester.element(find.byType(Scaffold)), - ).backButtonTooltip; - Finder backBtn = find.byTooltip(backTooltip); - if (!tester.any(backBtn)) { - backBtn = find.byIcon(Icons.arrow_back); - } - if (!tester.any(backBtn)) { - backBtn = find.byIcon(Icons.arrow_back_ios); - } - - if (tester.any(backBtn)) { - await tester.tap(backBtn); - } else { - await tester.pageBack(); - } - await tester.pumpAndSettle(); - - // 6. Verify Home Screen Again - expect(find.byType(ProductListScreen), findsNothing); - expect(addProductBtn, findsOneWidget); - }); - - testWidgets('Manager App Navigation Test - QR Upload', (WidgetTester tester) async { - // 1. Pump App - await tester.pumpWidget(const MyApp()); - await tester.pumpAndSettle(); - final l10n = AppLocalizations.of(tester.element(find.byType(HomeScreen)))!; - - // 2. Find and tap QR upload button - final qrUploadBtn = find.byIcon(Icons.qr_code_2); - expect(qrUploadBtn, findsOneWidget); - expect(find.text(l10n.uploadQr), findsOneWidget); - - await tester.tap(qrUploadBtn); - await tester.pumpAndSettle(); - - // 3. Verify QR Upload Screen - expect(find.byType(QrUploadScreen), findsOneWidget); - - // 4. Verify key UI elements on QR screen - expect(find.byIcon(Icons.image), findsOneWidget); // Select image button icon - expect(find.text(l10n.selectImage), findsOneWidget); - expect(find.text(l10n.uploadToServer), findsOneWidget); - - // 5. Go Back - final backTooltip = MaterialLocalizations.of( - tester.element(find.byType(Scaffold)), - ).backButtonTooltip; - Finder backBtn = find.byTooltip(backTooltip); - if (!tester.any(backBtn)) { - backBtn = find.byIcon(Icons.arrow_back); - } - if (!tester.any(backBtn)) { - backBtn = find.byIcon(Icons.arrow_back_ios); - } - - if (tester.any(backBtn)) { - await tester.tap(backBtn); - } else { - await tester.pageBack(); - } - await tester.pumpAndSettle(); - - // 6. Verify Home Screen Again - expect(find.byType(QrUploadScreen), findsNothing); - expect(qrUploadBtn, findsOneWidget); - }); -} - -class MockKioskConnectionService extends KioskConnectionService { - MockKioskConnectionService() : super.testing(); - - final Kiosk _kiosk = Kiosk( - id: 1, - ip: '127.0.0.1', - port: 8081, - pin: '1234', - name: 'Mock Kiosk', - lastSynced: 0, - deviceId: 'test-device', - ); - - @override - List get kiosks => [_kiosk]; - - @override - bool get hasConnectedKiosk => true; - - @override - Kiosk? get connectedKiosk => _kiosk; - - @override - bool isKioskConnected(int id) => id == _kiosk.id; - - @override - void startMonitoring() {} - - @override - void stopMonitoring() {} - - @override - Future refresh() async {} -} diff --git a/Manager/lib/services/kiosk_client/kiosk_client.dart b/Manager/lib/services/kiosk_client/kiosk_client.dart index 3454d8a..f182df9 100644 --- a/Manager/lib/services/kiosk_client/kiosk_client.dart +++ b/Manager/lib/services/kiosk_client/kiosk_client.dart @@ -49,6 +49,56 @@ class KioskClientService { } } + Future?> fetchAlipayNotificationState( + String ip, + int port, + String pin, + ) async { + try { + final response = await _client + .get( + Uri.parse('http://$ip:$port/notifications/alipay'), + headers: { + 'Authorization': 'Bearer $pin', + }, + ) + .timeout(const Duration(seconds: 2)); + + if (response.statusCode != 200) return null; + final data = jsonDecode(response.body); + if (data is! Map) return null; + return Map.from(data); + } catch (e) { + debugPrint('Error fetching Alipay notification state: $e'); + return null; + } + } + + Future?> fetchLatestAlipayNotification( + String ip, + int port, + String pin, + ) async { + try { + final response = await _client + .get( + Uri.parse('http://$ip:$port/notifications/alipay/latest'), + headers: { + 'Authorization': 'Bearer $pin', + }, + ) + .timeout(const Duration(seconds: 2)); + + if (response.statusCode != 200) return null; + final data = jsonDecode(response.body); + if (data is! Map) return null; + return Map.from(data); + } catch (e) { + debugPrint('Error fetching latest Alipay notification: $e'); + return null; + } + } + Future syncProductsToKiosk(String ip, int port, String pin) async { return await bidirectionalSync(ip, port, pin); } diff --git a/Manager/lib/services/kiosk_connection_service.dart b/Manager/lib/services/kiosk_connection_service.dart index abe43c0..5e803e5 100644 --- a/Manager/lib/services/kiosk_connection_service.dart +++ b/Manager/lib/services/kiosk_connection_service.dart @@ -98,4 +98,16 @@ class KioskConnectionService extends ChangeNotifier { // Force refresh Future refresh() => _refreshConnections(); + + Future?> fetchConnectedKioskAlipayNotificationState() async { + final kiosk = connectedKiosk; + if (kiosk == null) return null; + return _kioskService.fetchAlipayNotificationState(kiosk.ip, kiosk.port, kiosk.pin); + } + + Future?> fetchConnectedKioskLatestAlipayNotification() async { + final kiosk = connectedKiosk; + if (kiosk == null) return null; + return _kioskService.fetchLatestAlipayNotification(kiosk.ip, kiosk.port, kiosk.pin); + } } diff --git a/Manager/pubspec.yaml b/Manager/pubspec.yaml index cacea7e..50948af 100644 --- a/Manager/pubspec.yaml +++ b/Manager/pubspec.yaml @@ -27,8 +27,6 @@ dependencies: shared_preferences: ^2.5.4 sqflite: ^2.4.2 -dev_dependencies: - integration_test: sdk: flutter flutter_test: sdk: flutter From 43e6ae8717a0a7ca23f46d9db19cf144c68b7626 Mon Sep 17 00:00:00 2001 From: TachibanaLolo Date: Fri, 23 Jan 2026 18:31:38 +0800 Subject: [PATCH 02/12] feat: add WeChat payment notification detection --- .../kotlin/com/secgo/kiosk/MainActivity.kt | 40 +++++ .../kiosk/SecgoNotificationListenerService.kt | 49 +++++- Kiosk/lib/db/database_helper.dart | 77 +++++++++- Kiosk/lib/models/order.dart | 35 +++++ Kiosk/lib/screens/main_screen.dart | 23 ++- Kiosk/lib/screens/payment_screen.dart | 21 ++- .../alipay_payment_watch_service.dart | 141 ++++++++++++++---- ...android_notification_listener_service.dart | 41 +++++ Kiosk/lib/services/server/kiosk_server.dart | 30 ++++ Kiosk/test/alipay_amount_parser_test.dart | 24 ++- 10 files changed, 435 insertions(+), 46 deletions(-) diff --git a/Kiosk/android/app/src/main/kotlin/com/secgo/kiosk/MainActivity.kt b/Kiosk/android/app/src/main/kotlin/com/secgo/kiosk/MainActivity.kt index 916033d..3ec25ee 100644 --- a/Kiosk/android/app/src/main/kotlin/com/secgo/kiosk/MainActivity.kt +++ b/Kiosk/android/app/src/main/kotlin/com/secgo/kiosk/MainActivity.kt @@ -39,6 +39,10 @@ class MainActivity : FlutterActivity() { "getLatestAlipayNotification" -> result.success(getLatestAlipayNotification()) "getLatestAlipayPaymentNotification" -> result.success(getLatestAlipayPaymentNotification()) "getActiveAlipayNotificationsSnapshot" -> result.success(getActiveAlipayNotificationsSnapshot()) + "getWechatNotificationState" -> result.success(getWechatNotificationState()) + "getLatestWechatNotification" -> result.success(getLatestWechatNotification()) + "getLatestWechatPaymentNotification" -> result.success(getLatestWechatPaymentNotification()) + "getActiveWechatNotificationsSnapshot" -> result.success(getActiveWechatNotificationsSnapshot()) "openNotificationListenerSettings" -> { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) result.success(true) @@ -57,17 +61,24 @@ class MainActivity : FlutterActivity() { when (intent.action) { SecgoNotificationListenerService.ACTION_NOTIFICATION_STATE -> { val hasAlipay = intent.getBooleanExtra(SecgoNotificationListenerService.KEY_HAS_ALIPAY, false) + val hasWechat = intent.getBooleanExtra(SecgoNotificationListenerService.KEY_HAS_WECHAT, false) val updatedAt = intent.getLongExtra(SecgoNotificationListenerService.KEY_UPDATED_AT_MS, 0L) val latestAlipayJson = intent.getStringExtra(SecgoNotificationListenerService.KEY_LAST_ALIPAY_JSON) val latestAlipayPaymentJson = intent.getStringExtra(SecgoNotificationListenerService.KEY_LAST_ALIPAY_PAYMENT_JSON) + val latestWechatJson = intent.getStringExtra(SecgoNotificationListenerService.KEY_LAST_WECHAT_JSON) + val latestWechatPaymentJson = + intent.getStringExtra(SecgoNotificationListenerService.KEY_LAST_WECHAT_PAYMENT_JSON) eventSink?.success( mapOf( "type" to "state", "hasAlipay" to hasAlipay, + "hasWechat" to hasWechat, "updatedAtMs" to updatedAt, "alipay" to (parseJsonToMap(latestAlipayJson) ?: emptyMap()), "alipayPayment" to (parseJsonToMap(latestAlipayPaymentJson) ?: emptyMap()), + "wechat" to (parseJsonToMap(latestWechatJson) ?: emptyMap()), + "wechatPayment" to (parseJsonToMap(latestWechatPaymentJson) ?: emptyMap()), ), ) } @@ -145,6 +156,35 @@ class MainActivity : FlutterActivity() { return parseJsonToListOfMaps(json) } + private fun getWechatNotificationState(): Map { + val prefs = getSharedPreferences(SecgoNotificationListenerService.PREFS_NAME, Context.MODE_PRIVATE) + val hasWechat = prefs.getBoolean(SecgoNotificationListenerService.KEY_HAS_WECHAT, false) + val updatedAt = prefs.getLong(SecgoNotificationListenerService.KEY_UPDATED_AT_MS, 0L) + return mapOf( + "enabled" to isNotificationListenerEnabled(), + "hasWechat" to hasWechat, + "updatedAtMs" to updatedAt, + ) + } + + private fun getLatestWechatNotification(): Map? { + val prefs = getSharedPreferences(SecgoNotificationListenerService.PREFS_NAME, Context.MODE_PRIVATE) + val json = prefs.getString(SecgoNotificationListenerService.KEY_LAST_WECHAT_JSON, null) ?: return null + return parseJsonToMap(json) + } + + private fun getLatestWechatPaymentNotification(): Map? { + val prefs = getSharedPreferences(SecgoNotificationListenerService.PREFS_NAME, Context.MODE_PRIVATE) + val json = prefs.getString(SecgoNotificationListenerService.KEY_LAST_WECHAT_PAYMENT_JSON, null) ?: return null + return parseJsonToMap(json) + } + + private fun getActiveWechatNotificationsSnapshot(): List> { + val prefs = getSharedPreferences(SecgoNotificationListenerService.PREFS_NAME, Context.MODE_PRIVATE) + val json = prefs.getString(SecgoNotificationListenerService.KEY_ACTIVE_WECHAT_SNAPSHOT_JSON, null) ?: return emptyList() + return parseJsonToListOfMaps(json) + } + private fun parseJsonToMap(json: String?): Map? { if (json == null || json.isBlank()) return null return try { diff --git a/Kiosk/android/app/src/main/kotlin/com/secgo/kiosk/SecgoNotificationListenerService.kt b/Kiosk/android/app/src/main/kotlin/com/secgo/kiosk/SecgoNotificationListenerService.kt index 4b36a0e..3d04c90 100644 --- a/Kiosk/android/app/src/main/kotlin/com/secgo/kiosk/SecgoNotificationListenerService.kt +++ b/Kiosk/android/app/src/main/kotlin/com/secgo/kiosk/SecgoNotificationListenerService.kt @@ -15,7 +15,7 @@ class SecgoNotificationListenerService : NotificationListenerService() { } override fun onNotificationPosted(sbn: StatusBarNotification) { - if (sbn.packageName == ALIPAY_PACKAGE) { + if (sbn.packageName == ALIPAY_PACKAGE || sbn.packageName == WECHAT_PACKAGE) { sendBroadcast( Intent(ACTION_NOTIFICATION_POSTED).apply { putExtra(KEY_POSTED_JSON, buildNotificationJson(sbn).toString()) @@ -31,17 +31,27 @@ class SecgoNotificationListenerService : NotificationListenerService() { private fun updateState() { val hasAlipay = activeNotifications?.any { it.packageName == ALIPAY_PACKAGE } ?: false + val hasWechat = activeNotifications?.any { it.packageName == WECHAT_PACKAGE } ?: false val latestAlipay = getLatestAlipay(activeNotifications) val latestAlipayPayment = getLatestAlipayPayment(activeNotifications) val activeSnapshot = buildActiveAlipaySnapshot(activeNotifications) + val latestWechat = getLatestWechat(activeNotifications) + val latestWechatPayment = getLatestWechatPayment(activeNotifications) + val activeWechatSnapshot = buildActiveWechatSnapshot(activeNotifications) val latestAlipayJson = latestAlipay?.let { buildNotificationJson(it).toString() } val latestAlipayPaymentJson = latestAlipayPayment?.let { buildNotificationJson(it).toString() } + val latestWechatJson = latestWechat?.let { buildNotificationJson(it).toString() } + val latestWechatPaymentJson = latestWechatPayment?.let { buildNotificationJson(it).toString() } val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) prefs.edit() .putBoolean(KEY_HAS_ALIPAY, hasAlipay) .putString(KEY_LAST_ALIPAY_JSON, latestAlipayJson) .putString(KEY_LAST_ALIPAY_PAYMENT_JSON, latestAlipayPaymentJson) .putString(KEY_ACTIVE_ALIPAY_SNAPSHOT_JSON, activeSnapshot) + .putBoolean(KEY_HAS_WECHAT, hasWechat) + .putString(KEY_LAST_WECHAT_JSON, latestWechatJson) + .putString(KEY_LAST_WECHAT_PAYMENT_JSON, latestWechatPaymentJson) + .putString(KEY_ACTIVE_WECHAT_SNAPSHOT_JSON, activeWechatSnapshot) .putLong(KEY_UPDATED_AT_MS, System.currentTimeMillis()) .apply() @@ -51,6 +61,10 @@ class SecgoNotificationListenerService : NotificationListenerService() { putExtra(KEY_LAST_ALIPAY_JSON, latestAlipayJson) putExtra(KEY_LAST_ALIPAY_PAYMENT_JSON, latestAlipayPaymentJson) putExtra(KEY_ACTIVE_ALIPAY_SNAPSHOT_JSON, activeSnapshot) + putExtra(KEY_HAS_WECHAT, hasWechat) + putExtra(KEY_LAST_WECHAT_JSON, latestWechatJson) + putExtra(KEY_LAST_WECHAT_PAYMENT_JSON, latestWechatPaymentJson) + putExtra(KEY_ACTIVE_WECHAT_SNAPSHOT_JSON, activeWechatSnapshot) putExtra(KEY_UPDATED_AT_MS, System.currentTimeMillis()) }, ) @@ -71,6 +85,21 @@ class SecgoNotificationListenerService : NotificationListenerService() { .maxByOrNull { it.postTime } } + private fun getLatestWechat(notifications: Array?): StatusBarNotification? { + if (notifications == null || notifications.isEmpty()) return null + return notifications + .filter { it.packageName == WECHAT_PACKAGE } + .maxByOrNull { it.postTime } + } + + private fun getLatestWechatPayment(notifications: Array?): StatusBarNotification? { + if (notifications == null || notifications.isEmpty()) return null + return notifications + .filter { it.packageName == WECHAT_PACKAGE } + .filter { isPaymentLike(it) } + .maxByOrNull { it.postTime } + } + private fun isPaymentLike(sbn: StatusBarNotification): Boolean { val extras = sbn.notification.extras val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() ?: "" @@ -78,7 +107,8 @@ class SecgoNotificationListenerService : NotificationListenerService() { val bigText = extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString() ?: "" val combined = "$title $text $bigText" if (combined.contains("成功收款")) return true - if (combined.contains("收款") && combined.contains("元")) return true + if (combined.contains("收款到账")) return true + if (combined.contains("收款") && (combined.contains("元") || combined.contains("¥") || combined.contains("¥"))) return true return false } @@ -112,6 +142,16 @@ class SecgoNotificationListenerService : NotificationListenerService() { return list.toString() } + private fun buildActiveWechatSnapshot(notifications: Array?): String { + val list = JSONArray() + if (notifications == null || notifications.isEmpty()) return list.toString() + notifications + .filter { it.packageName == WECHAT_PACKAGE } + .sortedByDescending { it.postTime } + .forEach { list.put(buildNotificationJson(it)) } + return list.toString() + } + companion object { const val ACTION_NOTIFICATION_STATE = "com.secgo.kiosk.NOTIFICATION_STATE" const val ACTION_NOTIFICATION_POSTED = "com.secgo.kiosk.NOTIFICATION_POSTED" @@ -120,8 +160,13 @@ class SecgoNotificationListenerService : NotificationListenerService() { const val KEY_LAST_ALIPAY_JSON = "last_alipay_json" const val KEY_LAST_ALIPAY_PAYMENT_JSON = "last_alipay_payment_json" const val KEY_ACTIVE_ALIPAY_SNAPSHOT_JSON = "active_alipay_snapshot_json" + const val KEY_HAS_WECHAT = "has_wechat" + const val KEY_LAST_WECHAT_JSON = "last_wechat_json" + const val KEY_LAST_WECHAT_PAYMENT_JSON = "last_wechat_payment_json" + const val KEY_ACTIVE_WECHAT_SNAPSHOT_JSON = "active_wechat_snapshot_json" const val KEY_POSTED_JSON = "posted_json" const val KEY_UPDATED_AT_MS = "updated_at_ms" const val ALIPAY_PACKAGE = "com.eg.android.AlipayGphone" + const val WECHAT_PACKAGE = "com.tencent.mm" } } diff --git a/Kiosk/lib/db/database_helper.dart b/Kiosk/lib/db/database_helper.dart index 80c6a76..dbba37f 100644 --- a/Kiosk/lib/db/database_helper.dart +++ b/Kiosk/lib/db/database_helper.dart @@ -21,7 +21,7 @@ class DatabaseHelper { return await openDatabase( path, - version: 3, + version: 4, onCreate: _createDB, onUpgrade: _upgradeDB, ); @@ -53,7 +53,14 @@ class DatabaseHelper { alipay_matched_post_time_ms INTEGER, alipay_matched_title TEXT, alipay_matched_text TEXT, - alipay_matched_parsed_amount_fen INTEGER + alipay_matched_parsed_amount_fen INTEGER, + wechat_notify_checked_amount INTEGER NOT NULL DEFAULT 0, + wechat_checkout_time_ms INTEGER, + wechat_matched_key TEXT, + wechat_matched_post_time_ms INTEGER, + wechat_matched_title TEXT, + wechat_matched_text TEXT, + wechat_matched_parsed_amount_fen INTEGER ) '''); } @@ -75,6 +82,17 @@ class DatabaseHelper { await db.execute('ALTER TABLE orders ADD COLUMN alipay_matched_text TEXT'); await db.execute('ALTER TABLE orders ADD COLUMN alipay_matched_parsed_amount_fen INTEGER'); } + if (oldVersion < 4) { + await db.execute( + 'ALTER TABLE orders ADD COLUMN wechat_notify_checked_amount INTEGER NOT NULL DEFAULT 0', + ); + await db.execute('ALTER TABLE orders ADD COLUMN wechat_checkout_time_ms INTEGER'); + await db.execute('ALTER TABLE orders ADD COLUMN wechat_matched_key TEXT'); + await db.execute('ALTER TABLE orders ADD COLUMN wechat_matched_post_time_ms INTEGER'); + await db.execute('ALTER TABLE orders ADD COLUMN wechat_matched_title TEXT'); + await db.execute('ALTER TABLE orders ADD COLUMN wechat_matched_text TEXT'); + await db.execute('ALTER TABLE orders ADD COLUMN wechat_matched_parsed_amount_fen INTEGER'); + } } Future upsertProduct(Product product) async { @@ -144,6 +162,23 @@ class DatabaseHelper { return order; } + Future getLatestPendingWechatOrder({Duration maxAge = const Duration(minutes: 30)}) async { + final db = await instance.database; + final maps = await db.query( + 'orders', + where: 'wechat_notify_checked_amount = 0 AND wechat_checkout_time_ms IS NOT NULL', + orderBy: 'timestamp DESC', + limit: 1, + ); + if (maps.isEmpty) return null; + final order = Order.fromMap(maps.first); + final checkoutTimeMs = order.wechatCheckoutTimeMs; + if (checkoutTimeMs == null) return null; + final nowMs = DateTime.now().millisecondsSinceEpoch; + if (nowMs - checkoutTimeMs > maxAge.inMilliseconds) return null; + return order; + } + Future updateOrderAlipayMatch({ required String orderId, required int checkoutTimeMs, @@ -182,6 +217,44 @@ class DatabaseHelper { return result.isNotEmpty; } + Future updateOrderWechatMatch({ + required String orderId, + required int checkoutTimeMs, + required String matchedKey, + required int matchedPostTimeMs, + required String? matchedTitle, + required String? matchedText, + required int matchedParsedAmountFen, + }) async { + final db = await instance.database; + await db.update( + 'orders', + { + 'wechat_notify_checked_amount': 1, + 'wechat_checkout_time_ms': checkoutTimeMs, + 'wechat_matched_key': matchedKey, + 'wechat_matched_post_time_ms': matchedPostTimeMs, + 'wechat_matched_title': matchedTitle, + 'wechat_matched_text': matchedText, + 'wechat_matched_parsed_amount_fen': matchedParsedAmountFen, + }, + where: 'id = ?', + whereArgs: [orderId], + ); + } + + Future isWechatNotificationKeyAlreadyUsed(String key) async { + final db = await instance.database; + final result = await db.query( + 'orders', + columns: ['id'], + where: 'wechat_notify_checked_amount = 1 AND wechat_matched_key = ?', + whereArgs: [key], + limit: 1, + ); + return result.isNotEmpty; + } + // Restore Logic Future restoreProductsFromBackup(String backupPath) async { final db = await instance.database; diff --git a/Kiosk/lib/models/order.dart b/Kiosk/lib/models/order.dart index d86eb65..4f1b043 100644 --- a/Kiosk/lib/models/order.dart +++ b/Kiosk/lib/models/order.dart @@ -45,6 +45,13 @@ class Order { final String? alipayMatchedTitle; final String? alipayMatchedText; final int? alipayMatchedParsedAmountFen; + final int? wechatCheckoutTimeMs; + final bool wechatNotifyCheckedAmount; + final String? wechatMatchedKey; + final int? wechatMatchedPostTimeMs; + final String? wechatMatchedTitle; + final String? wechatMatchedText; + final int? wechatMatchedParsedAmountFen; Order({ required this.id, @@ -59,6 +66,13 @@ class Order { this.alipayMatchedTitle, this.alipayMatchedText, this.alipayMatchedParsedAmountFen, + this.wechatCheckoutTimeMs, + this.wechatNotifyCheckedAmount = false, + this.wechatMatchedKey, + this.wechatMatchedPostTimeMs, + this.wechatMatchedTitle, + this.wechatMatchedText, + this.wechatMatchedParsedAmountFen, }); Map toMap() { @@ -75,6 +89,13 @@ class Order { 'alipay_matched_title': alipayMatchedTitle, 'alipay_matched_text': alipayMatchedText, 'alipay_matched_parsed_amount_fen': alipayMatchedParsedAmountFen, + 'wechat_checkout_time_ms': wechatCheckoutTimeMs, + 'wechat_notify_checked_amount': wechatNotifyCheckedAmount ? 1 : 0, + 'wechat_matched_key': wechatMatchedKey, + 'wechat_matched_post_time_ms': wechatMatchedPostTimeMs, + 'wechat_matched_title': wechatMatchedTitle, + 'wechat_matched_text': wechatMatchedText, + 'wechat_matched_parsed_amount_fen': wechatMatchedParsedAmountFen, }; } @@ -94,6 +115,13 @@ class Order { alipayMatchedTitle: map['alipay_matched_title'], alipayMatchedText: map['alipay_matched_text'], alipayMatchedParsedAmountFen: map['alipay_matched_parsed_amount_fen'], + wechatCheckoutTimeMs: map['wechat_checkout_time_ms'], + wechatNotifyCheckedAmount: (map['wechat_notify_checked_amount'] ?? 0) == 1, + wechatMatchedKey: map['wechat_matched_key'], + wechatMatchedPostTimeMs: map['wechat_matched_post_time_ms'], + wechatMatchedTitle: map['wechat_matched_title'], + wechatMatchedText: map['wechat_matched_text'], + wechatMatchedParsedAmountFen: map['wechat_matched_parsed_amount_fen'], ); } @@ -111,6 +139,13 @@ class Order { 'alipay_matched_title': alipayMatchedTitle, 'alipay_matched_text': alipayMatchedText, 'alipay_matched_parsed_amount_fen': alipayMatchedParsedAmountFen, + 'wechat_checkout_time_ms': wechatCheckoutTimeMs, + 'wechat_notify_checked_amount': wechatNotifyCheckedAmount, + 'wechat_matched_key': wechatMatchedKey, + 'wechat_matched_post_time_ms': wechatMatchedPostTimeMs, + 'wechat_matched_title': wechatMatchedTitle, + 'wechat_matched_text': wechatMatchedText, + 'wechat_matched_parsed_amount_fen': wechatMatchedParsedAmountFen, }; } } diff --git a/Kiosk/lib/screens/main_screen.dart b/Kiosk/lib/screens/main_screen.dart index b980a0a..e582b44 100644 --- a/Kiosk/lib/screens/main_screen.dart +++ b/Kiosk/lib/screens/main_screen.dart @@ -308,6 +308,7 @@ class _MainScreenState extends State { totalAmount: _totalAmount, timestamp: checkoutTimeMs, alipayCheckoutTimeMs: checkoutTimeMs, + wechatCheckoutTimeMs: checkoutTimeMs, ); var autoConfirmEnabled = false; @@ -324,7 +325,7 @@ class _MainScreenState extends State { return AlertDialog( title: Text(l10n.payment), content: const Text( - 'Auto-confirm requires Notification Access. Enable it to confirm Alipay payment automatically.', + 'Auto-confirm requires Notification Access. Enable it to confirm payment automatically.', ), actions: [ TextButton( @@ -360,13 +361,27 @@ class _MainScreenState extends State { } if (autoConfirmEnabled) { - final snapshot = await _notificationListenerService.getActiveAlipayNotificationsSnapshot(); - for (final n in snapshot) { + final alipaySnapshot = + await _notificationListenerService.getActiveAlipayNotificationsSnapshot(); + for (final n in alipaySnapshot) { final key = n['key']; final postTime = n['postTime']; + final packageName = n['package']; + if (packageName is! String || packageName.isEmpty) continue; if (key is! String || key.isEmpty) continue; if (postTime is int && postTime > checkoutTimeMs) continue; - baselineKeys.add(key); + baselineKeys.add('$packageName|$key'); + } + final wechatSnapshot = + await _notificationListenerService.getActiveWechatNotificationsSnapshot(); + for (final n in wechatSnapshot) { + final key = n['key']; + final postTime = n['postTime']; + final packageName = n['package']; + if (packageName is! String || packageName.isEmpty) continue; + if (key is! String || key.isEmpty) continue; + if (postTime is int && postTime > checkoutTimeMs) continue; + baselineKeys.add('$packageName|$key'); } } else if (mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/Kiosk/lib/screens/payment_screen.dart b/Kiosk/lib/screens/payment_screen.dart index 4a1da4f..711e427 100644 --- a/Kiosk/lib/screens/payment_screen.dart +++ b/Kiosk/lib/screens/payment_screen.dart @@ -31,7 +31,7 @@ class PaymentScreen extends StatefulWidget { class _PaymentScreenState extends State { final SettingsService _settingsService = SettingsService(); - final AlipayPaymentWatchService _watchService = AlipayPaymentWatchService(); + final PaymentNotificationWatchService _watchService = PaymentNotificationWatchService(); final AndroidNotificationListenerService _notificationListenerService = AndroidNotificationListenerService(); String? _qrData; @@ -52,13 +52,26 @@ class _PaymentScreenState extends State { final enabled = await _notificationListenerService.isEnabled(); if (!enabled) return; final baseline = widget.baselineKeys.toSet(); - final snapshot = await _notificationListenerService.getActiveAlipayNotificationsSnapshot(); - for (final n in snapshot) { + final alipaySnapshot = await _notificationListenerService.getActiveAlipayNotificationsSnapshot(); + for (final n in alipaySnapshot) { final key = n['key']; final postTime = n['postTime']; + final packageName = n['package']; + if (packageName is! String || packageName.isEmpty) continue; if (key is! String || key.isEmpty) continue; if (postTime is int && postTime <= widget.checkoutTimeMs) { - baseline.add(key); + baseline.add('$packageName|$key'); + } + } + final wechatSnapshot = await _notificationListenerService.getActiveWechatNotificationsSnapshot(); + for (final n in wechatSnapshot) { + final key = n['key']; + final postTime = n['postTime']; + final packageName = n['package']; + if (packageName is! String || packageName.isEmpty) continue; + if (key is! String || key.isEmpty) continue; + if (postTime is int && postTime <= widget.checkoutTimeMs) { + baseline.add('$packageName|$key'); } } debugPrint( diff --git a/Kiosk/lib/services/alipay_payment_watch_service.dart b/Kiosk/lib/services/alipay_payment_watch_service.dart index f3aa150..5a3117a 100644 --- a/Kiosk/lib/services/alipay_payment_watch_service.dart +++ b/Kiosk/lib/services/alipay_payment_watch_service.dart @@ -32,8 +32,11 @@ class AlipayPaymentWatchResult { ); } -class AlipayPaymentWatchService { - AlipayPaymentWatchService({ +class PaymentNotificationWatchService { + static const String alipayPackage = 'com.eg.android.AlipayGphone'; + static const String wechatPackage = 'com.tencent.mm'; + + PaymentNotificationWatchService({ AndroidNotificationListenerService? androidNotificationListenerService, DatabaseHelper? databaseHelper, }) : _notificationListenerService = @@ -63,13 +66,16 @@ class AlipayPaymentWatchService { } Future> buildBaselineKeys() async { - final snapshot = await _notificationListenerService.getActiveAlipayNotificationsSnapshot(); final keys = {}; - for (final n in snapshot) { + final alipaySnapshot = await _notificationListenerService.getActiveAlipayNotificationsSnapshot(); + final wechatSnapshot = await _notificationListenerService.getActiveWechatNotificationsSnapshot(); + for (final n in alipaySnapshot) { final key = n['key']; - if (key is String && key.isNotEmpty) { - keys.add(key); - } + if (key is String && key.isNotEmpty) keys.add(buildProviderKey(alipayPackage, key)); + } + for (final n in wechatSnapshot) { + final key = n['key']; + if (key is String && key.isNotEmpty) keys.add(buildProviderKey(wechatPackage, key)); } return keys; } @@ -88,12 +94,12 @@ class AlipayPaymentWatchService { final expectedFen = _amountToFen(orderAmount); debugPrint( - 'AlipayWatch start orderId=$orderId expectedFen=$expectedFen checkoutTimeMs=$checkoutTimeMs baselineKeys=${baselineKeys.length}', + 'PaymentWatch start orderId=$orderId expectedFen=$expectedFen checkoutTimeMs=$checkoutTimeMs baselineKeys=${baselineKeys.length}', ); _timeoutTimer = Timer(const Duration(minutes: 5), () async { if (!_active) return; await stop(); - debugPrint('AlipayWatch timeout orderId=$orderId'); + debugPrint('PaymentWatch timeout orderId=$orderId'); onTimeout(); }); @@ -119,8 +125,23 @@ class AlipayPaymentWatchService { _pollTimer = Timer.periodic(const Duration(seconds: 2), (_) async { if (!_active) return; - final snapshot = await _notificationListenerService.getActiveAlipayNotificationsSnapshot(); - for (final n in snapshot) { + final alipaySnapshot = + await _notificationListenerService.getActiveAlipayNotificationsSnapshot(); + for (final n in alipaySnapshot) { + if (!_active) return; + await _evaluateNotification( + notification: n, + expectedFen: expectedFen, + orderId: orderId, + checkoutTimeMs: checkoutTimeMs, + baselineKeys: baselineKeys, + onMatched: onMatched, + onMismatch: onMismatch, + ); + } + final wechatSnapshot = + await _notificationListenerService.getActiveWechatNotificationsSnapshot(); + for (final n in wechatSnapshot) { if (!_active) return; await _evaluateNotification( notification: n, @@ -146,32 +167,34 @@ class AlipayPaymentWatchService { }) async { if (!_active) return; final packageName = notification['package']; - if (packageName != 'com.eg.android.AlipayGphone') return; + if (packageName != alipayPackage && packageName != wechatPackage) return; + final providerPackage = packageName as String; final key = notification['key']; if (key is! String || key.isEmpty) return; + final providerKey = buildProviderKey(providerPackage, key); final postTime = notification['postTime']; if (postTime is! int) return; if (postTime <= checkoutTimeMs) return; - final seenPostTime = _seenMaxPostTimeByKey[key]; + final seenPostTime = _seenMaxPostTimeByKey[providerKey]; if (seenPostTime != null && postTime <= seenPostTime) return; - _seenMaxPostTimeByKey[key] = postTime; + _seenMaxPostTimeByKey[providerKey] = postTime; - if (baselineKeys.contains(key)) return; + if (baselineKeys.contains(providerKey)) return; final title = _toStrOrNull(notification['title']); final text = _toStrOrNull(notification['text']); final bigText = _toStrOrNull(notification['bigText']); final combined = [title, text, bigText].whereType().join(' '); - if (!combined.contains('成功收款')) return; - - final parsedFen = parseSuccessAmountFen(combined); + final parsedFen = providerPackage == alipayPackage + ? parseAlipaySuccessAmountFen(combined) + : parseWeChatSuccessAmountFen(combined); if (parsedFen == null) { - if (_loggedParseFailureKeys.add(key)) { + if (_loggedParseFailureKeys.add(providerKey)) { debugPrint( - 'AlipayWatch parseFailed orderId=$orderId key=$key postTime=$postTime combined=$combined', + 'PaymentWatch parseFailed orderId=$orderId provider=$providerPackage key=$key postTime=$postTime combined=$combined', ); } return; @@ -179,32 +202,50 @@ class AlipayPaymentWatchService { if (parsedFen != expectedFen) { debugPrint( - 'AlipayWatch mismatch orderId=$orderId key=$key postTime=$postTime expectedFen=$expectedFen parsedFen=$parsedFen text=${text ?? bigText ?? ""}', + 'PaymentWatch mismatch orderId=$orderId provider=$providerPackage key=$key postTime=$postTime expectedFen=$expectedFen parsedFen=$parsedFen text=${text ?? bigText ?? ""}', ); onMismatch('mismatch: expectedFen=$expectedFen parsedFen=$parsedFen'); return; } - final used = await _db.isAlipayNotificationKeyAlreadyUsed(key); + final used = providerPackage == alipayPackage + ? await _db.isAlipayNotificationKeyAlreadyUsed(key) + : await _db.isWechatNotificationKeyAlreadyUsed(key); if (used) return; debugPrint( - 'AlipayWatch matched orderId=$orderId key=$key postTime=$postTime fen=$parsedFen', - ); - await _db.updateOrderAlipayMatch( - orderId: orderId, - checkoutTimeMs: checkoutTimeMs, - matchedKey: key, - matchedPostTimeMs: postTime, - matchedTitle: title, - matchedText: text ?? bigText, - matchedParsedAmountFen: parsedFen, + 'PaymentWatch matched orderId=$orderId provider=$providerPackage key=$key postTime=$postTime fen=$parsedFen', ); + if (providerPackage == alipayPackage) { + await _db.updateOrderAlipayMatch( + orderId: orderId, + checkoutTimeMs: checkoutTimeMs, + matchedKey: key, + matchedPostTimeMs: postTime, + matchedTitle: title, + matchedText: text ?? bigText, + matchedParsedAmountFen: parsedFen, + ); + } else { + await _db.updateOrderWechatMatch( + orderId: orderId, + checkoutTimeMs: checkoutTimeMs, + matchedKey: key, + matchedPostTimeMs: postTime, + matchedTitle: title, + matchedText: text ?? bigText, + matchedParsedAmountFen: parsedFen, + ); + } await stop(); onMatched(); } + static String buildProviderKey(String packageName, String key) { + return '$packageName|$key'; + } + static String? _toStrOrNull(Object? v) { if (v == null) return null; if (v is String) return v; @@ -226,7 +267,7 @@ class AlipayPaymentWatchService { return int.parse(fenStr); } - static int? parseSuccessAmountFen(String s) { + static int? parseAlipaySuccessAmountFen(String s) { final m = RegExp(r'成功收款\s*[¥¥]?\s*([0-9][0-9,]*)(?:\.([0-9]{1,2}))?\s*元') .firstMatch(s); if (m == null) return null; @@ -236,4 +277,38 @@ class AlipayPaymentWatchService { final frac2 = '${frac}00'.substring(0, 2); return int.parse('$intPart$frac2'); } + + static int? parseWeChatSuccessAmountFen(String s) { + final patterns = [ + RegExp(r'收款\s*到账\s*[¥¥]\s*([0-9][0-9,]*)(?:\.([0-9]{1,2}))?'), + RegExp( + r'(?:微信支付)?\s*收款\s*(?:到账)?\s*[¥¥]?\s*([0-9][0-9,]*)(?:\.([0-9]{1,2}))?\s*(?:元)?', + ), + ]; + for (final p in patterns) { + final m = p.firstMatch(s); + if (m == null) continue; + final intPart = (m.group(1) ?? '').replaceAll(',', ''); + if (intPart.isEmpty) continue; + final frac = m.group(2) ?? ''; + final frac2 = '${frac}00'.substring(0, 2); + return int.parse('$intPart$frac2'); + } + return null; + } + + static int? parseSuccessAmountFen(String s) { + return parseAlipaySuccessAmountFen(s); + } +} + +class AlipayPaymentWatchService extends PaymentNotificationWatchService { + AlipayPaymentWatchService({ + super.androidNotificationListenerService, + super.databaseHelper, + }); + + static int? parseSuccessAmountFen(String s) { + return PaymentNotificationWatchService.parseAlipaySuccessAmountFen(s); + } } diff --git a/Kiosk/lib/services/android_notification_listener_service.dart b/Kiosk/lib/services/android_notification_listener_service.dart index 9e98206..ce41f98 100644 --- a/Kiosk/lib/services/android_notification_listener_service.dart +++ b/Kiosk/lib/services/android_notification_listener_service.dart @@ -45,6 +45,23 @@ class AndroidNotificationListenerService { return state; } + Future> getWechatState() async { + if (!Platform.isAndroid) { + return { + 'enabled': false, + 'hasWechat': false, + 'updatedAtMs': 0, + }; + } + final state = await _methodChannel + .invokeMethod('getWechatNotificationState') + .then((v) => Map.from(v ?? const {})); + state.putIfAbsent('enabled', () => false); + state.putIfAbsent('hasWechat', () => false); + state.putIfAbsent('updatedAtMs', () => 0); + return state; + } + Future?> getLatestAlipayNotification() async { if (!Platform.isAndroid) return null; final data = await _methodChannel @@ -61,6 +78,22 @@ class AndroidNotificationListenerService { return data; } + Future?> getLatestWechatNotification() async { + if (!Platform.isAndroid) return null; + final data = await _methodChannel + .invokeMethod('getLatestWechatNotification') + .then((v) => v == null ? null : Map.from(v)); + return data; + } + + Future?> getLatestWechatPaymentNotification() async { + if (!Platform.isAndroid) return null; + final data = await _methodChannel + .invokeMethod('getLatestWechatPaymentNotification') + .then((v) => v == null ? null : Map.from(v)); + return data; + } + Future>> getActiveAlipayNotificationsSnapshot() async { if (!Platform.isAndroid) return const []; final data = await _methodChannel @@ -69,6 +102,14 @@ class AndroidNotificationListenerService { return data; } + Future>> getActiveWechatNotificationsSnapshot() async { + if (!Platform.isAndroid) return const []; + final data = await _methodChannel + .invokeMethod('getActiveWechatNotificationsSnapshot') + .then((v) => (v ?? const []).map((e) => Map.from(e as Map)).toList()); + return data; + } + Future openSettings() async { if (!Platform.isAndroid) return; await _methodChannel.invokeMethod('openNotificationListenerSettings'); diff --git a/Kiosk/lib/services/server/kiosk_server.dart b/Kiosk/lib/services/server/kiosk_server.dart index 994d6c9..c815dd6 100644 --- a/Kiosk/lib/services/server/kiosk_server.dart +++ b/Kiosk/lib/services/server/kiosk_server.dart @@ -146,6 +146,36 @@ class KioskServerService { ); }); + router.get('/notifications/wechat', (Request request) async { + final state = await _notificationListenerService.getWechatState(); + return Response.ok( + jsonEncode(state), + headers: {'Content-Type': 'application/json'}, + ); + }); + + router.get('/notifications/wechat/latest', (Request request) async { + final latest = await _notificationListenerService.getLatestWechatNotification(); + if (latest == null) { + return Response.notFound(jsonEncode({'message': 'No WeChat notification captured'})); + } + return Response.ok( + jsonEncode(latest), + headers: {'Content-Type': 'application/json'}, + ); + }); + + router.get('/notifications/wechat/payment/latest', (Request request) async { + final latest = await _notificationListenerService.getLatestWechatPaymentNotification(); + if (latest == null) { + return Response.notFound(jsonEncode({'message': 'No WeChat payment notification captured'})); + } + return Response.ok( + jsonEncode(latest), + headers: {'Content-Type': 'application/json'}, + ); + }); + // Endpoint: Sync Products (Push from Manager) router.post('/sync/products', (Request request) async { try { diff --git a/Kiosk/test/alipay_amount_parser_test.dart b/Kiosk/test/alipay_amount_parser_test.dart index 33ca1df..647887a 100644 --- a/Kiosk/test/alipay_amount_parser_test.dart +++ b/Kiosk/test/alipay_amount_parser_test.dart @@ -24,5 +24,27 @@ void main() { expect(fen, 300); }); }); -} + group('WeChatAmountParser', () { + test('parses 收款到账 format', () { + final fen = PaymentNotificationWatchService.parseWeChatSuccessAmountFen( + '微信支付 收款到账¥0.01', + ); + expect(fen, 1); + }); + + test('parses 收款 with 元', () { + final fen = PaymentNotificationWatchService.parseWeChatSuccessAmountFen( + '微信支付收款3元', + ); + expect(fen, 300); + }); + + test('parses with spaces', () { + final fen = PaymentNotificationWatchService.parseWeChatSuccessAmountFen( + '收款 到账 ¥ 12.34', + ); + expect(fen, 1234); + }); + }); +} From 40f4847a03beb5b7398f7e0fb115447cd32b4910 Mon Sep 17 00:00:00 2001 From: TachibanaLolo Date: Fri, 23 Jan 2026 19:16:20 +0800 Subject: [PATCH 03/12] refactor: rename payment watch service --- .../Alipay Auto-Confirm On Checkout.md | 89 +++++++++++++++++++ Kiosk/lib/screens/payment_screen.dart | 2 +- ...> payment_notification_watch_service.dart} | 25 ++---- Kiosk/test/alipay_amount_parser_test.dart | 8 +- 4 files changed, 99 insertions(+), 25 deletions(-) create mode 100644 .trae/documents/Alipay Auto-Confirm On Checkout.md rename Kiosk/lib/services/{alipay_payment_watch_service.dart => payment_notification_watch_service.dart} (93%) diff --git a/.trae/documents/Alipay Auto-Confirm On Checkout.md b/.trae/documents/Alipay Auto-Confirm On Checkout.md new file mode 100644 index 0000000..d46a422 --- /dev/null +++ b/.trae/documents/Alipay Auto-Confirm On Checkout.md @@ -0,0 +1,89 @@ +## Goal +- On Android, when user taps Checkout, start a short-lived “payment watch session”. +- Only accept a *new* Alipay notification (post-checkout) containing “成功收款” whose parsed amount equals `Order.totalAmount`. +- When matched: update the order DB record with audit fields + boolean, then run existing `onPaymentConfirmed()` flow (clear cart + pop + snackbar). No new success screen. + +## Current Flow Touchpoints +- Checkout triggers `_processPayment()` in [main_screen.dart](file:///Users/lolotachibana/dev/SecGo/Kiosk/lib/screens/main_screen.dart#L253-L295): create order → insert DB → navigate to [PaymentScreen](file:///Users/lolotachibana/dev/SecGo/Kiosk/lib/screens/payment_screen.dart). +- Orders table lacks required audit fields in [database_helper.dart](file:///Users/lolotachibana/dev/SecGo/Kiosk/lib/db/database_helper.dart). + +## Data Model / DB Migration +- Bump DB version (currently 2) and add `ALTER TABLE orders ADD COLUMN ...` for: + - `alipay_notify_checked_amount` (int 0/1) + - `alipay_checkout_time_ms` (int) + - `alipay_matched_key` (text) + - `alipay_matched_post_time_ms` (int) + - `alipay_matched_title` (text) + - `alipay_matched_text` (text) + - `alipay_matched_parsed_amount_fen` (int) (store as integer fen to avoid float issues) +- Add DB helpers: + - `updateOrderAlipayMatch(orderId, auditFields...)` + - `isAlipayNotificationKeyAlreadyUsed(key)` to prevent reuse across orders. + +## Android Notification Capture Enhancements +- Extend `SecgoNotificationListenerService` to persist a baseline snapshot payload: + - Store JSON array of currently active Alipay notifications (at least `{key, postTime, title, text}`) into shared prefs on every `updateState()`. +- Add a “notification posted” broadcast: + - In `onNotificationPosted()`, if package is Alipay, broadcast a payload including `{key, postTime, title, text, package}`. +- Extend `MainActivity` MethodChannel: + - `getActiveAlipayNotificationsSnapshot()` → returns list from shared prefs. + - Keep existing `isNotificationListenerEnabled()` and `openNotificationListenerSettings()`. +- Extend `MainActivity` EventChannel: + - Forward the “posted” broadcast events to Flutter so the watch session can react in near real time. + +## Flutter: PaymentWatchSession (core logic) +- Implement a single-session manager class (e.g. `alipay_payment_watch_service.dart`): + - Inputs: `orderId`, `orderAmount`, `checkoutTimeMs`, `baselineKeys`. + - Subscribe to notification events stream. + - Candidate is “new” only if: + - `key` not in `baselineKeys`, AND + - `postTime > checkoutTimeMs`. + - Filtering: + - package == `com.eg.android.AlipayGphone` + - text/title contains `成功收款` + - Parsing amount: + - Extract number in patterns like `成功收款0.01元` (support minor variants like spaces/¥ if trivial). + - Convert to integer fen (string-based conversion, no float tolerance). + - Match: + - If parsed fen equals `order.totalAmount` fen → success. + - If parsed but mismatch → show “amount mismatch” message and continue. + - Timeout: + - After 5 minutes → show modal “manual/admin required” and stop listening. + - Cancel on dispose / new checkout. + +## UI Integration +- In `_processPayment()`: + - Record `checkoutTimeMs`. + - If Android: + - Check NotificationListener permission. + - If not enabled: show dialog explaining permission + buttons: + - Open settings + - Continue without auto-confirm (manual required) + - If enabled: read baseline snapshot via platform method, start watch session immediately. + - Insert order with `alipay_checkout_time_ms`. + - Navigate to `PaymentScreen`, passing `orderId` and the watch session callbacks/state. +- In `PaymentScreen`: + - While showing QR, also show lightweight “waiting for payment” status. + - On match: call existing `widget.onPaymentConfirmed()`. + - On timeout: show modal dialog; user can proceed with existing admin PIN flow. + +## Verification +- Add a small debug-only path or logs to confirm: + - Baseline keys captured. + - Posted notification event received. + - Parsed amount and match decision. +- Test on device: + - Start checkout → send Alipay payment 0.01 → verify order updated + success flow. + - Send wrong amount → verify mismatch message and still listening. + - No payment → verify 5-minute timeout dialog. + +## Files Expected To Change +- Kiosk Android: + - [SecgoNotificationListenerService.kt](file:///Users/lolotachibana/dev/SecGo/Kiosk/android/app/src/main/kotlin/com/secgo/kiosk/SecgoNotificationListenerService.kt) + - [MainActivity.kt](file:///Users/lolotachibana/dev/SecGo/Kiosk/android/app/src/main/kotlin/com/secgo/kiosk/MainActivity.kt) +- Kiosk Flutter: + - [database_helper.dart](file:///Users/lolotachibana/dev/SecGo/Kiosk/lib/db/database_helper.dart) + - [order.dart](file:///Users/lolotachibana/dev/SecGo/Kiosk/lib/models/order.dart) (add optional fields if needed) + - [main_screen.dart](file:///Users/lolotachibana/dev/SecGo/Kiosk/lib/screens/main_screen.dart) + - [payment_screen.dart](file:///Users/lolotachibana/dev/SecGo/Kiosk/lib/screens/payment_screen.dart) + - Add new service file for the watch session under `Kiosk/lib/services/`. diff --git a/Kiosk/lib/screens/payment_screen.dart b/Kiosk/lib/screens/payment_screen.dart index 711e427..478348a 100644 --- a/Kiosk/lib/screens/payment_screen.dart +++ b/Kiosk/lib/screens/payment_screen.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -import 'package:kiosk/services/alipay_payment_watch_service.dart'; +import 'package:kiosk/services/payment_notification_watch_service.dart'; import 'package:kiosk/services/android_notification_listener_service.dart'; import 'package:kiosk/services/settings_service.dart'; // import 'package:qr_flutter/qr_flutter.dart'; diff --git a/Kiosk/lib/services/alipay_payment_watch_service.dart b/Kiosk/lib/services/payment_notification_watch_service.dart similarity index 93% rename from Kiosk/lib/services/alipay_payment_watch_service.dart rename to Kiosk/lib/services/payment_notification_watch_service.dart index 5a3117a..220d45b 100644 --- a/Kiosk/lib/services/alipay_payment_watch_service.dart +++ b/Kiosk/lib/services/payment_notification_watch_service.dart @@ -4,26 +4,26 @@ import 'package:flutter/foundation.dart'; import 'package:kiosk/db/database_helper.dart'; import 'package:kiosk/services/android_notification_listener_service.dart'; -class AlipayPaymentWatchResult { +class PaymentNotificationWatchResult { final bool matched; final bool timedOut; final bool amountMismatched; final String? mismatchText; - const AlipayPaymentWatchResult._({ + const PaymentNotificationWatchResult._({ required this.matched, required this.timedOut, required this.amountMismatched, this.mismatchText, }); - const AlipayPaymentWatchResult.matched() + const PaymentNotificationWatchResult.matched() : this._(matched: true, timedOut: false, amountMismatched: false); - const AlipayPaymentWatchResult.timedOut() + const PaymentNotificationWatchResult.timedOut() : this._(matched: false, timedOut: true, amountMismatched: false); - const AlipayPaymentWatchResult.amountMismatched(String text) + const PaymentNotificationWatchResult.amountMismatched(String text) : this._( matched: false, timedOut: false, @@ -296,19 +296,4 @@ class PaymentNotificationWatchService { } return null; } - - static int? parseSuccessAmountFen(String s) { - return parseAlipaySuccessAmountFen(s); - } -} - -class AlipayPaymentWatchService extends PaymentNotificationWatchService { - AlipayPaymentWatchService({ - super.androidNotificationListenerService, - super.databaseHelper, - }); - - static int? parseSuccessAmountFen(String s) { - return PaymentNotificationWatchService.parseAlipaySuccessAmountFen(s); - } } diff --git a/Kiosk/test/alipay_amount_parser_test.dart b/Kiosk/test/alipay_amount_parser_test.dart index 647887a..d914ce0 100644 --- a/Kiosk/test/alipay_amount_parser_test.dart +++ b/Kiosk/test/alipay_amount_parser_test.dart @@ -1,24 +1,24 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:kiosk/services/alipay_payment_watch_service.dart'; +import 'package:kiosk/services/payment_notification_watch_service.dart'; void main() { group('AlipayAmountParser', () { test('parses common success format', () { - final fen = AlipayPaymentWatchService.parseSuccessAmountFen( + final fen = PaymentNotificationWatchService.parseAlipaySuccessAmountFen( '支付宝成功收款0.01元,点击查看。', ); expect(fen, 1); }); test('parses with currency symbol and spaces', () { - final fen = AlipayPaymentWatchService.parseSuccessAmountFen( + final fen = PaymentNotificationWatchService.parseAlipaySuccessAmountFen( '店员通 支付宝成功收款 ¥3.00 元', ); expect(fen, 300); }); test('parses integer amount', () { - final fen = AlipayPaymentWatchService.parseSuccessAmountFen( + final fen = PaymentNotificationWatchService.parseAlipaySuccessAmountFen( '支付宝成功收款3元,点击查看。', ); expect(fen, 300); From 97f00c7e52f7b02a862b731de966d94962a2d600 Mon Sep 17 00:00:00 2001 From: TachibanaLolo Date: Fri, 23 Jan 2026 19:18:05 +0800 Subject: [PATCH 04/12] chore: ignore trae workspace files --- .gitignore | 1 + .../Alipay Auto-Confirm On Checkout.md | 89 ------------------- 2 files changed, 1 insertion(+), 89 deletions(-) create mode 100644 .gitignore delete mode 100644 .trae/documents/Alipay Auto-Confirm On Checkout.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bdcf39e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.trae/ diff --git a/.trae/documents/Alipay Auto-Confirm On Checkout.md b/.trae/documents/Alipay Auto-Confirm On Checkout.md deleted file mode 100644 index d46a422..0000000 --- a/.trae/documents/Alipay Auto-Confirm On Checkout.md +++ /dev/null @@ -1,89 +0,0 @@ -## Goal -- On Android, when user taps Checkout, start a short-lived “payment watch session”. -- Only accept a *new* Alipay notification (post-checkout) containing “成功收款” whose parsed amount equals `Order.totalAmount`. -- When matched: update the order DB record with audit fields + boolean, then run existing `onPaymentConfirmed()` flow (clear cart + pop + snackbar). No new success screen. - -## Current Flow Touchpoints -- Checkout triggers `_processPayment()` in [main_screen.dart](file:///Users/lolotachibana/dev/SecGo/Kiosk/lib/screens/main_screen.dart#L253-L295): create order → insert DB → navigate to [PaymentScreen](file:///Users/lolotachibana/dev/SecGo/Kiosk/lib/screens/payment_screen.dart). -- Orders table lacks required audit fields in [database_helper.dart](file:///Users/lolotachibana/dev/SecGo/Kiosk/lib/db/database_helper.dart). - -## Data Model / DB Migration -- Bump DB version (currently 2) and add `ALTER TABLE orders ADD COLUMN ...` for: - - `alipay_notify_checked_amount` (int 0/1) - - `alipay_checkout_time_ms` (int) - - `alipay_matched_key` (text) - - `alipay_matched_post_time_ms` (int) - - `alipay_matched_title` (text) - - `alipay_matched_text` (text) - - `alipay_matched_parsed_amount_fen` (int) (store as integer fen to avoid float issues) -- Add DB helpers: - - `updateOrderAlipayMatch(orderId, auditFields...)` - - `isAlipayNotificationKeyAlreadyUsed(key)` to prevent reuse across orders. - -## Android Notification Capture Enhancements -- Extend `SecgoNotificationListenerService` to persist a baseline snapshot payload: - - Store JSON array of currently active Alipay notifications (at least `{key, postTime, title, text}`) into shared prefs on every `updateState()`. -- Add a “notification posted” broadcast: - - In `onNotificationPosted()`, if package is Alipay, broadcast a payload including `{key, postTime, title, text, package}`. -- Extend `MainActivity` MethodChannel: - - `getActiveAlipayNotificationsSnapshot()` → returns list from shared prefs. - - Keep existing `isNotificationListenerEnabled()` and `openNotificationListenerSettings()`. -- Extend `MainActivity` EventChannel: - - Forward the “posted” broadcast events to Flutter so the watch session can react in near real time. - -## Flutter: PaymentWatchSession (core logic) -- Implement a single-session manager class (e.g. `alipay_payment_watch_service.dart`): - - Inputs: `orderId`, `orderAmount`, `checkoutTimeMs`, `baselineKeys`. - - Subscribe to notification events stream. - - Candidate is “new” only if: - - `key` not in `baselineKeys`, AND - - `postTime > checkoutTimeMs`. - - Filtering: - - package == `com.eg.android.AlipayGphone` - - text/title contains `成功收款` - - Parsing amount: - - Extract number in patterns like `成功收款0.01元` (support minor variants like spaces/¥ if trivial). - - Convert to integer fen (string-based conversion, no float tolerance). - - Match: - - If parsed fen equals `order.totalAmount` fen → success. - - If parsed but mismatch → show “amount mismatch” message and continue. - - Timeout: - - After 5 minutes → show modal “manual/admin required” and stop listening. - - Cancel on dispose / new checkout. - -## UI Integration -- In `_processPayment()`: - - Record `checkoutTimeMs`. - - If Android: - - Check NotificationListener permission. - - If not enabled: show dialog explaining permission + buttons: - - Open settings - - Continue without auto-confirm (manual required) - - If enabled: read baseline snapshot via platform method, start watch session immediately. - - Insert order with `alipay_checkout_time_ms`. - - Navigate to `PaymentScreen`, passing `orderId` and the watch session callbacks/state. -- In `PaymentScreen`: - - While showing QR, also show lightweight “waiting for payment” status. - - On match: call existing `widget.onPaymentConfirmed()`. - - On timeout: show modal dialog; user can proceed with existing admin PIN flow. - -## Verification -- Add a small debug-only path or logs to confirm: - - Baseline keys captured. - - Posted notification event received. - - Parsed amount and match decision. -- Test on device: - - Start checkout → send Alipay payment 0.01 → verify order updated + success flow. - - Send wrong amount → verify mismatch message and still listening. - - No payment → verify 5-minute timeout dialog. - -## Files Expected To Change -- Kiosk Android: - - [SecgoNotificationListenerService.kt](file:///Users/lolotachibana/dev/SecGo/Kiosk/android/app/src/main/kotlin/com/secgo/kiosk/SecgoNotificationListenerService.kt) - - [MainActivity.kt](file:///Users/lolotachibana/dev/SecGo/Kiosk/android/app/src/main/kotlin/com/secgo/kiosk/MainActivity.kt) -- Kiosk Flutter: - - [database_helper.dart](file:///Users/lolotachibana/dev/SecGo/Kiosk/lib/db/database_helper.dart) - - [order.dart](file:///Users/lolotachibana/dev/SecGo/Kiosk/lib/models/order.dart) (add optional fields if needed) - - [main_screen.dart](file:///Users/lolotachibana/dev/SecGo/Kiosk/lib/screens/main_screen.dart) - - [payment_screen.dart](file:///Users/lolotachibana/dev/SecGo/Kiosk/lib/screens/payment_screen.dart) - - Add new service file for the watch session under `Kiosk/lib/services/`. From 026d80bcca3546a7861f67c7075675cd3c479cd6 Mon Sep 17 00:00:00 2001 From: TachibanaLolo Date: Fri, 23 Jan 2026 19:26:44 +0800 Subject: [PATCH 05/12] fix: repair Manager pubspec dev_dependencies --- Manager/pubspec.lock | 39 --------------------------------------- Manager/pubspec.yaml | 2 +- 2 files changed, 1 insertion(+), 40 deletions(-) diff --git a/Manager/pubspec.lock b/Manager/pubspec.lock index a10186f..22b2bfb 100644 --- a/Manager/pubspec.lock +++ b/Manager/pubspec.lock @@ -262,11 +262,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" - flutter_driver: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" flutter_lints: dependency: "direct dev" description: @@ -306,11 +301,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" - fuchsia_remote_debug_protocol: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" glob: dependency: transitive description: @@ -447,11 +437,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" - integration_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" intl: dependency: "direct main" description: @@ -740,14 +725,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.2" - process: - dependency: transitive - description: - name: process - sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 - url: "https://pub.dev" - source: hosted - version: "5.0.5" provider: dependency: "direct main" description: @@ -945,14 +922,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" - sync_http: - dependency: transitive - description: - name: sync_http - sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" - url: "https://pub.dev" - source: hosted - version: "0.3.1" synchronized: dependency: transitive description: @@ -1041,14 +1010,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" - webdriver: - dependency: transitive - description: - name: webdriver - sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" - url: "https://pub.dev" - source: hosted - version: "3.1.0" win32: dependency: transitive description: diff --git a/Manager/pubspec.yaml b/Manager/pubspec.yaml index 50948af..fd5ec5e 100644 --- a/Manager/pubspec.yaml +++ b/Manager/pubspec.yaml @@ -27,7 +27,7 @@ dependencies: shared_preferences: ^2.5.4 sqflite: ^2.4.2 - sdk: flutter +dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^5.0.0 From ff7b2e0ab8f653fcdec6e54f6b65efd312d3cb8b Mon Sep 17 00:00:00 2001 From: TachibanaLolo Date: Fri, 23 Jan 2026 19:43:11 +0800 Subject: [PATCH 06/12] fix: allow matching updated WeChat notifications --- Kiosk/lib/services/payment_notification_watch_service.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/Kiosk/lib/services/payment_notification_watch_service.dart b/Kiosk/lib/services/payment_notification_watch_service.dart index 220d45b..b50df75 100644 --- a/Kiosk/lib/services/payment_notification_watch_service.dart +++ b/Kiosk/lib/services/payment_notification_watch_service.dart @@ -182,8 +182,6 @@ class PaymentNotificationWatchService { if (seenPostTime != null && postTime <= seenPostTime) return; _seenMaxPostTimeByKey[providerKey] = postTime; - if (baselineKeys.contains(providerKey)) return; - final title = _toStrOrNull(notification['title']); final text = _toStrOrNull(notification['text']); final bigText = _toStrOrNull(notification['bigText']); From 2b4a7eb7db3950ad926f3d2763025d8fa7c8febd Mon Sep 17 00:00:00 2001 From: TachibanaLolo Date: Fri, 23 Jan 2026 21:06:16 +0800 Subject: [PATCH 07/12] fix: allow cleartext http to local kiosk --- Manager/android/app/src/main/AndroidManifest.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/Manager/android/app/src/main/AndroidManifest.xml b/Manager/android/app/src/main/AndroidManifest.xml index 7160d16..064f3da 100644 --- a/Manager/android/app/src/main/AndroidManifest.xml +++ b/Manager/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ Date: Fri, 23 Jan 2026 21:14:12 +0800 Subject: [PATCH 08/12] chore: surface pairing errors and relax port parsing --- Manager/lib/screens/sync_kiosk_screen.dart | 20 ++++++++-- .../services/kiosk_client/kiosk_client.dart | 39 ++++++++++++++++++- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/Manager/lib/screens/sync_kiosk_screen.dart b/Manager/lib/screens/sync_kiosk_screen.dart index aa79643..5d9ad74 100644 --- a/Manager/lib/screens/sync_kiosk_screen.dart +++ b/Manager/lib/screens/sync_kiosk_screen.dart @@ -26,8 +26,18 @@ class _SyncKioskScreenState extends State { try { final Map data = jsonDecode(code); if (data.containsKey('ip') && data.containsKey('port')) { - final ip = data['ip'] as String; - final port = data['port'] as int; + final ip = data['ip']?.toString(); + final portValue = data['port']; + final port = portValue is int ? portValue : int.tryParse(portValue?.toString() ?? ''); + if (ip == null || ip.isEmpty || port == null) { + if (mounted) { + setState(() { + _isSyncing = false; + _statusMessage = AppLocalizations.of(context)!.pairFailed; + }); + } + return; + } final deviceId = data['deviceId'] as String?; setState(() { _isSyncing = true; @@ -118,7 +128,8 @@ class _SyncKioskScreenState extends State { _statusMessage = l10n.pairingKiosk(ip); }); - final fetchedDeviceId = await _kioskService.fetchDeviceId(ip, port, pin); + final debugResult = await _kioskService.fetchDeviceIdDebug(ip, port, pin); + final fetchedDeviceId = debugResult.deviceId; final success = fetchedDeviceId != null; if (!mounted) return; @@ -142,7 +153,8 @@ class _SyncKioskScreenState extends State { } else { setState(() { _isSyncing = false; - _statusMessage = l10n.pairFailed; + final err = debugResult.error; + _statusMessage = err == null || err.isEmpty ? l10n.pairFailed : err; }); } } diff --git a/Manager/lib/services/kiosk_client/kiosk_client.dart b/Manager/lib/services/kiosk_client/kiosk_client.dart index f182df9..5da69d3 100644 --- a/Manager/lib/services/kiosk_client/kiosk_client.dart +++ b/Manager/lib/services/kiosk_client/kiosk_client.dart @@ -12,6 +12,40 @@ class KioskClientService { KioskClientService({http.Client? client}) : _client = client ?? http.Client(); + Future<({String? deviceId, String? error, int? statusCode})> fetchDeviceIdDebug( + String ip, + int port, + String pin, + ) async { + try { + final response = await _client + .get( + Uri.parse('http://$ip:$port/status'), + headers: { + 'Authorization': 'Bearer $pin', + }, + ) + .timeout(const Duration(seconds: 3)); + + if (response.statusCode != 200) { + return ( + deviceId: null, + error: 'HTTP ${response.statusCode}: ${response.body}', + statusCode: response.statusCode, + ); + } + + final data = jsonDecode(response.body); + final deviceId = data is Map ? data['device_id']?.toString() : null; + if (deviceId == null || deviceId.isEmpty) { + return (deviceId: null, error: 'Missing device_id', statusCode: 200); + } + return (deviceId: deviceId, error: null, statusCode: 200); + } catch (e) { + return (deviceId: null, error: e.toString(), statusCode: null); + } + } + Future> getCandidateKioskIps() async { final info = NetworkInfo(); final candidates = {}; @@ -38,10 +72,11 @@ class KioskClientService { 'Authorization': 'Bearer $pin', }, ) - .timeout(const Duration(seconds: 1)); + .timeout(const Duration(seconds: 3)); if (response.statusCode == 200) { final data = jsonDecode(response.body); - return data['device_id']; + if (data is! Map) return null; + return data['device_id']?.toString(); } return null; } catch (e) { From 4aa38a4a162adaac2cc010d2367100e3b77095c1 Mon Sep 17 00:00:00 2001 From: TachibanaLolo Date: Fri, 23 Jan 2026 21:19:29 +0800 Subject: [PATCH 09/12] fix: improve kiosk pairing diagnostics and manual connect --- Manager/lib/screens/sync_kiosk_screen.dart | 92 +++++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/Manager/lib/screens/sync_kiosk_screen.dart b/Manager/lib/screens/sync_kiosk_screen.dart index 5d9ad74..1c432b6 100644 --- a/Manager/lib/screens/sync_kiosk_screen.dart +++ b/Manager/lib/screens/sync_kiosk_screen.dart @@ -15,14 +15,31 @@ class SyncKioskScreen extends StatefulWidget { class _SyncKioskScreenState extends State { final KioskClientService _kioskService = KioskClientService(); + late final MobileScannerController _scannerController; bool _isSyncing = false; String? _statusMessage; + @override + void initState() { + super.initState(); + _scannerController = MobileScannerController( + detectionSpeed: DetectionSpeed.noDuplicates, + formats: const [BarcodeFormat.qrCode], + ); + } + + @override + void dispose() { + _scannerController.dispose(); + super.dispose(); + } + Future _onQrDetect(BarcodeCapture capture) async { if (_isSyncing) return; final List barcodes = capture.barcodes; if (barcodes.isNotEmpty && barcodes.first.rawValue != null) { final String code = barcodes.first.rawValue!; + debugPrint('SyncKioskScreen qrDetected len=${code.length}'); try { final Map data = jsonDecode(code); if (data.containsKey('ip') && data.containsKey('port')) { @@ -38,6 +55,7 @@ class _SyncKioskScreenState extends State { } return; } + debugPrint('SyncKioskScreen parsed ip=$ip port=$port'); final deviceId = data['deviceId'] as String?; setState(() { _isSyncing = true; @@ -56,6 +74,7 @@ class _SyncKioskScreenState extends State { await _pairWithKiosk(ip, port, pin); } } catch (e) { + debugPrint('SyncKioskScreen qrDecodeFailed err=$e'); // Not a valid JSON or Kiosk QR if (mounted) { setState(() { @@ -128,9 +147,13 @@ class _SyncKioskScreenState extends State { _statusMessage = l10n.pairingKiosk(ip); }); + debugPrint('SyncKioskScreen pairingStart ip=$ip port=$port pinLen=${pin.length}'); final debugResult = await _kioskService.fetchDeviceIdDebug(ip, port, pin); final fetchedDeviceId = debugResult.deviceId; final success = fetchedDeviceId != null; + debugPrint( + 'SyncKioskScreen pairingResult success=$success statusCode=${debugResult.statusCode} error=${debugResult.error}', + ); if (!mounted) return; if (success) { @@ -159,6 +182,62 @@ class _SyncKioskScreenState extends State { } } + Future _manualPair() async { + if (_isSyncing) return; + final l10n = AppLocalizations.of(context)!; + final ipController = TextEditingController(); + final portController = TextEditingController(text: '8081'); + + final pin = await _promptForPin(); + if (!mounted) return; + if (pin == null) return; + + final result = await showDialog<({String ip, int port})>( + context: context, + barrierDismissible: true, + builder: (context) => AlertDialog( + title: Text(l10n.pairKioskTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: ipController, + keyboardType: TextInputType.url, + decoration: const InputDecoration(labelText: 'IP'), + ), + TextField( + controller: portController, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'Port'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.cancel), + ), + TextButton( + onPressed: () { + final ip = ipController.text.trim(); + final port = int.tryParse(portController.text.trim()); + if (ip.isEmpty || port == null) { + Navigator.pop(context); + return; + } + Navigator.pop(context, (ip: ip, port: port)); + }, + child: Text(l10n.confirm), + ), + ], + ), + ); + + if (!mounted) return; + if (result == null) return; + await _pairWithKiosk(result.ip, result.port, pin); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -169,6 +248,7 @@ class _SyncKioskScreenState extends State { Expanded( flex: 2, child: MobileScanner( + controller: _scannerController, onDetect: _onQrDetect, ), ), @@ -184,7 +264,17 @@ class _SyncKioskScreenState extends State { Text(_statusMessage ?? l10n.scanBarcode), ], ) - : Text(l10n.scanKioskHint), + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(l10n.scanKioskHint), + const SizedBox(height: 12), + TextButton( + onPressed: _manualPair, + child: const Text('Manual Pair'), + ), + ], + ), ), ), ], From 5beeb780bd53c7ec0edaa7571c21c9fc34cd3980 Mon Sep 17 00:00:00 2001 From: TachibanaLolo Date: Fri, 23 Jan 2026 21:39:04 +0800 Subject: [PATCH 10/12] feat(manager): localize payment method and pairing UI --- Manager/lib/l10n/app_en.arb | 9 +++- Manager/lib/l10n/app_localizations.dart | 42 +++++++++++++++++++ Manager/lib/l10n/app_localizations_en.dart | 23 ++++++++++ Manager/lib/l10n/app_localizations_zh.dart | 23 ++++++++++ Manager/lib/l10n/app_zh.arb | 9 +++- Manager/lib/models/order.dart | 17 ++++++++ Manager/lib/screens/kiosk_history_screen.dart | 13 +++++- Manager/lib/screens/sync_kiosk_screen.dart | 6 +-- 8 files changed, 136 insertions(+), 6 deletions(-) diff --git a/Manager/lib/l10n/app_en.arb b/Manager/lib/l10n/app_en.arb index 0cbb722..7c73a57 100644 --- a/Manager/lib/l10n/app_en.arb +++ b/Manager/lib/l10n/app_en.arb @@ -76,5 +76,12 @@ "enterPinTitle": "Enter PIN", "enterPinHint": "Kiosk PIN", "pinLength": "PIN must be at least 4 digits", - "confirm": "Confirm" + "confirm": "Confirm", + "payMethodLabel": "Pay: {method}", + "paymentMethodAlipay": "Alipay", + "paymentMethodWechat": "WeChat", + "paymentMethodPending": "Pending", + "manualPair": "Manual Pair", + "ipLabel": "IP", + "portLabel": "Port" } diff --git a/Manager/lib/l10n/app_localizations.dart b/Manager/lib/l10n/app_localizations.dart index 3c64776..92321b9 100644 --- a/Manager/lib/l10n/app_localizations.dart +++ b/Manager/lib/l10n/app_localizations.dart @@ -565,6 +565,48 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Confirm'** String get confirm; + + /// No description provided for @payMethodLabel. + /// + /// In en, this message translates to: + /// **'Pay: {method}'** + String payMethodLabel(Object method); + + /// No description provided for @paymentMethodAlipay. + /// + /// In en, this message translates to: + /// **'Alipay'** + String get paymentMethodAlipay; + + /// No description provided for @paymentMethodWechat. + /// + /// In en, this message translates to: + /// **'WeChat'** + String get paymentMethodWechat; + + /// No description provided for @paymentMethodPending. + /// + /// In en, this message translates to: + /// **'Pending'** + String get paymentMethodPending; + + /// No description provided for @manualPair. + /// + /// In en, this message translates to: + /// **'Manual Pair'** + String get manualPair; + + /// No description provided for @ipLabel. + /// + /// In en, this message translates to: + /// **'IP'** + String get ipLabel; + + /// No description provided for @portLabel. + /// + /// In en, this message translates to: + /// **'Port'** + String get portLabel; } class _AppLocalizationsDelegate diff --git a/Manager/lib/l10n/app_localizations_en.dart b/Manager/lib/l10n/app_localizations_en.dart index 0a0e16f..4e93f44 100644 --- a/Manager/lib/l10n/app_localizations_en.dart +++ b/Manager/lib/l10n/app_localizations_en.dart @@ -267,4 +267,27 @@ class AppLocalizationsEn extends AppLocalizations { @override String get confirm => 'Confirm'; + + @override + String payMethodLabel(Object method) { + return 'Pay: $method'; + } + + @override + String get paymentMethodAlipay => 'Alipay'; + + @override + String get paymentMethodWechat => 'WeChat'; + + @override + String get paymentMethodPending => 'Pending'; + + @override + String get manualPair => 'Manual Pair'; + + @override + String get ipLabel => 'IP'; + + @override + String get portLabel => 'Port'; } diff --git a/Manager/lib/l10n/app_localizations_zh.dart b/Manager/lib/l10n/app_localizations_zh.dart index e91ebbf..a399e91 100644 --- a/Manager/lib/l10n/app_localizations_zh.dart +++ b/Manager/lib/l10n/app_localizations_zh.dart @@ -264,4 +264,27 @@ class AppLocalizationsZh extends AppLocalizations { @override String get confirm => '确认'; + + @override + String payMethodLabel(Object method) { + return '支付方式:$method'; + } + + @override + String get paymentMethodAlipay => '支付宝'; + + @override + String get paymentMethodWechat => '微信'; + + @override + String get paymentMethodPending => '待确认'; + + @override + String get manualPair => '手动配对'; + + @override + String get ipLabel => 'IP'; + + @override + String get portLabel => '端口'; } diff --git a/Manager/lib/l10n/app_zh.arb b/Manager/lib/l10n/app_zh.arb index d5dec9c..ab05c6d 100644 --- a/Manager/lib/l10n/app_zh.arb +++ b/Manager/lib/l10n/app_zh.arb @@ -76,5 +76,12 @@ "enterPinTitle": "输入PIN", "enterPinHint": "终端PIN", "pinLength": "PIN至少4位", - "confirm": "确认" + "confirm": "确认", + "payMethodLabel": "支付方式:{method}", + "paymentMethodAlipay": "支付宝", + "paymentMethodWechat": "微信", + "paymentMethodPending": "待确认", + "manualPair": "手动配对", + "ipLabel": "IP", + "portLabel": "端口" } diff --git a/Manager/lib/models/order.dart b/Manager/lib/models/order.dart index ce15b75..b2f26b3 100644 --- a/Manager/lib/models/order.dart +++ b/Manager/lib/models/order.dart @@ -27,6 +27,8 @@ class Order { final double totalAmount; final int timestamp; final bool synced; + final bool alipayNotifyCheckedAmount; + final bool wechatNotifyCheckedAmount; Order({ required this.id, @@ -34,9 +36,22 @@ class Order { required this.totalAmount, required this.timestamp, this.synced = false, + this.alipayNotifyCheckedAmount = false, + this.wechatNotifyCheckedAmount = false, }); factory Order.fromJson(Map json) { + bool asBool(Object? v) { + if (v == null) return false; + if (v is bool) return v; + if (v is num) return v != 0; + final s = v.toString().trim().toLowerCase(); + if (s == 'true') return true; + final n = num.tryParse(s); + if (n != null) return n != 0; + return false; + } + return Order( id: json['id'], items: (json['items'] as List) @@ -45,6 +60,8 @@ class Order { totalAmount: (json['total_amount'] as num).toDouble(), timestamp: json['timestamp'], synced: json['synced'] == true || json['synced'] == 1, + alipayNotifyCheckedAmount: asBool(json['alipay_notify_checked_amount']), + wechatNotifyCheckedAmount: asBool(json['wechat_notify_checked_amount']), ); } } diff --git a/Manager/lib/screens/kiosk_history_screen.dart b/Manager/lib/screens/kiosk_history_screen.dart index ad82697..0132b92 100644 --- a/Manager/lib/screens/kiosk_history_screen.dart +++ b/Manager/lib/screens/kiosk_history_screen.dart @@ -101,6 +101,11 @@ class _KioskHistoryScreenState extends State { itemBuilder: (context, index) { final order = _orders[index]; final date = DateTime.fromMillisecondsSinceEpoch(order.timestamp); + final method = order.alipayNotifyCheckedAmount + ? l10n.paymentMethodAlipay + : order.wechatNotifyCheckedAmount + ? l10n.paymentMethodWechat + : l10n.paymentMethodPending; return ExpansionTile( leading: CircleAvatar( @@ -110,7 +115,13 @@ class _KioskHistoryScreenState extends State { l10n.orderNumber(order.id.substring(order.id.length - 4)), style: const TextStyle(fontWeight: FontWeight.bold), ), - subtitle: Text(DateFormat('yyyy-MM-dd HH:mm').format(date)), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(DateFormat('yyyy-MM-dd HH:mm').format(date)), + Text(l10n.payMethodLabel(method)), + ], + ), trailing: Text( currencyFormat.format(order.totalAmount), style: const TextStyle( diff --git a/Manager/lib/screens/sync_kiosk_screen.dart b/Manager/lib/screens/sync_kiosk_screen.dart index 1c432b6..8949998 100644 --- a/Manager/lib/screens/sync_kiosk_screen.dart +++ b/Manager/lib/screens/sync_kiosk_screen.dart @@ -203,12 +203,12 @@ class _SyncKioskScreenState extends State { TextField( controller: ipController, keyboardType: TextInputType.url, - decoration: const InputDecoration(labelText: 'IP'), + decoration: InputDecoration(labelText: l10n.ipLabel), ), TextField( controller: portController, keyboardType: TextInputType.number, - decoration: const InputDecoration(labelText: 'Port'), + decoration: InputDecoration(labelText: l10n.portLabel), ), ], ), @@ -271,7 +271,7 @@ class _SyncKioskScreenState extends State { const SizedBox(height: 12), TextButton( onPressed: _manualPair, - child: const Text('Manual Pair'), + child: Text(l10n.manualPair), ), ], ), From ee039d0b916dc0efe5ce9e684f10be8d891d8930 Mon Sep 17 00:00:00 2001 From: TachibanaLolo Date: Fri, 23 Jan 2026 22:50:46 +0800 Subject: [PATCH 11/12] feat: multi payment QR support and checkout UX --- Kiosk/lib/db/database_helper.dart | 12 + Kiosk/lib/l10n/app_en.arb | 6 + Kiosk/lib/l10n/app_localizations.dart | 36 +++ Kiosk/lib/l10n/app_localizations_en.dart | 20 ++ Kiosk/lib/l10n/app_localizations_zh.dart | 18 ++ Kiosk/lib/l10n/app_zh.arb | 6 + Kiosk/lib/screens/main_screen.dart | 48 ++-- Kiosk/lib/screens/payment_screen.dart | 208 ++++++++++++++---- Kiosk/lib/services/server/kiosk_server.dart | 40 +++- Kiosk/lib/services/settings_service.dart | 53 ++++- Manager/lib/l10n/app_en.arb | 10 +- Manager/lib/l10n/app_localizations.dart | 48 ++++ Manager/lib/l10n/app_localizations_en.dart | 26 +++ Manager/lib/l10n/app_localizations_zh.dart | 26 +++ Manager/lib/l10n/app_zh.arb | 10 +- Manager/lib/screens/product_form_screen.dart | 84 ++++++- Manager/lib/screens/qr_upload_screen.dart | 182 +++++++++++---- .../services/kiosk_client/kiosk_client.dart | 57 +++++ 18 files changed, 773 insertions(+), 117 deletions(-) diff --git a/Kiosk/lib/db/database_helper.dart b/Kiosk/lib/db/database_helper.dart index dbba37f..97e653a 100644 --- a/Kiosk/lib/db/database_helper.dart +++ b/Kiosk/lib/db/database_helper.dart @@ -145,6 +145,18 @@ class DatabaseHelper { return maps.map((map) => Order.fromMap(map)).toList(); } + Future getOrderById(String id) async { + final db = await instance.database; + final maps = await db.query( + 'orders', + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + if (maps.isEmpty) return null; + return Order.fromMap(maps.first); + } + Future getLatestPendingAlipayOrder({Duration maxAge = const Duration(minutes: 30)}) async { final db = await instance.database; final maps = await db.query( diff --git a/Kiosk/lib/l10n/app_en.arb b/Kiosk/lib/l10n/app_en.arb index d2d0a04..7b2dbe0 100644 --- a/Kiosk/lib/l10n/app_en.arb +++ b/Kiosk/lib/l10n/app_en.arb @@ -14,6 +14,12 @@ "cancel": "Cancel", "confirm": "Confirm", "invalidPin": "Invalid PIN", + "ok": "OK", + "amountMismatchWaiting": "Amount mismatch, still waiting for payment...", + "paymentTimeoutTitle": "Payment timeout", + "paymentTimeoutContent": "Auto confirmation timed out. Manual/admin handling required.", + "paymentMethodAlipay": "Alipay", + "paymentMethodWechat": "WeChat", "addedProduct": "Added {product}", "productNotFound": "Can't find product {barcode}", "scanError": "Scan error: {error}", diff --git a/Kiosk/lib/l10n/app_localizations.dart b/Kiosk/lib/l10n/app_localizations.dart index e48388a..59d356c 100644 --- a/Kiosk/lib/l10n/app_localizations.dart +++ b/Kiosk/lib/l10n/app_localizations.dart @@ -188,6 +188,42 @@ abstract class AppLocalizations { /// **'Invalid PIN'** String get invalidPin; + /// No description provided for @ok. + /// + /// In en, this message translates to: + /// **'OK'** + String get ok; + + /// No description provided for @amountMismatchWaiting. + /// + /// In en, this message translates to: + /// **'Amount mismatch, still waiting for payment...'** + String get amountMismatchWaiting; + + /// No description provided for @paymentTimeoutTitle. + /// + /// In en, this message translates to: + /// **'Payment timeout'** + String get paymentTimeoutTitle; + + /// No description provided for @paymentTimeoutContent. + /// + /// In en, this message translates to: + /// **'Auto confirmation timed out. Manual/admin handling required.'** + String get paymentTimeoutContent; + + /// No description provided for @paymentMethodAlipay. + /// + /// In en, this message translates to: + /// **'Alipay'** + String get paymentMethodAlipay; + + /// No description provided for @paymentMethodWechat. + /// + /// In en, this message translates to: + /// **'WeChat'** + String get paymentMethodWechat; + /// No description provided for @addedProduct. /// /// In en, this message translates to: diff --git a/Kiosk/lib/l10n/app_localizations_en.dart b/Kiosk/lib/l10n/app_localizations_en.dart index f2dab5e..59d6c0e 100644 --- a/Kiosk/lib/l10n/app_localizations_en.dart +++ b/Kiosk/lib/l10n/app_localizations_en.dart @@ -57,6 +57,26 @@ class AppLocalizationsEn extends AppLocalizations { @override String get invalidPin => 'Invalid PIN'; + @override + String get ok => 'OK'; + + @override + String get amountMismatchWaiting => + 'Amount mismatch, still waiting for payment...'; + + @override + String get paymentTimeoutTitle => 'Payment timeout'; + + @override + String get paymentTimeoutContent => + 'Auto confirmation timed out. Manual/admin handling required.'; + + @override + String get paymentMethodAlipay => 'Alipay'; + + @override + String get paymentMethodWechat => 'WeChat'; + @override String addedProduct(Object product) { return 'Added $product'; diff --git a/Kiosk/lib/l10n/app_localizations_zh.dart b/Kiosk/lib/l10n/app_localizations_zh.dart index ac6a199..80206fc 100644 --- a/Kiosk/lib/l10n/app_localizations_zh.dart +++ b/Kiosk/lib/l10n/app_localizations_zh.dart @@ -57,6 +57,24 @@ class AppLocalizationsZh extends AppLocalizations { @override String get invalidPin => '无效的PIN码'; + @override + String get ok => '确定'; + + @override + String get amountMismatchWaiting => '金额不匹配,仍在等待支付...'; + + @override + String get paymentTimeoutTitle => '支付超时'; + + @override + String get paymentTimeoutContent => '自动确认超时,需要人工/管理员处理。'; + + @override + String get paymentMethodAlipay => '支付宝'; + + @override + String get paymentMethodWechat => '微信'; + @override String addedProduct(Object product) { return '已添加 $product'; diff --git a/Kiosk/lib/l10n/app_zh.arb b/Kiosk/lib/l10n/app_zh.arb index 5c4c18d..e06d87f 100644 --- a/Kiosk/lib/l10n/app_zh.arb +++ b/Kiosk/lib/l10n/app_zh.arb @@ -14,6 +14,12 @@ "cancel": "取消", "confirm": "确认", "invalidPin": "无效的PIN码", + "ok": "确定", + "amountMismatchWaiting": "金额不匹配,仍在等待支付...", + "paymentTimeoutTitle": "支付超时", + "paymentTimeoutContent": "自动确认超时,需要人工/管理员处理。", + "paymentMethodAlipay": "支付宝", + "paymentMethodWechat": "微信", "addedProduct": "已添加 {product}", "productNotFound": "未找到商品 {barcode}", "scanError": "扫描出错: {error}", diff --git a/Kiosk/lib/screens/main_screen.dart b/Kiosk/lib/screens/main_screen.dart index e582b44..898ddf2 100644 --- a/Kiosk/lib/screens/main_screen.dart +++ b/Kiosk/lib/screens/main_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -12,6 +13,7 @@ import 'package:kiosk/l10n/app_localizations.dart'; import 'package:kiosk/config/store_config.dart'; import 'package:kiosk/services/restore_notifier.dart'; import 'package:kiosk/services/android_notification_listener_service.dart'; +import 'package:kiosk/services/settings_service.dart'; // Helper class for cart items class CartItem { @@ -85,6 +87,7 @@ class MainScreen extends StatefulWidget { class _MainScreenState extends State { final DatabaseHelper _db = DatabaseHelper.instance; + final SettingsService _settingsService = SettingsService(); final Map _cartItems = {}; // Use Map for O(1) lookups bool _isProcessing = false; final RestoreNotifier _restoreNotifier = RestoreNotifier.instance; @@ -174,30 +177,40 @@ class _MainScreenState extends State { Future _resumePendingPaymentIfAny() async { if (!mounted) return; - final pending = await _db.getLatestPendingAlipayOrder(); - if (pending == null) return; + final pendingId = _settingsService.getPendingPaymentOrderId(); + if (pendingId == null) return; + final order = await _db.getOrderById(pendingId); + if (order == null) { + await _settingsService.setPendingPaymentOrderId(null); + return; + } + final paid = order.alipayNotifyCheckedAmount || order.wechatNotifyCheckedAmount; + if (paid) { + await _settingsService.setPendingPaymentOrderId(null); + return; + } + final checkoutTimeMs = + order.alipayCheckoutTimeMs ?? order.wechatCheckoutTimeMs ?? order.timestamp; + final nowMs = DateTime.now().millisecondsSinceEpoch; + if (nowMs - checkoutTimeMs > const Duration(minutes: 30).inMilliseconds) { + await _settingsService.setPendingPaymentOrderId(null); + return; + } if (!mounted) return; Navigator.push( context, MaterialPageRoute( builder: (_) => PaymentScreen( - totalAmount: pending.totalAmount, - orderId: pending.id, - checkoutTimeMs: pending.alipayCheckoutTimeMs ?? pending.timestamp, + totalAmount: order.totalAmount, + orderId: order.id, + checkoutTimeMs: checkoutTimeMs, baselineKeys: const [], autoConfirmEnabled: true, onPaymentConfirmed: () { if (!mounted) return; - final l10n = AppLocalizations.of(context)!; final navigator = Navigator.of(context); - final messenger = ScaffoldMessenger.of(context); + unawaited(_settingsService.setPendingPaymentOrderId(null)); navigator.popUntil((route) => route.isFirst); - messenger.showSnackBar( - SnackBar( - content: Text(l10n.paymentSuccess), - duration: const Duration(seconds: 2), - ), - ); }, ), ), @@ -397,6 +410,8 @@ class _MainScreenState extends State { } _isProcessing = false; + if (!mounted) return; + await _settingsService.setPendingPaymentOrderId(order.id); if (!mounted) return; Navigator.push( context, @@ -408,15 +423,12 @@ class _MainScreenState extends State { baselineKeys: baselineKeys.toList(), autoConfirmEnabled: autoConfirmEnabled, onPaymentConfirmed: () { - final l10n = AppLocalizations.of(context)!; + if (!mounted) return; final navigator = Navigator.of(context); - final messenger = ScaffoldMessenger.of(context); + unawaited(_settingsService.setPendingPaymentOrderId(null)); _clearCart(); navigator.pop(); - messenger.showSnackBar( - SnackBar(content: Text(l10n.paymentSuccess)), - ); }, ), ), diff --git a/Kiosk/lib/screens/payment_screen.dart b/Kiosk/lib/screens/payment_screen.dart index 478348a..bf898a6 100644 --- a/Kiosk/lib/screens/payment_screen.dart +++ b/Kiosk/lib/screens/payment_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; @@ -34,18 +35,36 @@ class _PaymentScreenState extends State { final PaymentNotificationWatchService _watchService = PaymentNotificationWatchService(); final AndroidNotificationListenerService _notificationListenerService = AndroidNotificationListenerService(); - String? _qrData; + Map _qrs = {}; bool _isLoading = true; int _adminTapCount = 0; bool _autoConfirmStarted = false; + bool _confirmed = false; + bool _showPaymentSuccess = false; @override void initState() { super.initState(); + unawaited(_settingsService.setPendingPaymentOrderId(widget.orderId)); _loadQrCode(); _startAutoConfirm(); } + void _confirmPayment() { + if (_confirmed) return; + _confirmed = true; + unawaited(_settingsService.setPendingPaymentOrderId(null)); + if (mounted) { + setState(() => _showPaymentSuccess = true); + } + unawaited( + Future.delayed(const Duration(seconds: 2), () { + if (!mounted) return; + widget.onPaymentConfirmed(); + }), + ); + } + Future _startAutoConfirm() async { if (_autoConfirmStarted) return; _autoConfirmStarted = true; @@ -85,26 +104,28 @@ class _PaymentScreenState extends State { baselineKeys: baseline, onMatched: () { if (!mounted) return; - widget.onPaymentConfirmed(); + _confirmPayment(); }, onMismatch: (message) { if (!mounted) return; + final l10n = AppLocalizations.of(context)!; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Amount mismatch, still waiting for payment...')), + SnackBar(content: Text(l10n.amountMismatchWaiting)), ); }, onTimeout: () async { if (!mounted) return; + final l10n = AppLocalizations.of(context)!; await showDialog( context: context, builder: (context) { return AlertDialog( - title: const Text('Payment timeout'), - content: const Text('Auto confirmation timed out. Manual/admin handling required.'), + title: Text(l10n.paymentTimeoutTitle), + content: Text(l10n.paymentTimeoutContent), actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('OK'), + child: Text(l10n.ok), ), ], ); @@ -117,14 +138,20 @@ class _PaymentScreenState extends State { @override void dispose() { _watchService.stop(); + if (!_confirmed) { + final pendingId = _settingsService.getPendingPaymentOrderId(); + if (pendingId == widget.orderId) { + unawaited(_settingsService.setPendingPaymentOrderId(null)); + } + } super.dispose(); } Future _loadQrCode() async { - final qrData = _settingsService.getPaymentQr(); + final qrs = _settingsService.getPaymentQrs(); if (mounted) { setState(() { - _qrData = qrData; + _qrs = qrs; _isLoading = false; }); } @@ -161,7 +188,7 @@ class _PaymentScreenState extends State { // Hardcoded PIN for now: 1234 if (pinController.text == '1234') { Navigator.pop(context); - widget.onPaymentConfirmed(); + _confirmPayment(); } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.invalidPin)), @@ -175,11 +202,48 @@ class _PaymentScreenState extends State { ); } + Widget _buildQrTile(String provider, String base64Qr) { + final l10n = AppLocalizations.of(context)!; + final label = provider == 'alipay' + ? l10n.paymentMethodAlipay + : provider == 'wechat' + ? l10n.paymentMethodWechat + : provider; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + GestureDetector( + onTap: _handleAdminTap, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Image.memory( + base64Decode(base64Qr), + width: 220, + height: 220, + fit: BoxFit.contain, + ), + ), + ), + ], + ); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final theme = Theme.of(context); final currencyFormat = NumberFormat.currency(symbol: '¥'); + final providers = _qrs.keys.toList()..sort(); return Scaffold( // backgroundColor: Colors.black, // Use theme @@ -188,49 +252,99 @@ class _PaymentScreenState extends State { // backgroundColor: Colors.black, // Use theme // foregroundColor: Colors.white, // Use theme ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - l10n.totalWithAmount(currencyFormat.format(widget.totalAmount)), - style: theme.textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 40), - if (_isLoading) - const CircularProgressIndicator() - else if (_qrData != null) - GestureDetector( - onTap: _handleAdminTap, - child: Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white, // QR codes need white background - borderRadius: BorderRadius.circular(12), + body: Stack( + children: [ + LayoutBuilder( + builder: (context, constraints) { + Widget content; + if (_isLoading) { + content = const CircularProgressIndicator(); + } else if (providers.isEmpty) { + content = Text( + l10n.failedQr, + style: TextStyle(color: theme.colorScheme.error), + ); + } else { + content = Wrap( + spacing: 24, + runSpacing: 24, + alignment: WrapAlignment.center, + children: [ + for (final p in providers) _buildQrTile(p, _qrs[p]!), + ], + ); + } + + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.totalWithAmount(currencyFormat.format(widget.totalAmount)), + textAlign: TextAlign.center, + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 32), + content, + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + l10n.scanQr, + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), ), - child: Image.memory( - base64Decode(_qrData!), - width: 300, - height: 300, + ), + ); + }, + ), + if (_showPaymentSuccess) + Positioned.fill( + child: Container( + color: Colors.black.withValues(alpha: 0.65), + child: Center( + child: AnimatedScale( + scale: _showPaymentSuccess ? 1 : 0.9, + duration: const Duration(milliseconds: 300), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.check_circle, color: Colors.green, size: 96), + const SizedBox(height: 16), + Text( + l10n.paymentSuccess, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + l10n.returningHome, + style: const TextStyle(color: Colors.white70, fontSize: 16), + ), + ], + ), ), ), - ) - else - Text( - l10n.failedQr, - style: TextStyle(color: theme.colorScheme.error), - ), - const SizedBox(height: 40), - Text( - l10n.scanQr, - style: theme.textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.onSurfaceVariant, ), ), - ], - ), + ], ), ); } diff --git a/Kiosk/lib/services/server/kiosk_server.dart b/Kiosk/lib/services/server/kiosk_server.dart index c815dd6..d14abf6 100644 --- a/Kiosk/lib/services/server/kiosk_server.dart +++ b/Kiosk/lib/services/server/kiosk_server.dart @@ -225,17 +225,49 @@ class KioskServerService { try { final payload = await request.readAsString(); final Map data = jsonDecode(payload); - if (data.containsKey('data')) { - await _settingsService.setPaymentQr(data['data']); - return Response.ok(jsonEncode({'message': 'Payment QR updated'})); - } else { + final raw = data['data']; + if (raw is! String || raw.isEmpty) { return Response.badRequest(body: 'Missing "data" field'); } + + final provider = data['provider']?.toString().trim(); + if (provider != null && provider.isNotEmpty) { + await _settingsService.setPaymentQrForProvider(provider, raw); + return Response.ok(jsonEncode({'message': 'Payment QR updated', 'provider': provider})); + } + + await _settingsService.setPaymentQr(raw); + return Response.ok(jsonEncode({'message': 'Payment QR updated'})); } catch (e) { return Response.internalServerError(body: 'Failed to update QR: $e'); } }); + router.post('/payment_qrs', (Request request) async { + try { + final payload = await request.readAsString(); + final Map data = jsonDecode(payload); + final items = data['items']; + if (items is! Map) { + return Response.badRequest(body: 'Missing "items" field'); + } + + var updated = 0; + for (final entry in items.entries) { + final provider = entry.key?.toString().trim(); + final qr = entry.value?.toString(); + if (provider == null || provider.isEmpty) continue; + if (qr == null || qr.isEmpty) continue; + await _settingsService.setPaymentQrForProvider(provider, qr); + updated++; + } + + return Response.ok(jsonEncode({'message': 'Payment QRs updated', 'count': updated})); + } catch (e) { + return Response.internalServerError(body: 'Failed to update QRs: $e'); + } + }); + // Endpoint: Get Backup (Download DB) router.get('/backup', (Request request) async { try { diff --git a/Kiosk/lib/services/settings_service.dart b/Kiosk/lib/services/settings_service.dart index 099e70d..aa0cb0f 100644 --- a/Kiosk/lib/services/settings_service.dart +++ b/Kiosk/lib/services/settings_service.dart @@ -6,6 +6,8 @@ class SettingsService { static const String _pinKey = 'admin_pin'; static const String _deviceIdKey = 'device_id'; static const String _paymentQrKey = 'payment_qr'; + static const String _paymentQrsKey = 'payment_qrs'; + static const String _pendingPaymentOrderIdKey = 'pending_payment_order_id'; Future init() async { await Hive.openBox(_boxName); @@ -26,17 +28,66 @@ class SettingsService { } String? getPaymentQr() { - return _box.get(_paymentQrKey); + final qrs = getPaymentQrs(); + if (qrs.isEmpty) return _box.get(_paymentQrKey); + return qrs['alipay'] ?? qrs['wechat'] ?? qrs['default'] ?? qrs.values.first; } Future setPaymentQr(String base64) async { await _box.put(_paymentQrKey, base64); + final current = getPaymentQrs(); + current['default'] = base64; + await _box.put(_paymentQrsKey, current); + } + + Map getPaymentQrs() { + final v = _box.get(_paymentQrsKey); + if (v is Map) { + final result = {}; + for (final entry in v.entries) { + final k = entry.key?.toString(); + final val = entry.value?.toString(); + if (k == null || k.isEmpty) continue; + if (val == null || val.isEmpty) continue; + result[k] = val; + } + if (result.isNotEmpty) return result; + } + final legacy = _box.get(_paymentQrKey); + if (legacy is String && legacy.isNotEmpty) { + return {'default': legacy}; + } + return {}; + } + + Future setPaymentQrForProvider(String provider, String base64) async { + final key = provider.trim().toLowerCase(); + if (key.isEmpty) return; + final current = getPaymentQrs(); + current[key] = base64; + await _box.put(_paymentQrsKey, current); } String? getDeviceId() { return _box.get(_deviceIdKey); } + String? getPendingPaymentOrderId() { + final v = _box.get(_pendingPaymentOrderIdKey); + if (v is String && v.isNotEmpty) return v; + return null; + } + + Future setPendingPaymentOrderId(String? orderId) async { + final id = orderId?.trim(); + if (id == null || id.isEmpty) { + await _box.delete(_pendingPaymentOrderIdKey); + return; + } + await _box.put(_pendingPaymentOrderIdKey, id); + } + + Future getOrCreateDeviceId() async { final existing = getDeviceId(); if (existing != null && existing.isNotEmpty) { diff --git a/Manager/lib/l10n/app_en.arb b/Manager/lib/l10n/app_en.arb index 7c73a57..2a742b3 100644 --- a/Manager/lib/l10n/app_en.arb +++ b/Manager/lib/l10n/app_en.arb @@ -31,6 +31,7 @@ "barcodeRequired": "Please enter barcode", "nameRequired": "Please enter name", "priceRequired": "Please enter price", + "priceInvalid": "Please enter a valid price (max 2 decimals)", "backupRestore": "Backup & Restore", "createNewBackup": "Create New Backup", "noBackupsFound": "No backups found", @@ -83,5 +84,12 @@ "paymentMethodPending": "Pending", "manualPair": "Manual Pair", "ipLabel": "IP", - "portLabel": "Port" + "portLabel": "Port", + "addAlipayQr": "Add Alipay QR", + "addWechatQr": "Add WeChat QR", + "addCustomQr": "Add Custom QR", + "customPaymentMethodTitle": "Custom payment method", + "customPaymentMethodHint": "e.g. bank_xyz", + "customPaymentMethodInvalid": "Please enter a name", + "uploadedPaymentQrs": "Uploaded {count} payment QRs" } diff --git a/Manager/lib/l10n/app_localizations.dart b/Manager/lib/l10n/app_localizations.dart index 92321b9..25624d0 100644 --- a/Manager/lib/l10n/app_localizations.dart +++ b/Manager/lib/l10n/app_localizations.dart @@ -290,6 +290,12 @@ abstract class AppLocalizations { /// **'Please enter price'** String get priceRequired; + /// No description provided for @priceInvalid. + /// + /// In en, this message translates to: + /// **'Please enter a valid price (max 2 decimals)'** + String get priceInvalid; + /// No description provided for @backupRestore. /// /// In en, this message translates to: @@ -607,6 +613,48 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Port'** String get portLabel; + + /// No description provided for @addAlipayQr. + /// + /// In en, this message translates to: + /// **'Add Alipay QR'** + String get addAlipayQr; + + /// No description provided for @addWechatQr. + /// + /// In en, this message translates to: + /// **'Add WeChat QR'** + String get addWechatQr; + + /// No description provided for @addCustomQr. + /// + /// In en, this message translates to: + /// **'Add Custom QR'** + String get addCustomQr; + + /// No description provided for @customPaymentMethodTitle. + /// + /// In en, this message translates to: + /// **'Custom payment method'** + String get customPaymentMethodTitle; + + /// No description provided for @customPaymentMethodHint. + /// + /// In en, this message translates to: + /// **'e.g. bank_xyz'** + String get customPaymentMethodHint; + + /// No description provided for @customPaymentMethodInvalid. + /// + /// In en, this message translates to: + /// **'Please enter a name'** + String get customPaymentMethodInvalid; + + /// No description provided for @uploadedPaymentQrs. + /// + /// In en, this message translates to: + /// **'Uploaded {count} payment QRs'** + String uploadedPaymentQrs(Object count); } class _AppLocalizationsDelegate diff --git a/Manager/lib/l10n/app_localizations_en.dart b/Manager/lib/l10n/app_localizations_en.dart index 4e93f44..6e9ede8 100644 --- a/Manager/lib/l10n/app_localizations_en.dart +++ b/Manager/lib/l10n/app_localizations_en.dart @@ -110,6 +110,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get priceRequired => 'Please enter price'; + @override + String get priceInvalid => 'Please enter a valid price (max 2 decimals)'; + @override String get backupRestore => 'Backup & Restore'; @@ -290,4 +293,27 @@ class AppLocalizationsEn extends AppLocalizations { @override String get portLabel => 'Port'; + + @override + String get addAlipayQr => 'Add Alipay QR'; + + @override + String get addWechatQr => 'Add WeChat QR'; + + @override + String get addCustomQr => 'Add Custom QR'; + + @override + String get customPaymentMethodTitle => 'Custom payment method'; + + @override + String get customPaymentMethodHint => 'e.g. bank_xyz'; + + @override + String get customPaymentMethodInvalid => 'Please enter a name'; + + @override + String uploadedPaymentQrs(Object count) { + return 'Uploaded $count payment QRs'; + } } diff --git a/Manager/lib/l10n/app_localizations_zh.dart b/Manager/lib/l10n/app_localizations_zh.dart index a399e91..83f3a28 100644 --- a/Manager/lib/l10n/app_localizations_zh.dart +++ b/Manager/lib/l10n/app_localizations_zh.dart @@ -110,6 +110,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get priceRequired => '请输入价格'; + @override + String get priceInvalid => '请输入正确的价格(最多两位小数)'; + @override String get backupRestore => '备份与恢复'; @@ -287,4 +290,27 @@ class AppLocalizationsZh extends AppLocalizations { @override String get portLabel => '端口'; + + @override + String get addAlipayQr => '添加支付宝二维码'; + + @override + String get addWechatQr => '添加微信二维码'; + + @override + String get addCustomQr => '添加自定义二维码'; + + @override + String get customPaymentMethodTitle => '自定义支付方式'; + + @override + String get customPaymentMethodHint => '例如:bank_xyz'; + + @override + String get customPaymentMethodInvalid => '请输入名称'; + + @override + String uploadedPaymentQrs(Object count) { + return '已上传 $count 个支付二维码'; + } } diff --git a/Manager/lib/l10n/app_zh.arb b/Manager/lib/l10n/app_zh.arb index ab05c6d..07ba5a9 100644 --- a/Manager/lib/l10n/app_zh.arb +++ b/Manager/lib/l10n/app_zh.arb @@ -31,6 +31,7 @@ "barcodeRequired": "请输入条形码", "nameRequired": "请输入商品名称", "priceRequired": "请输入价格", + "priceInvalid": "请输入正确的价格(最多两位小数)", "backupRestore": "备份与恢复", "createNewBackup": "创建新备份", "noBackupsFound": "未找到备份", @@ -83,5 +84,12 @@ "paymentMethodPending": "待确认", "manualPair": "手动配对", "ipLabel": "IP", - "portLabel": "端口" + "portLabel": "端口", + "addAlipayQr": "添加支付宝二维码", + "addWechatQr": "添加微信二维码", + "addCustomQr": "添加自定义二维码", + "customPaymentMethodTitle": "自定义支付方式", + "customPaymentMethodHint": "例如:bank_xyz", + "customPaymentMethodInvalid": "请输入名称", + "uploadedPaymentQrs": "已上传 {count} 个支付二维码" } diff --git a/Manager/lib/screens/product_form_screen.dart b/Manager/lib/screens/product_form_screen.dart index 38d2576..0e32256 100644 --- a/Manager/lib/screens/product_form_screen.dart +++ b/Manager/lib/screens/product_form_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:manager/models/product.dart'; import 'package:manager/services/api_service.dart'; @@ -21,6 +22,7 @@ class _ProductFormScreenState extends State { final _barcodeController = TextEditingController(); final _nameController = TextEditingController(); final _priceController = TextEditingController(); + final _priceFocusNode = FocusNode(); final ApiService _apiService = ApiService(); final KioskConnectionService _connectionService = KioskConnectionService(); bool _isLoading = false; @@ -30,6 +32,11 @@ class _ProductFormScreenState extends State { void initState() { super.initState(); _connectionService.addListener(_onConnectionChange); + _priceFocusNode.addListener(() { + if (!_priceFocusNode.hasFocus) { + _normalizePriceOnBlur(); + } + }); if (widget.initialBarcode != null) { _barcodeController.text = widget.initialBarcode!; _loadProduct(widget.initialBarcode!); @@ -42,6 +49,7 @@ class _ProductFormScreenState extends State { _barcodeController.dispose(); _nameController.dispose(); _priceController.dispose(); + _priceFocusNode.dispose(); super.dispose(); } @@ -53,18 +61,57 @@ class _ProductFormScreenState extends State { setState(() => _isLoading = true); // 1. Try Local DB first - Product? product = await DatabaseHelper.instance.getProduct(barcode); + final localProduct = await DatabaseHelper.instance.getProduct(barcode); + Product? product = localProduct; // 2. If not found, try External API (AliCloud) product ??= await _apiService.getProduct(barcode); if (product != null) { _nameController.text = product.name; - _priceController.text = product.price.toString(); + if (localProduct != null) { + _priceController.text = product.price.toStringAsFixed(2); + } else { + _priceController.text = ''; + } } setState(() => _isLoading = false); } + bool _isPriceValidFinal(String input) { + final v = input.trim(); + if (v.isEmpty) return false; + if (v.contains(',')) return false; + if (v.startsWith('.')) return false; + if (v.endsWith('.')) return false; + final ok = RegExp(r'^\d+(\.\d{1,2})?$').hasMatch(v); + if (!ok) return false; + final n = double.tryParse(v); + if (n == null) return false; + if (n.isNaN || n.isInfinite) return false; + if (n < 0) return false; + return true; + } + + void _normalizePriceOnBlur() { + final raw = _priceController.text.trim(); + if (raw.isEmpty) return; + if (!_isPriceValidFinal(raw)) { + if (mounted) { + _formKey.currentState?.validate(); + } + return; + } + final n = double.parse(raw); + final formatted = n.toStringAsFixed(2); + if (_priceController.text != formatted) { + _priceController.value = TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); + } + } + Future _scanBarcode() async { // Navigate to a dedicated scanner screen or show modal // For simplicity in this iteration, we'll assume a modal approach @@ -105,8 +152,13 @@ class _ProductFormScreenState extends State { Future _saveProduct() async { if (_isSaving) return; - if (!_formKey.currentState!.validate()) return; final l10n = AppLocalizations.of(context)!; + if (!_formKey.currentState!.validate()) { + if (_priceController.text.trim().isEmpty || !_isPriceValidFinal(_priceController.text)) { + _priceFocusNode.requestFocus(); + } + return; + } final kiosk = _connectionService.connectedKiosk; if (kiosk == null) { ScaffoldMessenger.of(context).showSnackBar( @@ -115,11 +167,12 @@ class _ProductFormScreenState extends State { return; } + _normalizePriceOnBlur(); setState(() => _isSaving = true); final product = Product( barcode: _barcodeController.text, name: _nameController.text, - price: double.parse(_priceController.text), + price: double.parse(_priceController.text.trim()), lastUpdated: DateTime.now().millisecondsSinceEpoch, ); @@ -219,10 +272,27 @@ class _ProductFormScreenState extends State { TextFormField( controller: _priceController, decoration: InputDecoration(labelText: l10n.price), - keyboardType: TextInputType.number, + focusNode: _priceFocusNode, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + TextInputFormatter.withFunction((oldValue, newValue) { + final text = newValue.text; + if (text.isEmpty) return newValue; + if (text.contains(',')) return oldValue; + if (text.startsWith('.')) return oldValue; + if (!RegExp(r'^\d*\.?\d{0,2}$').hasMatch(text)) { + return oldValue; + } + return newValue; + }), + ], enabled: isConnected && !_isSaving, - validator: (value) => - value!.isEmpty ? l10n.priceRequired : null, + validator: (value) { + final v = value?.trim() ?? ''; + if (v.isEmpty) return l10n.priceRequired; + if (!_isPriceValidFinal(v)) return l10n.priceInvalid; + return null; + }, ), const SizedBox(height: 20), ElevatedButton( diff --git a/Manager/lib/screens/qr_upload_screen.dart b/Manager/lib/screens/qr_upload_screen.dart index 2af78fd..beaf45a 100644 --- a/Manager/lib/screens/qr_upload_screen.dart +++ b/Manager/lib/screens/qr_upload_screen.dart @@ -17,7 +17,7 @@ class _QrUploadScreenState extends State { final KioskClientService _kioskService = KioskClientService(); final KioskConnectionService _connectionService = KioskConnectionService(); final ImagePicker _picker = ImagePicker(); - File? _image; + final Map _imagesByProvider = {}; bool _isUploading = false; @override @@ -36,17 +36,73 @@ class _QrUploadScreenState extends State { if (mounted) setState(() {}); } - Future _pickImage() async { + Future _pickImage() async { final XFile? pickedFile = await _picker.pickImage(source: ImageSource.gallery); if (pickedFile != null) { - setState(() { - _image = File(pickedFile.path); - }); + return File(pickedFile.path); } + return null; } - Future _uploadImage() async { - if (_image == null) return; + Future _promptCustomProvider() async { + final l10n = AppLocalizations.of(context)!; + final controller = TextEditingController(); + String? errorText; + final result = await showDialog( + context: context, + barrierDismissible: true, + builder: (context) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + title: Text(l10n.customPaymentMethodTitle), + content: TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + hintText: l10n.customPaymentMethodHint, + errorText: errorText, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.cancel), + ), + TextButton( + onPressed: () { + final v = controller.text.trim().toLowerCase(); + if (v.isEmpty) { + setState(() => errorText = l10n.customPaymentMethodInvalid); + return; + } + Navigator.pop(context, v); + }, + child: Text(l10n.confirm), + ), + ], + ), + ), + ); + return result; + } + + Future _addProviderImage(String provider) async { + if (!_connectionService.hasConnectedKiosk) return; + final file = await _pickImage(); + if (file == null) return; + if (!mounted) return; + setState(() => _imagesByProvider[provider] = file); + } + + Future _addCustomProviderImage() async { + if (!_connectionService.hasConnectedKiosk) return; + final provider = await _promptCustomProvider(); + if (!mounted) return; + if (provider == null || provider.isEmpty) return; + await _addProviderImage(provider); + } + + Future _uploadImages() async { + if (_imagesByProvider.isEmpty) return; final connectedKiosk = _connectionService.connectedKiosk; if (connectedKiosk == null) { final l10n = AppLocalizations.of(context)!; @@ -58,13 +114,16 @@ class _QrUploadScreenState extends State { setState(() => _isUploading = true); try { - final bytes = await _image!.readAsBytes(); - final base64Image = base64Encode(bytes); - final success = await _kioskService.uploadPaymentQr( + final payload = {}; + for (final entry in _imagesByProvider.entries) { + final bytes = await entry.value.readAsBytes(); + payload[entry.key] = base64Encode(bytes); + } + final success = await _kioskService.uploadPaymentQrs( connectedKiosk.ip, connectedKiosk.port, connectedKiosk.pin, - base64Image, + payload, ); setState(() => _isUploading = false); @@ -73,7 +132,7 @@ class _QrUploadScreenState extends State { final l10n = AppLocalizations.of(context)!; if (success) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.uploadedToKiosks(1))), + SnackBar(content: Text(l10n.uploadedPaymentQrs(_imagesByProvider.length))), ); Navigator.pop(context); } else { @@ -97,40 +156,87 @@ class _QrUploadScreenState extends State { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final isConnected = _connectionService.hasConnectedKiosk; - final canUpload = isConnected && _image != null && !_isUploading; + final canUpload = isConnected && _imagesByProvider.isNotEmpty && !_isUploading; return Scaffold( appBar: AppBar(title: Text(l10n.uploadQr)), body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (!isConnected) - Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Text( - l10n.connectKioskToUpload, - textAlign: TextAlign.center, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + if (!isConnected) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text( + l10n.connectKioskToUpload, + textAlign: TextAlign.center, + ), ), + Wrap( + spacing: 12, + runSpacing: 12, + alignment: WrapAlignment.center, + children: [ + ElevatedButton( + onPressed: isConnected ? () => _addProviderImage('alipay') : null, + child: Text(l10n.addAlipayQr), + ), + ElevatedButton( + onPressed: isConnected ? () => _addProviderImage('wechat') : null, + child: Text(l10n.addWechatQr), + ), + ElevatedButton( + onPressed: isConnected ? _addCustomProviderImage : null, + child: Text(l10n.addCustomQr), + ), + ], ), - if (_image != null) - Image.file(_image!, height: 300) - else - const Icon(Icons.qr_code, size: 100, color: Colors.grey), - const SizedBox(height: 20), - ElevatedButton.icon( - onPressed: isConnected ? _pickImage : null, - icon: const Icon(Icons.image), - label: Text(l10n.selectImage), - ), - const SizedBox(height: 20), - if (_isUploading) - const CircularProgressIndicator() - else + const SizedBox(height: 16), + Expanded( + child: _imagesByProvider.isEmpty + ? const Center( + child: Icon(Icons.qr_code, size: 100, color: Colors.grey), + ) + : ListView( + children: _imagesByProvider.entries.map((e) { + final label = e.key == 'alipay' + ? l10n.paymentMethodAlipay + : e.key == 'wechat' + ? l10n.paymentMethodWechat + : e.key; + return Card( + child: ListTile( + title: Text(label), + subtitle: Padding( + padding: const EdgeInsets.only(top: 8), + child: Image.file(e.value, height: 140), + ), + trailing: IconButton( + tooltip: l10n.remove, + icon: const Icon(Icons.close), + onPressed: _isUploading + ? null + : () { + setState(() => _imagesByProvider.remove(e.key)); + }, + ), + ), + ); + }).toList(), + ), + ), + const SizedBox(height: 12), + if (_isUploading) + const Padding( + padding: EdgeInsets.only(bottom: 12), + child: CircularProgressIndicator(), + ), ElevatedButton( - onPressed: canUpload ? _uploadImage : null, + onPressed: canUpload ? _uploadImages : null, child: Text(l10n.uploadToServer), ), - ], + ], + ), ), ), ); diff --git a/Manager/lib/services/kiosk_client/kiosk_client.dart b/Manager/lib/services/kiosk_client/kiosk_client.dart index 5da69d3..465dc76 100644 --- a/Manager/lib/services/kiosk_client/kiosk_client.dart +++ b/Manager/lib/services/kiosk_client/kiosk_client.dart @@ -266,6 +266,63 @@ class KioskClientService { } } + Future uploadPaymentQrForProvider( + String ip, + int port, + String pin, + String provider, + String base64Image, + ) async { + final key = provider.trim().toLowerCase(); + if (key.isEmpty) return false; + try { + final response = await _client.post( + Uri.parse('http://$ip:$port/payment_qr'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $pin', + }, + body: jsonEncode({'provider': key, 'data': base64Image}), + ); + return response.statusCode == 200; + } catch (e) { + debugPrint('Error uploading provider QR to kiosk: $e'); + return false; + } + } + + Future uploadPaymentQrs( + String ip, + int port, + String pin, + Map providerToBase64, + ) async { + if (providerToBase64.isEmpty) return true; + final items = {}; + for (final entry in providerToBase64.entries) { + final k = entry.key.trim().toLowerCase(); + if (k.isEmpty) continue; + final v = entry.value; + if (v.isEmpty) continue; + items[k] = v; + } + if (items.isEmpty) return false; + try { + final response = await _client.post( + Uri.parse('http://$ip:$port/payment_qrs'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $pin', + }, + body: jsonEncode({'items': items}), + ); + return response.statusCode == 200; + } catch (e) { + debugPrint('Error uploading QRs to kiosk: $e'); + return false; + } + } + Future?> fetchOrders(String ip, int port, String pin) async { try { final response = await _client.get( From ba0364304be1900a5b6e343952798925a7a870c1 Mon Sep 17 00:00:00 2001 From: TachibanaLolo Date: Fri, 23 Jan 2026 22:50:52 +0800 Subject: [PATCH 12/12] chore: bump kiosk/manager to 1.0.0 --- Kiosk/pubspec.yaml | 2 +- Manager/pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Kiosk/pubspec.yaml b/Kiosk/pubspec.yaml index 15939e5..fbf2d5b 100644 --- a/Kiosk/pubspec.yaml +++ b/Kiosk/pubspec.yaml @@ -1,7 +1,7 @@ name: kiosk description: "Vending Machine Kiosk App" publish_to: 'none' -version: 0.4.0+6 +version: 1.0.0+7 environment: sdk: ^3.9.2 diff --git a/Manager/pubspec.yaml b/Manager/pubspec.yaml index fd5ec5e..e09c950 100644 --- a/Manager/pubspec.yaml +++ b/Manager/pubspec.yaml @@ -1,7 +1,7 @@ name: manager description: "Vending Machine Manager App" publish_to: 'none' -version: 0.4.0+6 +version: 1.0.0+7 environment: sdk: ^3.9.2