diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bdcf39e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.trae/ 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()) + "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) + } + 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 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()), + ), + ) + } + 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 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 { + 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..3d04c90 --- /dev/null +++ b/Kiosk/android/app/src/main/kotlin/com/secgo/kiosk/SecgoNotificationListenerService.kt @@ -0,0 +1,172 @@ +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 || sbn.packageName == WECHAT_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 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() + + 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_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()) + }, + ) + } + + 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 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() ?: "" + 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("收款到账")) return true + if (combined.contains("收款") && (combined.contains("元") || 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() + } + + 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" + 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_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/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..97e653a 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: 4, onCreate: _createDB, onUpgrade: _upgradeDB, ); @@ -46,7 +46,21 @@ 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, + 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 ) '''); } @@ -57,6 +71,28 @@ 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'); + } + 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 { @@ -109,6 +145,128 @@ 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( + '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 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, + 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; + } + + 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/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/models/order.dart b/Kiosk/lib/models/order.dart index 1af70b8..4f1b043 100644 --- a/Kiosk/lib/models/order.dart +++ b/Kiosk/lib/models/order.dart @@ -38,6 +38,20 @@ 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; + 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, @@ -45,6 +59,20 @@ 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, + this.wechatCheckoutTimeMs, + this.wechatNotifyCheckedAmount = false, + this.wechatMatchedKey, + this.wechatMatchedPostTimeMs, + this.wechatMatchedTitle, + this.wechatMatchedText, + this.wechatMatchedParsedAmountFen, }); Map toMap() { @@ -54,6 +82,20 @@ 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, + '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, }; } @@ -66,6 +108,20 @@ 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'], + 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'], ); } @@ -76,6 +132,20 @@ 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, + '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 c4175ae..898ddf2 100644 --- a/Kiosk/lib/screens/main_screen.dart +++ b/Kiosk/lib/screens/main_screen.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +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 +12,8 @@ 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'; +import 'package:kiosk/services/settings_service.dart'; // Helper class for cart items class CartItem { @@ -82,9 +87,12 @@ 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; + final AndroidNotificationListenerService _notificationListenerService = + AndroidNotificationListenerService(); // Use the front camera as requested. final MobileScannerController _scannerController = MobileScannerController( @@ -112,6 +120,9 @@ class _MainScreenState extends State { void initState() { super.initState(); _restoreNotifier.addListener(_handleRestore); + WidgetsBinding.instance.addPostFrameCallback((_) { + _resumePendingPaymentIfAny(); + }); } @override @@ -164,6 +175,48 @@ class _MainScreenState extends State { ); } + Future _resumePendingPaymentIfAny() async { + if (!mounted) 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: order.totalAmount, + orderId: order.id, + checkoutTimeMs: checkoutTimeMs, + baselineKeys: const [], + autoConfirmEnabled: true, + onPaymentConfirmed: () { + if (!mounted) return; + final navigator = Navigator.of(context); + unawaited(_settingsService.setPendingPaymentOrderId(null)); + navigator.popUntil((route) => route.isFirst); + }, + ), + ), + ); + } + Future _handleBarcodeDetect(BarcodeCapture capture) async { // Only process logic if not already processing if (_isProcessing) return; @@ -252,8 +305,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,31 +319,116 @@ class _MainScreenState extends State { )) .toList(), totalAmount: _totalAmount, - timestamp: DateTime.now().millisecondsSinceEpoch, + timestamp: checkoutTimeMs, + alipayCheckoutTimeMs: checkoutTimeMs, + wechatCheckoutTimeMs: 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 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 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('$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( + 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; + await _settingsService.setPendingPaymentOrderId(order.id); if (!mounted) return; Navigator.push( context, MaterialPageRoute( builder: (_) => PaymentScreen( totalAmount: _totalAmount, + orderId: order.id, + checkoutTimeMs: checkoutTimeMs, + 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 5b57206..bf898a6 100644 --- a/Kiosk/lib/screens/payment_screen.dart +++ b/Kiosk/lib/screens/payment_screen.dart @@ -1,17 +1,28 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:intl/intl.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'; 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,21 +32,126 @@ class PaymentScreen extends StatefulWidget { class _PaymentScreenState extends State { final SettingsService _settingsService = SettingsService(); - String? _qrData; + final PaymentNotificationWatchService _watchService = PaymentNotificationWatchService(); + final AndroidNotificationListenerService _notificationListenerService = + AndroidNotificationListenerService(); + 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; + final enabled = await _notificationListenerService.isEnabled(); + if (!enabled) return; + final baseline = widget.baselineKeys.toSet(); + 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('$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( + '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; + _confirmPayment(); + }, + onMismatch: (message) { + if (!mounted) return; + final l10n = AppLocalizations.of(context)!; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.amountMismatchWaiting)), + ); + }, + onTimeout: () async { + if (!mounted) return; + final l10n = AppLocalizations.of(context)!; + await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(l10n.paymentTimeoutTitle), + content: Text(l10n.paymentTimeoutContent), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.ok), + ), + ], + ); + }, + ); + }, + ); + } + + @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; }); } @@ -72,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)), @@ -86,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 @@ -99,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/android_notification_listener_service.dart b/Kiosk/lib/services/android_notification_listener_service.dart new file mode 100644 index 0000000..ce41f98 --- /dev/null +++ b/Kiosk/lib/services/android_notification_listener_service.dart @@ -0,0 +1,117 @@ +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> 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 + .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?> 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 + .invokeMethod('getActiveAlipayNotificationsSnapshot') + .then((v) => (v ?? const []).map((e) => Map.from(e as Map)).toList()); + 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/payment_notification_watch_service.dart b/Kiosk/lib/services/payment_notification_watch_service.dart new file mode 100644 index 0000000..b50df75 --- /dev/null +++ b/Kiosk/lib/services/payment_notification_watch_service.dart @@ -0,0 +1,297 @@ +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 PaymentNotificationWatchResult { + final bool matched; + final bool timedOut; + final bool amountMismatched; + final String? mismatchText; + + const PaymentNotificationWatchResult._({ + required this.matched, + required this.timedOut, + required this.amountMismatched, + this.mismatchText, + }); + + const PaymentNotificationWatchResult.matched() + : this._(matched: true, timedOut: false, amountMismatched: false); + + const PaymentNotificationWatchResult.timedOut() + : this._(matched: false, timedOut: true, amountMismatched: false); + + const PaymentNotificationWatchResult.amountMismatched(String text) + : this._( + matched: false, + timedOut: false, + amountMismatched: true, + mismatchText: text, + ); +} + +class PaymentNotificationWatchService { + static const String alipayPackage = 'com.eg.android.AlipayGphone'; + static const String wechatPackage = 'com.tencent.mm'; + + PaymentNotificationWatchService({ + 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 keys = {}; + 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(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; + } + + 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( + 'PaymentWatch start orderId=$orderId expectedFen=$expectedFen checkoutTimeMs=$checkoutTimeMs baselineKeys=${baselineKeys.length}', + ); + _timeoutTimer = Timer(const Duration(minutes: 5), () async { + if (!_active) return; + await stop(); + debugPrint('PaymentWatch 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 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, + 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 != 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[providerKey]; + if (seenPostTime != null && postTime <= seenPostTime) return; + _seenMaxPostTimeByKey[providerKey] = postTime; + + final title = _toStrOrNull(notification['title']); + final text = _toStrOrNull(notification['text']); + final bigText = _toStrOrNull(notification['bigText']); + final combined = [title, text, bigText].whereType().join(' '); + final parsedFen = providerPackage == alipayPackage + ? parseAlipaySuccessAmountFen(combined) + : parseWeChatSuccessAmountFen(combined); + if (parsedFen == null) { + if (_loggedParseFailureKeys.add(providerKey)) { + debugPrint( + 'PaymentWatch parseFailed orderId=$orderId provider=$providerPackage key=$key postTime=$postTime combined=$combined', + ); + } + return; + } + + if (parsedFen != expectedFen) { + debugPrint( + '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 = providerPackage == alipayPackage + ? await _db.isAlipayNotificationKeyAlreadyUsed(key) + : await _db.isWechatNotificationKeyAlreadyUsed(key); + if (used) return; + + debugPrint( + '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; + 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? 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; + 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'); + } + + 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; + } +} diff --git a/Kiosk/lib/services/server/kiosk_server.dart b/Kiosk/lib/services/server/kiosk_server.dart index d388c2c..d14abf6 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,66 @@ 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'}, + ); + }); + + 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 { @@ -162,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/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/Kiosk/test/alipay_amount_parser_test.dart b/Kiosk/test/alipay_amount_parser_test.dart new file mode 100644 index 0000000..d914ce0 --- /dev/null +++ b/Kiosk/test/alipay_amount_parser_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kiosk/services/payment_notification_watch_service.dart'; + +void main() { + group('AlipayAmountParser', () { + test('parses common success format', () { + final fen = PaymentNotificationWatchService.parseAlipaySuccessAmountFen( + '支付宝成功收款0.01元,点击查看。', + ); + expect(fen, 1); + }); + + test('parses with currency symbol and spaces', () { + final fen = PaymentNotificationWatchService.parseAlipaySuccessAmountFen( + '店员通 支付宝成功收款 ¥3.00 元', + ); + expect(fen, 300); + }); + + test('parses integer amount', () { + final fen = PaymentNotificationWatchService.parseAlipaySuccessAmountFen( + '支付宝成功收款3元,点击查看。', + ); + 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); + }); + }); +} 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 @@ 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/l10n/app_en.arb b/Manager/lib/l10n/app_en.arb index 0cbb722..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", @@ -76,5 +77,19 @@ "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", + "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 3c64776..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: @@ -565,6 +571,90 @@ 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; + + /// 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 0a0e16f..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'; @@ -267,4 +270,50 @@ 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'; + + @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 e91ebbf..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 => '备份与恢复'; @@ -264,4 +267,50 @@ 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 => '端口'; + + @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 d5dec9c..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": "未找到备份", @@ -76,5 +77,19 @@ "enterPinTitle": "输入PIN", "enterPinHint": "终端PIN", "pinLength": "PIN至少4位", - "confirm": "确认" + "confirm": "确认", + "payMethodLabel": "支付方式:{method}", + "paymentMethodAlipay": "支付宝", + "paymentMethodWechat": "微信", + "paymentMethodPending": "待确认", + "manualPair": "手动配对", + "ipLabel": "IP", + "portLabel": "端口", + "addAlipayQr": "添加支付宝二维码", + "addWechatQr": "添加微信二维码", + "addCustomQr": "添加自定义二维码", + "customPaymentMethodTitle": "自定义支付方式", + "customPaymentMethodHint": "例如:bank_xyz", + "customPaymentMethodInvalid": "请输入名称", + "uploadedPaymentQrs": "已上传 {count} 个支付二维码" } 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/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/screens/sync_kiosk_screen.dart b/Manager/lib/screens/sync_kiosk_screen.dart index aa79643..8949998 100644 --- a/Manager/lib/screens/sync_kiosk_screen.dart +++ b/Manager/lib/screens/sync_kiosk_screen.dart @@ -15,19 +15,47 @@ 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')) { - 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; + } + debugPrint('SyncKioskScreen parsed ip=$ip port=$port'); final deviceId = data['deviceId'] as String?; setState(() { _isSyncing = true; @@ -46,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(() { @@ -118,8 +147,13 @@ class _SyncKioskScreenState extends State { _statusMessage = l10n.pairingKiosk(ip); }); - final fetchedDeviceId = await _kioskService.fetchDeviceId(ip, port, pin); + 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) { @@ -142,11 +176,68 @@ class _SyncKioskScreenState extends State { } else { setState(() { _isSyncing = false; - _statusMessage = l10n.pairFailed; + final err = debugResult.error; + _statusMessage = err == null || err.isEmpty ? l10n.pairFailed : err; }); } } + 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: InputDecoration(labelText: l10n.ipLabel), + ), + TextField( + controller: portController, + keyboardType: TextInputType.number, + decoration: InputDecoration(labelText: l10n.portLabel), + ), + ], + ), + 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)!; @@ -157,6 +248,7 @@ class _SyncKioskScreenState extends State { Expanded( flex: 2, child: MobileScanner( + controller: _scannerController, onDetect: _onQrDetect, ), ), @@ -172,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: Text(l10n.manualPair), + ), + ], + ), ), ), ], diff --git a/Manager/lib/services/kiosk_client/kiosk_client.dart b/Manager/lib/services/kiosk_client/kiosk_client.dart index 3454d8a..465dc76 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) { @@ -49,6 +84,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); } @@ -181,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( 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.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 cacea7e..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 @@ -28,8 +28,6 @@ dependencies: sqflite: ^2.4.2 dev_dependencies: - integration_test: - sdk: flutter flutter_test: sdk: flutter flutter_lints: ^5.0.0