diff --git a/.gitignore b/.gitignore
index bdcf39e..38cffcc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
.trae/
+.gradle*
\ No newline at end of file
diff --git a/.trae/documents/Create todo.md for Version 0.1.0.md b/.trae/documents/Create todo.md for Version 0.1.0.md
deleted file mode 100644
index 4f5b0d1..0000000
--- a/.trae/documents/Create todo.md for Version 0.1.0.md
+++ /dev/null
@@ -1,47 +0,0 @@
-I have analyzed the `constitution.md`, `DESIGN.md`, and `IMPLEMENTATION.md` files. It appears we are at the very beginning of the project (Phase 1).
-
-I propose creating a `todo.md` file that outlines the tasks for **Version 0.1.0**, mirroring the structure of your `IMPLEMENTATION.md` but in a checklist format for easy tracking.
-
-### Plan for `todo.md`
-
-I will create a file named `todo.md` with the following content:
-
-```markdown
-# Todo List
-
-## Version 0.1.0
-
-### Phase 1: Project Scaffolding
-- [ ] **Server:** Create a new binary Rust project in `Server/` (`cargo new Server --bin`).
-- [ ] **Manager App:** Create a new Flutter project in `Manager/` (`flutter create --template=app --empty`).
-- [ ] **Kiosk App:** Create a new Flutter project in `Kiosk/` (`flutter create --template=app --empty`).
-- [ ] **Metadata:** Update `pubspec.yaml` and `Cargo.toml` to version `0.1.0`.
-- [ ] **Documentation:** Update `README.md` and create `CHANGELOG.md` for all projects.
-- [ ] **Commit:** Initial commit of the project shells.
-
-### Phase 2: Server API Implementation (Rust)
-- [ ] **Dependencies:** Add `actix-web`, `serde`, `rusqlite`, `reqwest`.
-- [ ] **Models:** Define `Product` and `PaymentQrCode` structs.
-- [ ] **Database:** Setup SQLite connection and tables.
-- [ ] **API:** Implement endpoints (`/products`, `/payment_qr`).
-- [ ] **Logic:** Implement external API lookup for products.
-- [ ] **Tests:** Write unit tests for API handlers.
-
-### Phase 3: Manager App - Product Management UI
-- [ ] **Dependencies:** Add `mobile_scanner`, `http`, `hive`.
-- [ ] **Service:** Create API service for server communication.
-- [ ] **UI:** Build Product List, Add/Edit Product (with scanning), and QR Code Upload screens.
-- [ ] **Cache:** Implement local caching with Hive.
-
-### Phase 4: Kiosk App - Customer Checkout Flow
-- [ ] **Dependencies:** Add `mobile_scanner`, `qr_flutter`, `http`, `hive`.
-- [ ] **Theme:** Implement OLED-optimized dark theme.
-- [ ] **UI:** Build Main Scanning screen, Payment QR display, and Admin Confirmation.
-
-### Phase 5: Finalization
-- [ ] **Docs:** Create comprehensive `README.md` for each project.
-- [ ] **Overview:** Create `GEMINI.md` describing the architecture.
-- [ ] **Review:** Final verification and user approval.
-```
-
-Does this look correct to you? If you approve, I will proceed to create this file.
\ No newline at end of file
diff --git a/.trae/documents/Plan for Version 0.2.0_ I18n, Dual Backup DB, and Advanced Sync.md b/.trae/documents/Plan for Version 0.2.0_ I18n, Dual Backup DB, and Advanced Sync.md
deleted file mode 100644
index df67ec3..0000000
--- a/.trae/documents/Plan for Version 0.2.0_ I18n, Dual Backup DB, and Advanced Sync.md
+++ /dev/null
@@ -1,15 +0,0 @@
-I will implement Version 0.2.0 with the following plan:
-
-1. **Documentation Update:** Update `DESIGN.md` and `todo.md` to reflect the new architecture (Dual Backup, I18n, Sync).
-2. **Phase 6: Internationalization (I18n):**
- * Add `flutter_localizations`, `intl` to Kiosk and Manager.
- * Create base localization files (English).
-3. **Phase 7: Database Migration & Dual Backup:**
- * **Server:** Update API to support sync (e.g., bulk product fetch, order upload).
- * **Kiosk/Manager:** Migrate from Hive to `sqflite` (SQLite) to match Server schema.
- * **Sync Logic:** Implement logic to fetch data from Server and store locally, allowing offline operation.
-4. **Phase 8: Kiosk Settings & Manager Control:**
- * **Kiosk:** Add Settings screen with QR code (containing IP/Info).
- * **Manager:** Add feature to scan Kiosk QR and push configuration/trigger sync.
-
-I will start by updating the documentation and task list.
\ No newline at end of file
diff --git a/.trae/documents/Revised Plan_ Manager-Centric Peer-to-Peer Architecture.md b/.trae/documents/Revised Plan_ Manager-Centric Peer-to-Peer Architecture.md
deleted file mode 100644
index 543d7e1..0000000
--- a/.trae/documents/Revised Plan_ Manager-Centric Peer-to-Peer Architecture.md
+++ /dev/null
@@ -1,29 +0,0 @@
-This is a significant architectural simplification. I will pivot the plan to focus on a **Manager-centric** model where the "Server" is optional/deprecated for core functionality.
-
-**New Architecture:**
-* **Kiosk:** Acts as the primary "node" for customers. It has its own database. It exposes an API/Socket for the Manager to connect to.
-* **Manager:** Acts as the "Controller". It stores the master product list. It connects directly to the Kiosk(s) on the local network.
-* **Server (Rust):** Now acts as an optional "Extension" or backup, not a dependency.
-* **Data Flow:**
- * Manager edits data locally.
- * On "Done", Manager pushes updates to Kiosk.
- * Manager pulls sales data from Kiosk.
-
-**Revised Plan for Version 0.2.0:**
-
-1. **Documentation Update:** Reflect this simpler, 2-app architecture.
-2. **Phase 6: I18n:** (Remains same) Add localization.
-3. **Phase 7: Database & API Migration (Manager-Kiosk Direct):**
- * **Kiosk:**
- * Implement local SQLite DB.
- * Implement a simple HTTP server (using `shelf` or similar in Dart) to listen for Manager commands (`/sync`, `/config`).
- * Add "Set PIN" screen on first run.
- * **Manager:**
- * Implement local SQLite DB.
- * Add "Add Kiosk" feature: Scan Network (mDNS) or Scan QR to find Kiosk IP + Enter PIN.
- * Implement "Connection Status" indicator.
- * Implement "Push to Kiosk" logic on product save.
-4. **Phase 8: Settings & External API:**
- * **Manager:** Add setting to configure "Product API URL" (e.g., OpenFoodFacts or custom).
-
-I will update the `todo.md` to reflect this simpler, more robust peer-to-peer design.
\ No newline at end of file
diff --git a/Kiosk/android/app/src/main/AndroidManifest.xml b/Kiosk/android/app/src/main/AndroidManifest.xml
index bb1f914..8939a1e 100644
--- a/Kiosk/android/app/src/main/AndroidManifest.xml
+++ b/Kiosk/android/app/src/main/AndroidManifest.xml
@@ -40,6 +40,11 @@
+
+
+
+
+
diff --git a/Kiosk/android/app/src/main/kotlin/com/secgo/kiosk/SecgoNotificationListenerService.kt b/Kiosk/android/app/src/main/kotlin/com/secgo/kiosk/SecgoNotificationListenerService.kt
index 3d04c90..5ba4fd6 100644
--- a/Kiosk/android/app/src/main/kotlin/com/secgo/kiosk/SecgoNotificationListenerService.kt
+++ b/Kiosk/android/app/src/main/kotlin/com/secgo/kiosk/SecgoNotificationListenerService.kt
@@ -1,6 +1,7 @@
package com.secgo.kiosk
import android.app.Notification
+import android.os.Build
import android.content.Context
import android.content.Intent
import android.service.notification.NotificationListenerService
@@ -115,12 +116,14 @@ class SecgoNotificationListenerService : NotificationListenerService() {
private fun buildNotificationJson(sbn: StatusBarNotification): JSONObject {
val n = sbn.notification
val extras = n.extras
+ val channelId =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) n.channelId else null
val json =
JSONObject()
.put("package", sbn.packageName)
.put("key", sbn.key)
.put("id", sbn.id)
- .put("channelId", n.channelId ?: JSONObject.NULL)
+ .put("channelId", channelId ?: JSONObject.NULL)
.put("postTime", sbn.postTime)
.put("when", n.`when`)
.put("category", n.category ?: JSONObject.NULL)
diff --git a/Kiosk/assets/audio/add_success.mp3 b/Kiosk/assets/audio/add_success.mp3
new file mode 100644
index 0000000..3166190
Binary files /dev/null and b/Kiosk/assets/audio/add_success.mp3 differ
diff --git a/Kiosk/lib/screens/main_screen.dart b/Kiosk/lib/screens/main_screen.dart
index 691665d..2094eba 100644
--- a/Kiosk/lib/screens/main_screen.dart
+++ b/Kiosk/lib/screens/main_screen.dart
@@ -1,23 +1,32 @@
import 'dart:async';
import 'dart:io';
-import 'package:flutter/foundation.dart';
+import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:google_mlkit_barcode_scanning/google_mlkit_barcode_scanning.dart';
+import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart';
import 'package:intl/intl.dart'; // Add intl for date formatting
+import 'package:native_device_orientation/native_device_orientation.dart';
import 'package:kiosk/db/database_helper.dart';
import 'package:kiosk/models/product.dart';
-import 'package:kiosk/models/order.dart' as model; // Alias to avoid conflict if needed
+import 'package:kiosk/models/order.dart'
+ as model; // Alias to avoid conflict if needed
import 'package:kiosk/screens/payment_screen.dart';
import 'package:kiosk/screens/manual_barcode_dialog.dart';
import 'package:kiosk/screens/pin_input_dialog.dart';
import 'package:kiosk/screens/no_barcode_products_dialog.dart';
import 'package:kiosk/screens/settings_screen.dart';
-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/scanner_keyboard_buffer.dart';
+import 'package:kiosk/services/screen_wake_bus.dart';
import 'package:kiosk/services/settings_service.dart';
+import 'package:kiosk/services/add_success_sound_service.dart';
+import 'package:kiosk/widgets/camera_monitor_badge.dart';
+import 'package:permission_handler/permission_handler.dart';
// Helper class for cart items
class CartItem {
@@ -29,59 +38,6 @@ class CartItem {
double get total => product.price * quantity;
}
-class BarcodeOverlayPainter extends CustomPainter {
- final BarcodeCapture capture;
- final Size widgetSize;
-
- BarcodeOverlayPainter({
- required this.capture,
- required this.widgetSize,
- });
-
- @override
- void paint(Canvas canvas, Size size) {
- final paint = Paint()
- ..color = Colors.green
- ..style = PaintingStyle.stroke
- ..strokeWidth = 3.0;
-
- // Use the image size from the capture
- final Size imageSize = capture.size; // e.g. 1280x720
-
- if (imageSize.width == 0 || imageSize.height == 0) {
- return;
- }
-
- // Calculate scale factors to map image coordinates to widget coordinates
- final double scaleX = widgetSize.width / imageSize.width;
- final double scaleY = widgetSize.height / imageSize.height;
-
- for (final barcode in capture.barcodes) {
- if (barcode.corners.isEmpty) continue;
-
- final path = Path();
- // Move to the first corner
- final first = barcode.corners.first;
- path.moveTo(first.dx * scaleX, first.dy * scaleY);
-
- // Draw lines to subsequent corners
- for (int i = 1; i < barcode.corners.length; i++) {
- final point = barcode.corners[i];
- path.lineTo(point.dx * scaleX, point.dy * scaleY);
- }
-
- // Close the loop
- path.close();
- canvas.drawPath(path, paint);
- }
- }
-
- @override
- bool shouldRepaint(covariant BarcodeOverlayPainter oldDelegate) {
- return oldDelegate.capture != capture;
- }
-}
-
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
@@ -94,20 +50,22 @@ class _MainScreenState extends State {
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(
- facing: CameraFacing.front,
- cameraResolution: const Size(1280, 720),
- autoZoom: true,
+ bool _isDialogOpen = false;
+ bool _cameraBarcodeRecognitionEnabled = true;
+ bool _faceWakeupEnabled = true;
+ CameraController? _cameraController;
+ bool _cameraReady = false;
+ bool _isAnalyzingFrame = false;
+ DateTime _lastFaceAnalyzeAt = DateTime.fromMillisecondsSinceEpoch(0);
+ DateTime _lastFaceWakeAt = DateTime.fromMillisecondsSinceEpoch(0);
+ final Duration _faceAnalyzeInterval = const Duration(milliseconds: 300);
+ final Duration _faceWakeCooldown = const Duration(seconds: 10);
+ final BarcodeScanner _barcodeScanner = BarcodeScanner(
formats: const [
BarcodeFormat.ean13,
BarcodeFormat.ean8,
- BarcodeFormat.upcA,
- BarcodeFormat.upcE,
+ BarcodeFormat.upca,
+ BarcodeFormat.upce,
BarcodeFormat.code128,
BarcodeFormat.code39,
BarcodeFormat.code93,
@@ -115,29 +73,275 @@ class _MainScreenState extends State {
BarcodeFormat.codabar,
BarcodeFormat.qrCode,
],
- detectionSpeed: DetectionSpeed.normal,
- detectionTimeoutMs: 250,
- returnImage: false, // Improves performance on older devices
);
+ final FaceDetector _faceDetector = FaceDetector(
+ options: FaceDetectorOptions(
+ performanceMode: FaceDetectorMode.fast,
+ enableContours: false,
+ enableLandmarks: false,
+ enableClassification: false,
+ ),
+ );
+ final RestoreNotifier _restoreNotifier = RestoreNotifier.instance;
+ final ScreenWakeBus _screenWakeBus = ScreenWakeBus.instance;
+ final FocusNode _scannerKeyboardFocusNode = FocusNode(
+ debugLabel: 'scannerKeyboardFocus',
+ );
+ final ScannerKeyboardBuffer _scannerKeyboardBuffer = ScannerKeyboardBuffer(
+ minLength: 4,
+ );
+ final AndroidNotificationListenerService _notificationListenerService =
+ AndroidNotificationListenerService();
+ final AddSuccessSoundService _addSuccessSoundService =
+ AddSuccessSoundService.withDefaultBackend();
@override
void initState() {
super.initState();
+ _loadScannerSettings();
_restoreNotifier.addListener(_handleRestore);
WidgetsBinding.instance.addPostFrameCallback((_) {
+ _requestScannerKeyboardFocus();
_resumePendingPaymentIfAny();
+ _initCameraPipeline();
});
}
@override
void dispose() {
- _scannerController.dispose();
+ final controller = _cameraController;
+ if (controller != null) {
+ if (controller.value.isStreamingImages) {
+ unawaited(controller.stopImageStream());
+ }
+ unawaited(controller.dispose());
+ }
+ _barcodeScanner.close();
+ _faceDetector.close();
+ _scannerKeyboardFocusNode.dispose();
+ unawaited(_addSuccessSoundService.dispose());
_restoreNotifier.removeListener(_handleRestore);
super.dispose();
}
- double get _totalAmount => _cartItems.values.fold(0, (sum, item) => sum + item.total);
- int get _totalItems => _cartItems.values.fold(0, (sum, item) => sum + item.quantity);
+ Future _loadScannerSettings() async {
+ final barcodeEnabled = _settingsService
+ .getCameraBarcodeRecognitionEnabled();
+ final faceWakeEnabled = _settingsService.getFaceWakeupEnabled();
+ if (!mounted) return;
+ setState(() {
+ _cameraBarcodeRecognitionEnabled = barcodeEnabled;
+ _faceWakeupEnabled = faceWakeEnabled;
+ });
+ }
+
+ Future _initCameraPipeline() async {
+ try {
+ final status = await Permission.camera.status;
+ if (!status.isGranted) {
+ final next = await Permission.camera.request();
+ if (!next.isGranted) {
+ if (mounted) {
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(const SnackBar(content: Text('相机权限未授予,无法启用摄像头')));
+ }
+ return;
+ }
+ }
+ final cameras = await availableCameras();
+ if (cameras.isEmpty) return;
+ final CameraDescription selected = cameras.firstWhere(
+ (c) => c.lensDirection == CameraLensDirection.front,
+ orElse: () => cameras.first,
+ );
+ final controller = CameraController(
+ selected,
+ ResolutionPreset.medium,
+ enableAudio: false,
+ imageFormatGroup: Platform.isAndroid
+ ? ImageFormatGroup.nv21
+ : ImageFormatGroup.bgra8888,
+ );
+ await controller.initialize();
+ await controller.startImageStream(_analyzeFrame);
+ if (!mounted) {
+ await controller.dispose();
+ return;
+ }
+ setState(() {
+ _cameraController = controller;
+ _cameraReady = true;
+ });
+ } catch (e) {
+ debugPrint('Camera init failed: $e');
+ }
+ }
+
+ Future _analyzeFrame(CameraImage image) async {
+ if (!mounted || _isAnalyzingFrame) return;
+ if (!_cameraBarcodeRecognitionEnabled && !_faceWakeupEnabled) return;
+ final controller = _cameraController;
+ if (controller == null) return;
+
+ final inputImage = _buildInputImage(image, controller);
+ if (inputImage == null) return;
+
+ _isAnalyzingFrame = true;
+ try {
+ if (_cameraBarcodeRecognitionEnabled) {
+ final barcodes = await _barcodeScanner.processImage(inputImage);
+ if (barcodes.isNotEmpty) {
+ final raw = barcodes.first.rawValue;
+ if (raw != null) {
+ await _processBarcodeCode(raw);
+ }
+ }
+ }
+
+ if (_faceWakeupEnabled) {
+ final now = DateTime.now();
+ if (now.difference(_lastFaceAnalyzeAt) >= _faceAnalyzeInterval) {
+ _lastFaceAnalyzeAt = now;
+ final faces = await _faceDetector.processImage(inputImage);
+ if (faces.isNotEmpty && _canWakeByFace(now)) {
+ _lastFaceWakeAt = now;
+ _screenWakeBus.requestWake();
+ }
+ }
+ }
+ } catch (e) {
+ debugPrint('Camera frame analyze failed: $e');
+ } finally {
+ _isAnalyzingFrame = false;
+ }
+ }
+
+ bool _canWakeByFace(DateTime now) {
+ if (!_faceWakeupEnabled) return false;
+ if (_isDialogOpen) return false;
+ final route = ModalRoute.of(context);
+ if (route != null && !route.isCurrent) return false;
+ return now.difference(_lastFaceWakeAt) >= _faceWakeCooldown;
+ }
+
+ InputImage? _buildInputImage(CameraImage image, CameraController controller) {
+ try {
+ final description = controller.description;
+ InputImageRotation? rotation;
+ if (Platform.isIOS) {
+ rotation = InputImageRotationValue.fromRawValue(
+ description.sensorOrientation,
+ );
+ } else if (Platform.isAndroid) {
+ const orientationToDegrees = {
+ DeviceOrientation.portraitUp: 0,
+ DeviceOrientation.landscapeLeft: 90,
+ DeviceOrientation.portraitDown: 180,
+ DeviceOrientation.landscapeRight: 270,
+ };
+ final deviceDegrees =
+ orientationToDegrees[controller.value.deviceOrientation];
+ if (deviceDegrees == null) return null;
+ final sensor = description.sensorOrientation;
+ final compensated =
+ description.lensDirection == CameraLensDirection.front
+ ? (sensor + deviceDegrees) % 360
+ : (sensor - deviceDegrees + 360) % 360;
+ rotation = InputImageRotationValue.fromRawValue(compensated);
+ }
+ if (rotation == null) return null;
+ final format = InputImageFormatValue.fromRawValue(image.format.raw);
+ if (format == null) return null;
+ if ((Platform.isAndroid && format != InputImageFormat.nv21) ||
+ (Platform.isIOS && format != InputImageFormat.bgra8888)) {
+ return null;
+ }
+ if (image.planes.length != 1) return null;
+ final plane = image.planes.first;
+ final metadata = InputImageMetadata(
+ size: Size(image.width.toDouble(), image.height.toDouble()),
+ rotation: rotation,
+ format: format,
+ bytesPerRow: plane.bytesPerRow,
+ );
+ return InputImage.fromBytes(bytes: plane.bytes, metadata: metadata);
+ } catch (e) {
+ debugPrint('Build input image failed: $e');
+ return null;
+ }
+ }
+
+ Widget _buildAdaptiveCameraPreview(BoxConstraints constraints) {
+ if (!_cameraReady || _cameraController == null) {
+ return const ColoredBox(color: Colors.black);
+ }
+
+ return NativeDeviceOrientationReader(
+ useSensor: true,
+ builder: (context) {
+ final controller = _cameraController!;
+ final sensor = controller.description.sensorOrientation;
+ final nativeOrientation = NativeDeviceOrientationReader.orientation(context);
+
+ // 将设备方向转换为度数
+ final deviceDegrees = switch (nativeOrientation) {
+ NativeDeviceOrientation.portraitUp => 0,
+ NativeDeviceOrientation.landscapeRight => 90,
+ NativeDeviceOrientation.portraitDown => 180,
+ NativeDeviceOrientation.landscapeLeft => 270,
+ _ => 0,
+ };
+
+ // 计算补偿旋转(前置摄像头需要镜像补偿)
+ final isFront = controller.description.lensDirection == CameraLensDirection.front;
+ final quarterTurns = isFront
+ ? ((sensor + deviceDegrees) ~/ 90) % 4
+ : ((sensor - deviceDegrees + 360) ~/ 90) % 4;
+
+ final rotated = quarterTurns.isOdd;
+ final aspect = controller.value.aspectRatio;
+ final w = constraints.maxWidth;
+ final h = constraints.maxHeight;
+
+ double previewWidth = w;
+ double previewHeight = w / aspect;
+
+ if (rotated) {
+ previewWidth = h * aspect;
+ previewHeight = h;
+ }
+
+ Widget preview = CameraPreview(controller);
+ if (quarterTurns != 0) {
+ preview = RotatedBox(quarterTurns: quarterTurns, child: preview);
+ }
+
+ return SizedBox.expand(
+ child: FittedBox(
+ fit: BoxFit.cover,
+ child: SizedBox(
+ width: previewWidth,
+ height: previewHeight,
+ child: preview,
+ ),
+ ),
+ );
+ },
+ );
+ }
+
+ void _requestScannerKeyboardFocus() {
+ if (!mounted) return;
+ if (!_scannerKeyboardFocusNode.hasFocus) {
+ _scannerKeyboardFocusNode.requestFocus();
+ }
+ }
+
+ double get _totalAmount =>
+ _cartItems.values.fold(0, (sum, item) => sum + item.total);
+ int get _totalItems =>
+ _cartItems.values.fold(0, (sum, item) => sum + item.quantity);
void _addToCart(Product product) {
setState(() {
@@ -149,6 +353,23 @@ class _MainScreenState extends State {
});
}
+ void _handleAddSuccess(
+ Product product, {
+ bool showSnackBar = false,
+ Duration? snackBarDuration,
+ }) {
+ _addToCart(product);
+ unawaited(_addSuccessSoundService.play());
+ if (!showSnackBar || !mounted) return;
+ final l10n = AppLocalizations.of(context)!;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(l10n.addedProduct(product.name)),
+ duration: snackBarDuration ?? const Duration(milliseconds: 1000),
+ ),
+ );
+ }
+
void _removeFromCart(String barcode) {
setState(() {
if (_cartItems.containsKey(barcode)) {
@@ -183,11 +404,14 @@ class _MainScreenState extends State {
final expected = _settingsService.getPin();
if (expected == null || expected.isEmpty) return true;
+ _isDialogOpen = true;
final result = await showDialog(
context: context,
barrierDismissible: false,
builder: (dialogContext) => PinInputDialog(expectedPin: expected),
);
+ _isDialogOpen = false;
+ _requestScannerKeyboardFocus();
return result ?? false;
}
@@ -200,13 +424,16 @@ class _MainScreenState extends State {
await _settingsService.setPendingPaymentOrderId(null);
return;
}
- final paid = order.alipayNotifyCheckedAmount || order.wechatNotifyCheckedAmount;
+ final paid =
+ order.alipayNotifyCheckedAmount || order.wechatNotifyCheckedAmount;
if (paid) {
await _settingsService.setPendingPaymentOrderId(null);
return;
}
final checkoutTimeMs =
- order.alipayCheckoutTimeMs ?? order.wechatCheckoutTimeMs ?? order.timestamp;
+ order.alipayCheckoutTimeMs ??
+ order.wechatCheckoutTimeMs ??
+ order.timestamp;
final nowMs = DateTime.now().millisecondsSinceEpoch;
if (nowMs - checkoutTimeMs > const Duration(minutes: 30).inMilliseconds) {
await _settingsService.setPendingPaymentOrderId(null);
@@ -234,107 +461,66 @@ class _MainScreenState extends State {
}
Future _openNoBarcodePicker() async {
- final l10n = AppLocalizations.of(context)!;
- final messenger = ScaffoldMessenger.of(context);
+ _isDialogOpen = true;
await showDialog(
context: context,
builder: (context) {
return NoBarcodeProductsDialog(
onAdd: (product) {
- _addToCart(product);
- messenger.showSnackBar(
- SnackBar(content: Text(l10n.addedProduct(product.name))),
- );
+ _handleAddSuccess(product, showSnackBar: true);
},
);
},
);
+ _isDialogOpen = false;
+ _requestScannerKeyboardFocus();
}
- Future _handleBarcodeDetect(BarcodeCapture capture) async {
+ Future _processBarcodeCode(String rawCode) async {
// Only process logic if not already processing
if (_isProcessing) return;
-
- final List barcodes = capture.barcodes;
- if (barcodes.isNotEmpty && barcodes.first.rawValue != null) {
- final String code = barcodes.first.rawValue!;
- debugPrint('Detected barcode: $code');
-
- setState(() => _isProcessing = true);
-
- // ... processing logic ...
- final product = await _db.getProduct(code);
- if (product != null) {
- _addToCart(product);
- if (mounted) {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(
- content: Text(AppLocalizations.of(context)!.addedProduct(product.name)),
- duration: const Duration(milliseconds: 1000),
- ),
- );
- }
- } else {
- if (mounted) {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text(AppLocalizations.of(context)!.productNotFound(code))),
- );
- }
- }
-
- await Future.delayed(const Duration(seconds: 1)); // Faster scan interval
- if (mounted) {
- setState(() => _isProcessing = false);
- }
+ final code = rawCode.trim();
+ if (code.length < 4) return;
+
+ debugPrint('Detected barcode: $code');
+ setState(() => _isProcessing = true);
+ final product = await _db.getProduct(code);
+ if (product != null) {
+ _handleAddSuccess(product, showSnackBar: true);
+ } else if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(AppLocalizations.of(context)!.productNotFound(code)),
+ ),
+ );
}
- }
- void _handleBarcodeError(Object error, StackTrace stackTrace) {
- debugPrint('Barcode scan error: $error');
+ await Future.delayed(const Duration(milliseconds: 300));
if (mounted) {
- final l10n = AppLocalizations.of(context)!;
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text(l10n.scanError(error))),
- );
+ setState(() => _isProcessing = false);
}
}
- Future _testScanFile() async {
- const String filePath = '/sdcard/DCIM/Camera/IMG_20260122_024901.jpg';
- try {
- if (mounted) {
- final l10n = AppLocalizations.of(context)!;
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text(l10n.analyzingDebugImage)),
- );
- }
-
- final BarcodeCapture? capture = await _scannerController.analyzeImage(filePath);
-
- if (capture != null && capture.barcodes.isNotEmpty) {
- await _handleBarcodeDetect(capture);
- if (mounted) {
- final l10n = AppLocalizations.of(context)!;
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text(l10n.foundBarcodes(capture.barcodes.length))),
- );
- }
- } else {
- if (mounted) {
- final l10n = AppLocalizations.of(context)!;
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text(l10n.noBarcodesFound)),
- );
- }
- }
- } catch (e) {
- if (mounted) {
- final l10n = AppLocalizations.of(context)!;
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text(l10n.analyzeImageError(e))),
- );
+ KeyEventResult _handleScannerKeyboardEvent(FocusNode node, KeyEvent event) {
+ if (event is! KeyDownEvent) return KeyEventResult.ignored;
+ if (_isDialogOpen || !mounted) return KeyEventResult.ignored;
+ final route = ModalRoute.of(context);
+ if (route != null && !route.isCurrent) return KeyEventResult.ignored;
+
+ final isEnter =
+ event.logicalKey == LogicalKeyboardKey.enter ||
+ event.logicalKey == LogicalKeyboardKey.numpadEnter;
+ if (isEnter) {
+ final code = _scannerKeyboardBuffer.commit();
+ if (code != null) {
+ unawaited(_processBarcodeCode(code));
+ return KeyEventResult.handled;
}
+ return KeyEventResult.ignored;
}
+
+ _scannerKeyboardBuffer.pushCharacter(event.character);
+ return KeyEventResult.ignored;
}
Future _processPayment() async {
@@ -345,12 +531,14 @@ class _MainScreenState extends State {
final order = model.Order(
id: checkoutTimeMs.toString(),
items: _cartItems.values
- .map((item) => model.OrderItem(
- barcode: item.product.barcode,
- name: item.product.name,
- price: item.product.price,
- quantity: item.quantity,
- ))
+ .map(
+ (item) => model.OrderItem(
+ barcode: item.product.barcode,
+ name: item.product.name,
+ price: item.product.price,
+ quantity: item.quantity,
+ ),
+ )
.toList(),
totalAmount: _totalAmount,
timestamp: checkoutTimeMs,
@@ -396,7 +584,9 @@ class _MainScreenState extends State {
await _notificationListenerService.openSettings();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(content: Text('Enable notification access, then tap Retry.')),
+ const SnackBar(
+ content: Text('Enable notification access, then tap Retry.'),
+ ),
);
}
autoConfirmEnabled = await _notificationListenerService.isEnabled();
@@ -408,8 +598,8 @@ class _MainScreenState extends State {
}
if (autoConfirmEnabled) {
- final alipaySnapshot =
- await _notificationListenerService.getActiveAlipayNotificationsSnapshot();
+ final alipaySnapshot = await _notificationListenerService
+ .getActiveAlipayNotificationsSnapshot();
for (final n in alipaySnapshot) {
final key = n['key'];
final postTime = n['postTime'];
@@ -419,8 +609,8 @@ class _MainScreenState extends State {
if (postTime is int && postTime > checkoutTimeMs) continue;
baselineKeys.add('$packageName|$key');
}
- final wechatSnapshot =
- await _notificationListenerService.getActiveWechatNotificationsSnapshot();
+ final wechatSnapshot = await _notificationListenerService
+ .getActiveWechatNotificationsSnapshot();
for (final n in wechatSnapshot) {
final key = n['key'];
final postTime = n['postTime'];
@@ -432,7 +622,11 @@ class _MainScreenState extends State {
}
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(content: Text('Auto confirmation unavailable. Manual confirmation required.')),
+ const SnackBar(
+ content: Text(
+ 'Auto confirmation unavailable. Manual confirmation required.',
+ ),
+ ),
);
}
}
@@ -474,315 +668,351 @@ class _MainScreenState extends State {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final storeName = StoreConfig.storeName;
-
+ final isLandscape =
+ MediaQuery.of(context).orientation == Orientation.landscape;
+
// Formatting currency
- final currencyFormat = NumberFormat.currency(symbol: '¥'); // Or '$' based on locale
+ final currencyFormat = NumberFormat.currency(
+ symbol: '¥',
+ ); // Or '$' based on locale
return Scaffold(
- body: Stack(
- children: [
- // Layer 1: Main UI
- Column(
- children: [
- // Header
- Container(
- padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
- color: Colors.red, // Brand color from image
- child: SafeArea(
- bottom: false,
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Row(
- children: [
- Text(
- storeName,
- style: TextStyle(
- color: Colors.white,
- fontSize: 24,
- fontWeight: FontWeight.bold,
+ body: Focus(
+ autofocus: true,
+ focusNode: _scannerKeyboardFocusNode,
+ onKeyEvent: _handleScannerKeyboardEvent,
+ child: Stack(
+ children: [
+ // Layer 1: Main UI
+ Column(
+ children: [
+ // Header
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 20,
+ vertical: 12,
+ ),
+ color: Colors.red, // Brand color from image
+ child: SafeArea(
+ bottom: false,
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Row(
+ children: [
+ Text(
+ storeName,
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 24,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(width: 20),
+ Text(
+ l10n.kioskIdLabel('0000'), // Placeholder ID
+ style: TextStyle(
+ color: Colors.white.withValues(alpha: 0.8),
+ ),
+ ),
+ const SizedBox(width: 20),
+ Text(
+ DateFormat('HH:mm').format(DateTime.now()),
+ style: const TextStyle(color: Colors.white),
+ ),
+ ],
+ ),
+ TextButton(
+ onPressed: _clearCart,
+ style: TextButton.styleFrom(
+ foregroundColor: Colors.white,
+ shape: const StadiumBorder(
+ side: BorderSide(color: Colors.white),
),
),
- const SizedBox(width: 20),
- Text(
- l10n.kioskIdLabel('0000'), // Placeholder ID
- style: TextStyle(color: Colors.white.withValues(alpha: 0.8)),
- ),
- const SizedBox(width: 20),
- Text(
- DateFormat('HH:mm').format(DateTime.now()),
- style: const TextStyle(color: Colors.white),
- ),
- ],
- ),
- TextButton(
- onPressed: _clearCart,
- style: TextButton.styleFrom(
- foregroundColor: Colors.white,
- shape: const StadiumBorder(side: BorderSide(color: Colors.white)),
+ child: Text(l10n.clearCart),
),
- child: Text(l10n.clearCart),
- ),
- ],
+ ],
+ ),
),
),
- ),
-
- // Body: Cart List
- Expanded(
- child: _cartItems.isEmpty
- ? Center(
- child: Text(
- l10n.emptyCart,
- style: theme.textTheme.headlineSmall?.copyWith(color: Colors.grey),
- ),
- )
- : ListView.separated(
- padding: const EdgeInsets.all(16),
- itemCount: _cartItems.length,
- separatorBuilder: (_, __) => const Divider(),
- itemBuilder: (context, index) {
- final item = _cartItems.values.elementAt(index);
- return Container(
- padding: const EdgeInsets.all(12),
- decoration: BoxDecoration(
- color: Colors.white,
- borderRadius: BorderRadius.circular(8),
- boxShadow: [
- BoxShadow(
- color: Colors.black.withValues(alpha: 0.05),
- blurRadius: 4,
- offset: const Offset(0, 2),
- ),
- ],
+
+ // Body: Cart List
+ Expanded(
+ child: _cartItems.isEmpty
+ ? Center(
+ child: Text(
+ l10n.emptyCart,
+ style: theme.textTheme.headlineSmall?.copyWith(
+ color: Colors.grey,
),
- child: Row(
- children: [
- Expanded(
- flex: 3,
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- item.product.name,
- style: const TextStyle(
- fontSize: 18,
- fontWeight: FontWeight.bold,
- ),
- ),
- const SizedBox(height: 4),
- Text(
- "${l10n.unit}${currencyFormat.format(item.product.price)}",
- style: TextStyle(color: Colors.grey[600]),
- ),
- ],
+ ),
+ )
+ : ListView.separated(
+ padding: const EdgeInsets.all(16),
+ itemCount: _cartItems.length,
+ separatorBuilder: (_, __) => const Divider(),
+ itemBuilder: (context, index) {
+ final item = _cartItems.values.elementAt(index);
+ return Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(8),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withValues(alpha: 0.05),
+ blurRadius: 4,
+ offset: const Offset(0, 2),
),
- ),
- // Quantity Controls
- Container(
- decoration: BoxDecoration(
- border: Border.all(color: Colors.grey[300]!),
- borderRadius: BorderRadius.circular(20),
+ ],
+ ),
+ child: Row(
+ children: [
+ Expanded(
+ flex: 3,
+ child: Column(
+ crossAxisAlignment:
+ CrossAxisAlignment.start,
+ children: [
+ Text(
+ item.product.name,
+ style: const TextStyle(
+ fontSize: 18,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ "${l10n.unit}${currencyFormat.format(item.product.price)}",
+ style: TextStyle(
+ color: Colors.grey[600],
+ ),
+ ),
+ ],
+ ),
),
- child: Row(
- children: [
- IconButton(
- icon: const Icon(Icons.remove, size: 16),
- onPressed: () => _removeFromCart(item.product.barcode),
- ),
- Text(
- "${item.quantity}",
- style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
+ // Quantity Controls
+ Container(
+ decoration: BoxDecoration(
+ border: Border.all(
+ color: Colors.grey[300]!,
),
- IconButton(
- icon: const Icon(Icons.add, size: 16),
- onPressed: () => _addToCart(item.product),
- ),
- ],
+ borderRadius: BorderRadius.circular(20),
+ ),
+ child: Row(
+ children: [
+ IconButton(
+ icon: const Icon(
+ Icons.remove,
+ size: 16,
+ ),
+ onPressed: () => _removeFromCart(
+ item.product.barcode,
+ ),
+ ),
+ Text(
+ "${item.quantity}",
+ style: const TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ IconButton(
+ icon: const Icon(Icons.add, size: 16),
+ onPressed: () =>
+ _handleAddSuccess(item.product),
+ ),
+ ],
+ ),
),
- ),
- const SizedBox(width: 20),
- SizedBox(
- width: 100,
- child: Text(
- currencyFormat.format(item.total),
- textAlign: TextAlign.right,
- style: const TextStyle(
- fontSize: 20,
- color: Colors.red,
- fontWeight: FontWeight.bold,
+ const SizedBox(width: 20),
+ SizedBox(
+ width: 100,
+ child: Text(
+ currencyFormat.format(item.total),
+ textAlign: TextAlign.right,
+ style: const TextStyle(
+ fontSize: 20,
+ color: Colors.red,
+ fontWeight: FontWeight.bold,
+ ),
),
),
- ),
- ],
- ),
- );
- },
- ),
- ),
-
- // Footer
- Container(
- padding: const EdgeInsets.all(16),
- decoration: BoxDecoration(
- color: Colors.white,
- boxShadow: [
- BoxShadow(
- color: Colors.black.withValues(alpha: 0.1),
- blurRadius: 10,
- offset: const Offset(0, -5),
- ),
- ],
- ),
- child: Row(
- children: [
- // Left Actions
- Row(
- children: [
- TextButton.icon(
- onPressed: () async {
- final product = await showDialog(
- context: context,
- builder: (context) => const ManualBarcodeDialog(),
+ ],
+ ),
);
- if (product != null) {
- _addToCart(product);
- }
},
- icon: const Icon(Icons.keyboard, color: Colors.grey),
- label: Text(l10n.inputBarcode, style: const TextStyle(color: Colors.grey)),
- ),
- const SizedBox(width: 16),
- TextButton.icon(
- onPressed: _openNoBarcodePicker,
- icon: const Icon(Icons.grid_view, color: Colors.grey),
- label: Text(l10n.noBarcodeItem, style: const TextStyle(color: Colors.grey)),
),
- if (kDebugMode) ...[
+ ),
+
+ // Footer
+ Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: Colors.white,
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withValues(alpha: 0.1),
+ blurRadius: 10,
+ offset: const Offset(0, -5),
+ ),
+ ],
+ ),
+ child: Row(
+ children: [
+ // Left Actions
+ Row(
+ children: [
+ TextButton.icon(
+ onPressed: () async {
+ _isDialogOpen = true;
+ final product = await showDialog(
+ context: context,
+ builder: (context) =>
+ const ManualBarcodeDialog(),
+ );
+ _isDialogOpen = false;
+ _requestScannerKeyboardFocus();
+ if (product != null) {
+ _handleAddSuccess(product);
+ }
+ },
+ icon: const Icon(
+ Icons.keyboard,
+ color: Colors.grey,
+ ),
+ label: Text(
+ l10n.inputBarcode,
+ style: const TextStyle(color: Colors.grey),
+ ),
+ ),
const SizedBox(width: 16),
+ TextButton.icon(
+ onPressed: _openNoBarcodePicker,
+ icon: const Icon(
+ Icons.grid_view,
+ color: Colors.grey,
+ ),
+ label: Text(
+ l10n.noBarcodeItem,
+ style: const TextStyle(color: Colors.grey),
+ ),
+ ),
+ // Settings Button (Hidden access)
IconButton(
- icon: const Icon(Icons.image_search, color: Colors.grey),
- onPressed: _testScanFile,
- tooltip: l10n.debugScanFile,
+ icon: const Icon(
+ Icons.settings,
+ color: Colors.grey,
+ ),
+ onPressed: () async {
+ final navigator = Navigator.of(context);
+ final ok = await _confirmAdminPin();
+ if (!ok || !mounted) return;
+ final result = await navigator.push(
+ MaterialPageRoute(
+ builder: (_) => const SettingsScreen(),
+ ),
+ );
+ if (!mounted) return;
+ await _loadScannerSettings();
+ _requestScannerKeyboardFocus();
+ if (result == 'reset') {
+ _clearCart();
+ setState(() => _isProcessing = false);
+ }
+ },
),
],
- // Settings Button (Hidden access)
- IconButton(
- icon: const Icon(Icons.settings, color: Colors.grey),
- onPressed: () async {
- final navigator = Navigator.of(context);
- final ok = await _confirmAdminPin();
- if (!ok || !mounted) return;
- final result = await navigator.push(
- MaterialPageRoute(builder: (_) => const SettingsScreen()),
- );
- if (!mounted) return;
- if (result == 'reset') {
- _clearCart();
- setState(() => _isProcessing = false);
- }
- },
+ ),
+ const Spacer(),
+ // Right Actions
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ l10n.totalWithAmount(
+ currencyFormat.format(_totalAmount),
+ ),
+ style: const TextStyle(
+ fontSize: 16,
+ color: Colors.red,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ Text(
+ l10n.itemsCount(_totalItems),
+ style: const TextStyle(
+ color: Colors.grey,
+ fontSize: 12,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(width: 20),
+ ElevatedButton(
+ onPressed: _cartItems.isEmpty ? null : _processPayment,
+ style: ElevatedButton.styleFrom(
+ backgroundColor: Colors.red,
+ foregroundColor: Colors.white,
+ padding: const EdgeInsets.symmetric(
+ horizontal: 40,
+ vertical: 20,
+ ),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(30),
+ ),
),
- ],
- ),
- const Spacer(),
- // Right Actions
- Column(
- crossAxisAlignment: CrossAxisAlignment.end,
- mainAxisSize: MainAxisSize.min,
- children: [
- Text(
- l10n.totalWithAmount(currencyFormat.format(_totalAmount)),
+ child: Text(
+ l10n.checkout,
style: const TextStyle(
- fontSize: 16,
- color: Colors.red,
+ fontSize: 20,
fontWeight: FontWeight.bold,
),
),
- Text(
- l10n.itemsCount(_totalItems),
- style: const TextStyle(color: Colors.grey, fontSize: 12),
- ),
- ],
- ),
- const SizedBox(width: 20),
- ElevatedButton(
- onPressed: _cartItems.isEmpty ? null : _processPayment,
- style: ElevatedButton.styleFrom(
- backgroundColor: Colors.red,
- foregroundColor: Colors.white,
- padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 20),
- shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)),
- ),
- child: Text(
- l10n.checkout,
- style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
+ ],
+ ),
+ ),
+ ],
+ ),
+
+ // Layer 2: Camera Overlay (Suspension at bottom-left)
+ Positioned(
+ left: 20,
+ bottom: 100, // Above the footer
+ child: Container(
+ width: isLandscape ? 200 : 150,
+ height: isLandscape ? 150 : 200,
+ decoration: BoxDecoration(
+ color: Colors.black,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: Colors.white, width: 2),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withValues(alpha: 0.3),
+ blurRadius: 10,
),
],
),
- ),
- ],
- ),
-
- // Layer 2: Camera Overlay (Suspension at bottom-left)
- Positioned(
- left: 20,
- bottom: 100, // Above the footer
- child: Container(
- width: 200,
- height: 150,
- decoration: BoxDecoration(
- color: Colors.black,
- borderRadius: BorderRadius.circular(12),
- border: Border.all(color: Colors.white, width: 2),
- boxShadow: [
- BoxShadow(
- color: Colors.black.withValues(alpha: 0.3),
- blurRadius: 10,
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(10),
+ child: LayoutBuilder(
+ builder: (context, constraints) => Stack(
+ fit: StackFit.expand,
+ children: [
+ _buildAdaptiveCameraPreview(constraints),
+ if (_faceWakeupEnabled)
+ const IgnorePointer(child: CameraMonitorBadge()),
+ ],
+ ),
),
- ],
- ),
- child: ClipRRect(
- borderRadius: BorderRadius.circular(10),
- child: MobileScanner(
- controller: _scannerController,
- onDetect: _handleBarcodeDetect,
- onDetectError: _handleBarcodeError,
- tapToFocus: true,
- fit: BoxFit.cover,
- overlayBuilder: (context, constraints) {
- return ValueListenableBuilder(
- valueListenable: _scannerController,
- builder: (context, value, child) {
- // In MobileScanner 7.x, value is MobileScannerState
- // We need to check if we have a valid capture in the stream or state?
- // Wait, MobileScannerState does NOT have 'capture'.
- // We must listen to the 'barcodes' stream for the overlay data.
- return StreamBuilder(
- stream: _scannerController.barcodes,
- builder: (context, snapshot) {
- if (!snapshot.hasData || snapshot.data == null) {
- return const SizedBox();
- }
- return IgnorePointer(
- child: CustomPaint(
- painter: BarcodeOverlayPainter(
- capture: snapshot.data!,
- widgetSize: Size(constraints.maxWidth, constraints.maxHeight),
- ),
- ),
- );
- },
- );
- },
- );
- },
),
),
),
- ),
- ],
+ ],
+ ),
),
);
}
diff --git a/Kiosk/lib/screens/payment_screen.dart b/Kiosk/lib/screens/payment_screen.dart
index bf898a6..09ca6d2 100644
--- a/Kiosk/lib/screens/payment_screen.dart
+++ b/Kiosk/lib/screens/payment_screen.dart
@@ -26,13 +26,44 @@ class PaymentScreen extends StatefulWidget {
required this.onPaymentConfirmed,
});
+ static bool canStartAutoConfirm({
+ required bool checkoutAutoConfirmEnabled,
+ required bool notificationListenerEnabled,
+ }) {
+ return checkoutAutoConfirmEnabled && notificationListenerEnabled;
+ }
+
+ static bool latestPaymentMatchesExpected({
+ required String packageName,
+ required String? title,
+ required String? text,
+ required String? bigText,
+ required double orderAmount,
+ required Map providerDiscountFolds,
+ }) {
+ final combined = [title, text, bigText].whereType().join(' ');
+ final parsedFen = packageName == PaymentNotificationWatchService.alipayPackage
+ ? PaymentNotificationWatchService.parseAlipaySuccessAmountFen(combined)
+ : packageName == PaymentNotificationWatchService.wechatPackage
+ ? PaymentNotificationWatchService.parseWeChatSuccessAmountFen(combined)
+ : null;
+ if (parsedFen == null) return false;
+ final expectedFen = PaymentNotificationWatchService.expectedFenForPackage(
+ orderAmount: orderAmount,
+ packageName: packageName,
+ providerDiscountFolds: providerDiscountFolds,
+ );
+ return parsedFen == expectedFen;
+ }
+
@override
State createState() => _PaymentScreenState();
}
class _PaymentScreenState extends State {
final SettingsService _settingsService = SettingsService();
- final PaymentNotificationWatchService _watchService = PaymentNotificationWatchService();
+ final PaymentNotificationWatchService _watchService =
+ PaymentNotificationWatchService();
final AndroidNotificationListenerService _notificationListenerService =
AndroidNotificationListenerService();
Map _qrs = {};
@@ -41,6 +72,18 @@ class _PaymentScreenState extends State {
bool _autoConfirmStarted = false;
bool _confirmed = false;
bool _showPaymentSuccess = false;
+ Timer? _latestPaymentPollTimer;
+ int _lastWechatPostTime = 0;
+ int _lastAlipayPostTime = 0;
+
+ void _showAutoConfirmUnavailableHint() {
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('Auto confirmation unavailable. Manual confirmation required.'),
+ ),
+ );
+ }
@override
void initState() {
@@ -69,9 +112,16 @@ class _PaymentScreenState extends State {
if (_autoConfirmStarted) return;
_autoConfirmStarted = true;
final enabled = await _notificationListenerService.isEnabled();
- if (!enabled) return;
+ if (!PaymentScreen.canStartAutoConfirm(
+ checkoutAutoConfirmEnabled: widget.autoConfirmEnabled,
+ notificationListenerEnabled: enabled,
+ )) {
+ _showAutoConfirmUnavailableHint();
+ return;
+ }
final baseline = widget.baselineKeys.toSet();
- final alipaySnapshot = await _notificationListenerService.getActiveAlipayNotificationsSnapshot();
+ final alipaySnapshot = await _notificationListenerService
+ .getActiveAlipayNotificationsSnapshot();
for (final n in alipaySnapshot) {
final key = n['key'];
final postTime = n['postTime'];
@@ -82,7 +132,8 @@ class _PaymentScreenState extends State {
baseline.add('$packageName|$key');
}
}
- final wechatSnapshot = await _notificationListenerService.getActiveWechatNotificationsSnapshot();
+ final wechatSnapshot = await _notificationListenerService
+ .getActiveWechatNotificationsSnapshot();
for (final n in wechatSnapshot) {
final key = n['key'];
final postTime = n['postTime'];
@@ -96,12 +147,15 @@ class _PaymentScreenState extends State {
debugPrint(
'PaymentScreen autoConfirm start orderId=${widget.orderId} amount=${widget.totalAmount} checkoutTimeMs=${widget.checkoutTimeMs} baselineKeys=${widget.baselineKeys.length}',
);
+ final providerDiscountFolds = _settingsService.getPaymentDiscountFolds();
+ _startLatestPaymentFallback(providerDiscountFolds);
await _watchService.start(
orderId: widget.orderId,
orderAmount: widget.totalAmount,
checkoutTimeMs: widget.checkoutTimeMs,
baselineKeys: baseline,
+ providerDiscountFolds: providerDiscountFolds,
onMatched: () {
if (!mounted) return;
_confirmPayment();
@@ -109,9 +163,9 @@ class _PaymentScreenState extends State {
onMismatch: (message) {
if (!mounted) return;
final l10n = AppLocalizations.of(context)!;
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text(l10n.amountMismatchWaiting)),
- );
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text(l10n.amountMismatchWaiting)));
},
onTimeout: () async {
if (!mounted) return;
@@ -135,9 +189,66 @@ class _PaymentScreenState extends State {
);
}
+ void _startLatestPaymentFallback(Map providerDiscountFolds) {
+ _latestPaymentPollTimer?.cancel();
+ _latestPaymentPollTimer = Timer.periodic(const Duration(seconds: 2), (_) async {
+ if (!mounted || _confirmed) return;
+ final latestWechat =
+ await _notificationListenerService.getLatestWechatPaymentNotification();
+ final latestAlipay =
+ await _notificationListenerService.getLatestAlipayPaymentNotification();
+ if (!mounted || _confirmed) return;
+
+ if (_tryConfirmFromLatest(
+ notification: latestWechat,
+ packageName: PaymentNotificationWatchService.wechatPackage,
+ providerDiscountFolds: providerDiscountFolds,
+ )) {
+ return;
+ }
+ _tryConfirmFromLatest(
+ notification: latestAlipay,
+ packageName: PaymentNotificationWatchService.alipayPackage,
+ providerDiscountFolds: providerDiscountFolds,
+ );
+ });
+ }
+
+ bool _tryConfirmFromLatest({
+ required Map? notification,
+ required String packageName,
+ required Map providerDiscountFolds,
+ }) {
+ if (notification == null) return false;
+ final postTime = notification['postTime'];
+ if (postTime is! int || postTime <= widget.checkoutTimeMs) return false;
+
+ if (packageName == PaymentNotificationWatchService.wechatPackage) {
+ if (postTime <= _lastWechatPostTime) return false;
+ _lastWechatPostTime = postTime;
+ } else if (packageName == PaymentNotificationWatchService.alipayPackage) {
+ if (postTime <= _lastAlipayPostTime) return false;
+ _lastAlipayPostTime = postTime;
+ }
+
+ final matched = PaymentScreen.latestPaymentMatchesExpected(
+ packageName: packageName,
+ title: notification['title']?.toString(),
+ text: notification['text']?.toString(),
+ bigText: notification['bigText']?.toString(),
+ orderAmount: widget.totalAmount,
+ providerDiscountFolds: providerDiscountFolds,
+ );
+ if (!matched) return false;
+ _confirmPayment();
+ return true;
+ }
+
@override
void dispose() {
_watchService.stop();
+ _latestPaymentPollTimer?.cancel();
+ _latestPaymentPollTimer = null;
if (!_confirmed) {
final pendingId = _settingsService.getPendingPaymentOrderId();
if (pendingId == widget.orderId) {
@@ -190,9 +301,9 @@ class _PaymentScreenState extends State {
Navigator.pop(context);
_confirmPayment();
} else {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text(l10n.invalidPin)),
- );
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text(l10n.invalidPin)));
}
},
child: Text(l10n.confirm),
@@ -207,16 +318,13 @@ class _PaymentScreenState extends State {
final label = provider == 'alipay'
? l10n.paymentMethodAlipay
: provider == 'wechat'
- ? l10n.paymentMethodWechat
- : provider;
+ ? l10n.paymentMethodWechat
+ : provider;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
- Text(
- label,
- style: Theme.of(context).textTheme.titleMedium,
- ),
+ Text(label, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
GestureDetector(
onTap: _handleAdminTap,
@@ -244,7 +352,7 @@ class _PaymentScreenState extends State {
final theme = Theme.of(context);
final currencyFormat = NumberFormat.currency(symbol: '¥');
final providers = _qrs.keys.toList()..sort();
-
+
return Scaffold(
// backgroundColor: Colors.black, // Use theme
appBar: AppBar(
@@ -285,7 +393,9 @@ class _PaymentScreenState extends State {
mainAxisSize: MainAxisSize.min,
children: [
Text(
- l10n.totalWithAmount(currencyFormat.format(widget.totalAmount)),
+ l10n.totalWithAmount(
+ currencyFormat.format(widget.totalAmount),
+ ),
textAlign: TextAlign.center,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
@@ -323,7 +433,11 @@ class _PaymentScreenState extends State {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
- const Icon(Icons.check_circle, color: Colors.green, size: 96),
+ const Icon(
+ Icons.check_circle,
+ color: Colors.green,
+ size: 96,
+ ),
const SizedBox(height: 16),
Text(
l10n.paymentSuccess,
@@ -336,7 +450,10 @@ class _PaymentScreenState extends State {
const SizedBox(height: 8),
Text(
l10n.returningHome,
- style: const TextStyle(color: Colors.white70, fontSize: 16),
+ style: const TextStyle(
+ color: Colors.white70,
+ fontSize: 16,
+ ),
),
],
),
diff --git a/Kiosk/lib/screens/pin_input_dialog.dart b/Kiosk/lib/screens/pin_input_dialog.dart
index 700bc0f..e64e217 100644
--- a/Kiosk/lib/screens/pin_input_dialog.dart
+++ b/Kiosk/lib/screens/pin_input_dialog.dart
@@ -105,31 +105,19 @@ class _PinInputDialogState extends State {
for (var i = 1; i <= 9; i++) _buildKey(i.toString()),
_buildBackspaceKey(),
_buildKey('0'),
- _buildActionButton(l10n.clear, Colors.orange, _onClear),
+ _buildActionButton(l10n.confirm, Colors.orange, _onConfirm),
],
),
),
const SizedBox(height: 12),
- Row(
- children: [
- Expanded(
- child: SizedBox(
- height: 56,
- child: _buildActionButton(
- l10n.cancel,
- Colors.grey,
- () => Navigator.pop(context, false),
- ),
- ),
- ),
- const SizedBox(width: 16),
- Expanded(
- child: SizedBox(
- height: 56,
- child: _buildActionButton(l10n.confirm, Colors.blue, _onConfirm),
- ),
- ),
- ],
+ SizedBox(
+ width: double.infinity,
+ height: 56,
+ child: _buildActionButton(
+ l10n.cancel,
+ Colors.grey,
+ () => Navigator.pop(context, false),
+ ),
),
],
),
diff --git a/Kiosk/lib/screens/pin_setup_screen.dart b/Kiosk/lib/screens/pin_setup_screen.dart
index 50f114b..715fa4e 100644
--- a/Kiosk/lib/screens/pin_setup_screen.dart
+++ b/Kiosk/lib/screens/pin_setup_screen.dart
@@ -42,16 +42,6 @@ class _PinSetupScreenState extends State {
});
}
- void _onClear() {
- setState(() {
- if (_editingConfirm) {
- _confirmPin = '';
- } else {
- _pin = '';
- }
- });
- }
-
Future _savePin() async {
final l10n = AppLocalizations.of(context)!;
if (_pin.isEmpty) {
@@ -146,29 +136,18 @@ class _PinSetupScreenState extends State {
),
_PinKey(label: '0', onTap: () => _onKeyTap('0')),
_PinKey(
- label: l10n.clear,
+ label: l10n.confirm,
color: Colors.orange,
textStyle: const TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.bold,
),
- onTap: _onClear,
+ onTap: _savePin,
),
],
),
),
- const SizedBox(height: 20),
- ElevatedButton(
- onPressed: _savePin,
- style: ElevatedButton.styleFrom(
- minimumSize: const Size.fromHeight(56),
- ),
- child: Text(
- l10n.saveAndContinue,
- style: const TextStyle(fontSize: 20),
- ),
- ),
],
),
),
diff --git a/Kiosk/lib/screens/settings_screen.dart b/Kiosk/lib/screens/settings_screen.dart
index 3f30e68..da33258 100644
--- a/Kiosk/lib/screens/settings_screen.dart
+++ b/Kiosk/lib/screens/settings_screen.dart
@@ -20,10 +20,16 @@ class SettingsScreen extends StatefulWidget {
State createState() => _SettingsScreenState();
}
-class _SettingsScreenState extends State with WidgetsBindingObserver {
+class _SettingsScreenState extends State
+ with WidgetsBindingObserver {
late final KioskServerService _serverService;
final TextEditingController _pinController = TextEditingController();
- final SettingsService _settingsService = SettingsService(); // Add SettingsService
+ final TextEditingController _alipayDiscountController =
+ TextEditingController();
+ final TextEditingController _wechatDiscountController =
+ TextEditingController();
+ final SettingsService _settingsService =
+ SettingsService(); // Add SettingsService
final AndroidLauncherService _launcherService = AndroidLauncherService();
final AndroidNetworkService _networkService = AndroidNetworkService();
bool _isServerRunning = false;
@@ -39,6 +45,8 @@ class _SettingsScreenState extends State with WidgetsBindingObse
String? _hotspotPassword;
String? _hotspotMode;
bool _networkBusy = false;
+ bool _cameraBarcodeRecognitionEnabled = true;
+ bool _faceWakeupEnabled = true;
StreamSubscription>? _connectivitySub;
Timer? _networkDebounce;
@@ -49,6 +57,15 @@ class _SettingsScreenState extends State with WidgetsBindingObse
_serverService = KioskServerService(onRestoreComplete: _onRestoreComplete);
_homeAppPackage = _settingsService.getHomeAppPackage();
_homeAppLabel = _settingsService.getHomeAppLabel();
+ _alipayDiscountController.text = _formatFold(
+ _settingsService.getPaymentDiscountFold('alipay'),
+ );
+ _wechatDiscountController.text = _formatFold(
+ _settingsService.getPaymentDiscountFold('wechat'),
+ );
+ _cameraBarcodeRecognitionEnabled =
+ _settingsService.getCameraBarcodeRecognitionEnabled();
+ _faceWakeupEnabled = _settingsService.getFaceWakeupEnabled();
// Pre-fill PIN if available
final savedPin = _settingsService.getPin();
if (savedPin != null) {
@@ -74,6 +91,8 @@ class _SettingsScreenState extends State with WidgetsBindingObse
WidgetsBinding.instance.removeObserver(this);
_serverService.stopServer();
_pinController.dispose();
+ _alipayDiscountController.dispose();
+ _wechatDiscountController.dispose();
super.dispose();
}
@@ -148,8 +167,13 @@ class _SettingsScreenState extends State with WidgetsBindingObse
return ListTile(
leading: const Icon(Icons.home_outlined),
title: Text(l10n.launcherDefault),
- trailing: selected ? const Icon(Icons.check) : null,
- onTap: () => Navigator.pop(context, {'packageName': '', 'label': ''}),
+ trailing: selected
+ ? const Icon(Icons.check)
+ : null,
+ onTap: () => Navigator.pop(context, {
+ 'packageName': '',
+ 'label': '',
+ }),
);
}
final app = filtered[index - 1];
@@ -159,8 +183,13 @@ class _SettingsScreenState extends State with WidgetsBindingObse
return ListTile(
title: Text(label),
subtitle: Text(pkg),
- trailing: selected ? const Icon(Icons.check) : null,
- onTap: () => Navigator.pop(context, {'packageName': pkg, 'label': label}),
+ trailing: selected
+ ? const Icon(Icons.check)
+ : null,
+ onTap: () => Navigator.pop(context, {
+ 'packageName': pkg,
+ 'label': label,
+ }),
);
},
),
@@ -214,9 +243,9 @@ class _SettingsScreenState extends State with WidgetsBindingObse
if (pkg != null && pkg.isNotEmpty) {
launched = await _launcherService.openApp(pkg);
if (!launched && mounted) {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text(l10n.homeAppOpenFailed(pkg))),
- );
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text(l10n.homeAppOpenFailed(pkg))));
}
}
if (!launched) {
@@ -244,7 +273,9 @@ class _SettingsScreenState extends State with WidgetsBindingObse
ListTile(
leading: const Icon(Icons.apps_outlined),
title: Text(l10n.homeAppTitle),
- subtitle: Text(_homeAppLabel ?? _homeAppPackage ?? l10n.homeAppNotSet),
+ subtitle: Text(
+ _homeAppLabel ?? _homeAppPackage ?? l10n.homeAppNotSet,
+ ),
onTap: _pickHomeApp,
),
],
@@ -278,8 +309,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse
_hotspotPassword = hotspotInfo.password;
_mobileDataEnabled = mobile;
});
- } catch (_) {
- }
+ } catch (_) {}
}
void _onNetworkChanged() {
@@ -350,8 +380,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse
} catch (_) {
try {
await _networkService.openHotspotSettings();
- } catch (_) {
- }
+ } catch (_) {}
} finally {
await _loadNetworkState();
await _refreshServerStatus();
@@ -393,7 +422,9 @@ class _SettingsScreenState extends State with WidgetsBindingObse
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
- message.isEmpty ? l10n.networkToggleFailed : l10n.hotspotFailedWithReason(message),
+ message.isEmpty
+ ? l10n.networkToggleFailed
+ : l10n.hotspotFailedWithReason(message),
),
),
);
@@ -402,12 +433,11 @@ class _SettingsScreenState extends State with WidgetsBindingObse
} catch (_) {
try {
await _networkService.openHotspotSettings();
- } catch (_) {
- }
+ } catch (_) {}
if (mounted) {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text(l10n.networkToggleFailed)),
- );
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text(l10n.networkToggleFailed)));
}
} finally {
await _loadNetworkState();
@@ -424,20 +454,19 @@ class _SettingsScreenState extends State with WidgetsBindingObse
if (!ok) {
await _networkService.openInternetSettings();
if (mounted) {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text(l10n.networkToggleFailed)),
- );
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text(l10n.networkToggleFailed)));
}
}
} catch (_) {
try {
await _networkService.openInternetSettings();
- } catch (_) {
- }
+ } catch (_) {}
if (mounted) {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text(l10n.networkToggleFailed)),
- );
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text(l10n.networkToggleFailed)));
}
} finally {
await _loadNetworkState();
@@ -450,11 +479,11 @@ class _SettingsScreenState extends State with WidgetsBindingObse
final l10n = AppLocalizations.of(context)!;
final hotspotSubtitle = _hotspotMode == 'system'
? (_hotspotSsid != null
- ? '${l10n.hotspotEnabledInSystemSettings}\n${l10n.ssidLabel}: ${_hotspotSsid ?? '-'}\n${l10n.passwordLabel}: ${_hotspotPassword ?? '-'}'
- : '${l10n.hotspotEnabledInSystemSettings}\n${l10n.hotspotChangeInSystemSettings}')
+ ? '${l10n.hotspotEnabledInSystemSettings}\n${l10n.ssidLabel}: ${_hotspotSsid ?? '-'}\n${l10n.passwordLabel}: ${_hotspotPassword ?? '-'}'
+ : '${l10n.hotspotEnabledInSystemSettings}\n${l10n.hotspotChangeInSystemSettings}')
: (_hotspotEnabled && _hotspotSsid != null
- ? '${l10n.hotspotHint}\n${l10n.ssidLabel}: ${_hotspotSsid ?? '-'}\n${l10n.passwordLabel}: ${_hotspotPassword ?? '-'}'
- : l10n.hotspotHint);
+ ? '${l10n.hotspotHint}\n${l10n.ssidLabel}: ${_hotspotSsid ?? '-'}\n${l10n.passwordLabel}: ${_hotspotPassword ?? '-'}'
+ : l10n.hotspotHint);
return Card(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -482,14 +511,149 @@ class _SettingsScreenState extends State with WidgetsBindingObse
);
}
+ String _formatFold(double fold) {
+ final s = fold.toStringAsFixed(2);
+ return s.replaceFirst(RegExp(r'\.?0+$'), '');
+ }
+
+ Future _savePaymentDiscounts() async {
+ final l10n = AppLocalizations.of(context)!;
+ final alipayFold =
+ double.tryParse(_alipayDiscountController.text.trim()) ?? 10.0;
+ final wechatFold =
+ double.tryParse(_wechatDiscountController.text.trim()) ?? 10.0;
+ final valid =
+ alipayFold > 0 &&
+ alipayFold <= 10 &&
+ wechatFold > 0 &&
+ wechatFold <= 10;
+ if (!valid) {
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(const SnackBar(content: Text('折扣需在 0~10 之间,例如 9.9')));
+ return;
+ }
+ await _settingsService.setPaymentDiscountFold('alipay', alipayFold);
+ await _settingsService.setPaymentDiscountFold('wechat', wechatFold);
+ if (!mounted) return;
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text(l10n.save)));
+ }
+
+ Widget _buildPaymentDiscountSection() {
+ final l10n = AppLocalizations.of(context)!;
+ return Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Text(
+ '渠道优惠',
+ style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
+ ),
+ const SizedBox(height: 8),
+ const Text('结算页显示原价;实收自动校验按渠道折扣计算。'),
+ const SizedBox(height: 12),
+ TextField(
+ controller: _alipayDiscountController,
+ keyboardType: const TextInputType.numberWithOptions(
+ decimal: true,
+ ),
+ decoration: InputDecoration(
+ labelText: '${l10n.paymentMethodAlipay} 折扣(折)',
+ hintText: '10',
+ ),
+ ),
+ const SizedBox(height: 12),
+ TextField(
+ controller: _wechatDiscountController,
+ keyboardType: const TextInputType.numberWithOptions(
+ decimal: true,
+ ),
+ decoration: InputDecoration(
+ labelText: '${l10n.paymentMethodWechat} 折扣(折)',
+ hintText: '9.9',
+ ),
+ ),
+ const SizedBox(height: 12),
+ Align(
+ alignment: Alignment.centerRight,
+ child: FilledButton(
+ onPressed: _savePaymentDiscounts,
+ child: Text(l10n.save),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Future _setCameraBarcodeRecognition(bool enabled) async {
+ if (enabled) {
+ final status = await Permission.camera.status;
+ if (!status.isGranted) {
+ final next = await Permission.camera.request();
+ if (!next.isGranted) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('相机权限被拒绝,无法启用摄像头识别')),
+ );
+ }
+ return;
+ }
+ }
+ }
+
+ await _settingsService.setCameraBarcodeRecognitionEnabled(enabled);
+ if (!mounted) return;
+ setState(() {
+ _cameraBarcodeRecognitionEnabled = enabled;
+ });
+ }
+
+ Future _setFaceWakeupEnabled(bool enabled) async {
+ await _settingsService.setFaceWakeupEnabled(enabled);
+ if (!mounted) return;
+ setState(() {
+ _faceWakeupEnabled = enabled;
+ });
+ }
+
+ Widget _buildBarcodeRecognitionSection() {
+ return Card(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ SwitchListTile(
+ value: _cameraBarcodeRecognitionEnabled,
+ onChanged: _setCameraBarcodeRecognition,
+ title: const Text('通过摄像头识别条形码'),
+ subtitle: const Text('关闭后保留相机画面,但不进行条码识别'),
+ ),
+ const Divider(height: 1),
+ SwitchListTile(
+ value: _faceWakeupEnabled,
+ onChanged: _setFaceWakeupEnabled,
+ title: const Text('检测到人脸自动亮屏'),
+ subtitle: const Text('启用后检测到人脸会自动解除黑屏并重置熄屏计时'),
+ ),
+ ],
+ ),
+ );
+ }
+
Future _startServer({bool silent = false}) async {
final l10n = AppLocalizations.of(context)!;
if (_isLoading) return;
if (_pinController.text.length < 4) {
if (!silent) {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text(l10n.pinLength)),
- );
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text(l10n.pinLength)));
}
return;
}
@@ -502,7 +666,7 @@ class _SettingsScreenState extends State with WidgetsBindingObse
_deviceId ??= await _settingsService.getOrCreateDeviceId();
await _serverService.startServer(_pinController.text, deviceId: _deviceId);
-
+
if (mounted) {
setState(() {
_isLoading = false;
@@ -510,9 +674,9 @@ class _SettingsScreenState extends State with WidgetsBindingObse
_updateQrData();
});
if (!silent && _serverService.ipAddress == null) {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text(l10n.serverNoIp)),
- );
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text(l10n.serverNoIp)));
}
}
}
@@ -527,54 +691,55 @@ class _SettingsScreenState extends State with WidgetsBindingObse
Padding(
padding: const EdgeInsets.all(16.0),
child: Center(
- child: _isLoading
- ? const CircularProgressIndicator()
- : _isServerRunning
+ child: _isLoading
+ ? const CircularProgressIndicator()
+ : _isServerRunning
? SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
- Text(
- l10n.kioskReadyToSync,
- style: TextStyle(fontSize: 24, color: Colors.green),
- ),
- const SizedBox(height: 20),
- if (_qrData != null)
- Container(
- color: Colors.white,
- padding: const EdgeInsets.all(16),
- child: QrImageView(
- data: _qrData!,
- size: 250,
+ Text(
+ l10n.kioskReadyToSync,
+ style: TextStyle(fontSize: 24, color: Colors.green),
+ ),
+ const SizedBox(height: 20),
+ if (_qrData != null)
+ Container(
+ color: Colors.white,
+ padding: const EdgeInsets.all(16),
+ child: QrImageView(data: _qrData!, size: 250),
+ ),
+ const SizedBox(height: 20),
+ Text(
+ l10n.ipAddressLabel(
+ _serverService.ipAddress ?? '-',
+ _serverService.port,
),
),
- const SizedBox(height: 20),
- Text(
- l10n.ipAddressLabel(
- _serverService.ipAddress ?? '-',
- _serverService.port,
+ const SizedBox(height: 24),
+ ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 520),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ _buildLauncherSection(),
+ const SizedBox(height: 16),
+ _buildNetworkSection(),
+ const SizedBox(height: 16),
+ _buildPaymentDiscountSection(),
+ const SizedBox(height: 16),
+ _buildBarcodeRecognitionSection(),
+ ],
+ ),
),
- ),
- const SizedBox(height: 24),
- ConstrainedBox(
- constraints: const BoxConstraints(maxWidth: 520),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- _buildLauncherSection(),
- const SizedBox(height: 16),
- _buildNetworkSection(),
- ],
+ const SizedBox(height: 16),
+ ElevatedButton(
+ onPressed: () {
+ // Restart server logic or close page
+ Navigator.pop(context);
+ },
+ child: Text(l10n.close),
),
- ),
- const SizedBox(height: 16),
- ElevatedButton(
- onPressed: () {
- // Restart server logic or close page
- Navigator.pop(context);
- },
- child: Text(l10n.close),
- ),
],
),
)
@@ -582,34 +747,45 @@ class _SettingsScreenState extends State with WidgetsBindingObse
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
- const Icon(Icons.error_outline, size: 60, color: Colors.red),
- const SizedBox(height: 20),
- Text(
- l10n.serverStartFailedTitle,
- style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
- ),
- const SizedBox(height: 10),
- Text(
- l10n.serverStartFailedMessage,
- textAlign: TextAlign.center,
- ),
- const SizedBox(height: 24),
- ConstrainedBox(
- constraints: const BoxConstraints(maxWidth: 520),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- _buildLauncherSection(),
- const SizedBox(height: 16),
- _buildNetworkSection(),
- ],
+ const Icon(
+ Icons.error_outline,
+ size: 60,
+ color: Colors.red,
+ ),
+ const SizedBox(height: 20),
+ Text(
+ l10n.serverStartFailedTitle,
+ style: TextStyle(
+ fontSize: 24,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 10),
+ Text(
+ l10n.serverStartFailedMessage,
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 24),
+ ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 520),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ _buildLauncherSection(),
+ const SizedBox(height: 16),
+ _buildNetworkSection(),
+ const SizedBox(height: 16),
+ _buildPaymentDiscountSection(),
+ const SizedBox(height: 16),
+ _buildBarcodeRecognitionSection(),
+ ],
+ ),
+ ),
+ const SizedBox(height: 16),
+ ElevatedButton(
+ onPressed: _startServer,
+ child: Text(l10n.retry),
),
- ),
- const SizedBox(height: 16),
- ElevatedButton(
- onPressed: _startServer,
- child: Text(l10n.retry),
- ),
],
),
),
@@ -626,7 +802,11 @@ class _SettingsScreenState extends State with WidgetsBindingObse
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
- const Icon(Icons.check_circle, color: Colors.green, size: 96),
+ const Icon(
+ Icons.check_circle,
+ color: Colors.green,
+ size: 96,
+ ),
const SizedBox(height: 16),
Text(
l10n.restoreComplete,
@@ -639,7 +819,10 @@ class _SettingsScreenState extends State with WidgetsBindingObse
const SizedBox(height: 8),
Text(
l10n.returningHome,
- style: const TextStyle(color: Colors.white70, fontSize: 16),
+ style: const TextStyle(
+ color: Colors.white70,
+ fontSize: 16,
+ ),
),
],
),
diff --git a/Kiosk/lib/services/add_success_sound_service.dart b/Kiosk/lib/services/add_success_sound_service.dart
new file mode 100644
index 0000000..3d50524
--- /dev/null
+++ b/Kiosk/lib/services/add_success_sound_service.dart
@@ -0,0 +1,48 @@
+import 'package:audioplayers/audioplayers.dart';
+import 'package:flutter/foundation.dart';
+
+abstract class AddSuccessSoundBackend {
+ Future playAsset(String assetPath);
+ Future dispose();
+}
+
+class AudioplayersAddSuccessSoundBackend implements AddSuccessSoundBackend {
+ final AudioPlayer _player = AudioPlayer();
+
+ @override
+ Future playAsset(String assetPath) async {
+ await _player.stop();
+ await _player.play(AssetSource(assetPath));
+ }
+
+ @override
+ Future dispose() => _player.dispose();
+}
+
+class AddSuccessSoundService {
+ final AddSuccessSoundBackend _backend;
+ final String _assetPath;
+
+ AddSuccessSoundService({
+ required AddSuccessSoundBackend backend,
+ required String assetPath,
+ }) : _backend = backend,
+ _assetPath = assetPath;
+
+ factory AddSuccessSoundService.withDefaultBackend() {
+ return AddSuccessSoundService(
+ backend: AudioplayersAddSuccessSoundBackend(),
+ assetPath: 'audio/add_success.mp3',
+ );
+ }
+
+ Future play() async {
+ try {
+ await _backend.playAsset(_assetPath);
+ } catch (e) {
+ debugPrint('Add success sound playback failed: $e');
+ }
+ }
+
+ Future dispose() => _backend.dispose();
+}
diff --git a/Kiosk/lib/services/payment_notification_watch_service.dart b/Kiosk/lib/services/payment_notification_watch_service.dart
index b50df75..266ef32 100644
--- a/Kiosk/lib/services/payment_notification_watch_service.dart
+++ b/Kiosk/lib/services/payment_notification_watch_service.dart
@@ -18,18 +18,18 @@ class PaymentNotificationWatchResult {
});
const PaymentNotificationWatchResult.matched()
- : this._(matched: true, timedOut: false, amountMismatched: false);
+ : this._(matched: true, timedOut: false, amountMismatched: false);
const PaymentNotificationWatchResult.timedOut()
- : this._(matched: false, timedOut: true, amountMismatched: false);
+ : this._(matched: false, timedOut: true, amountMismatched: false);
const PaymentNotificationWatchResult.amountMismatched(String text)
- : this._(
- matched: false,
- timedOut: false,
- amountMismatched: true,
- mismatchText: text,
- );
+ : this._(
+ matched: false,
+ timedOut: false,
+ amountMismatched: true,
+ mismatchText: text,
+ );
}
class PaymentNotificationWatchService {
@@ -39,9 +39,10 @@ class PaymentNotificationWatchService {
PaymentNotificationWatchService({
AndroidNotificationListenerService? androidNotificationListenerService,
DatabaseHelper? databaseHelper,
- }) : _notificationListenerService =
- androidNotificationListenerService ?? AndroidNotificationListenerService(),
- _db = databaseHelper ?? DatabaseHelper.instance;
+ }) : _notificationListenerService =
+ androidNotificationListenerService ??
+ AndroidNotificationListenerService(),
+ _db = databaseHelper ?? DatabaseHelper.instance;
final AndroidNotificationListenerService _notificationListenerService;
final DatabaseHelper _db;
@@ -67,15 +68,21 @@ class PaymentNotificationWatchService {
Future> buildBaselineKeys() async {
final keys = {};
- final alipaySnapshot = await _notificationListenerService.getActiveAlipayNotificationsSnapshot();
- final wechatSnapshot = await _notificationListenerService.getActiveWechatNotificationsSnapshot();
+ 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));
+ 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));
+ if (key is String && key.isNotEmpty) {
+ keys.add(buildProviderKey(wechatPackage, key));
+ }
}
return keys;
}
@@ -85,6 +92,7 @@ class PaymentNotificationWatchService {
required double orderAmount,
required int checkoutTimeMs,
required Set baselineKeys,
+ Map providerDiscountFolds = const {},
required void Function() onMatched,
required void Function(String message) onMismatch,
required void Function() onTimeout,
@@ -93,8 +101,20 @@ class PaymentNotificationWatchService {
_active = true;
final expectedFen = _amountToFen(orderAmount);
+ final expectedFenByPackage = {
+ alipayPackage: expectedFenForPackage(
+ orderAmount: orderAmount,
+ packageName: alipayPackage,
+ providerDiscountFolds: providerDiscountFolds,
+ ),
+ wechatPackage: expectedFenForPackage(
+ orderAmount: orderAmount,
+ packageName: wechatPackage,
+ providerDiscountFolds: providerDiscountFolds,
+ ),
+ };
debugPrint(
- 'PaymentWatch start orderId=$orderId expectedFen=$expectedFen checkoutTimeMs=$checkoutTimeMs baselineKeys=${baselineKeys.length}',
+ 'PaymentWatch start orderId=$orderId expectedFen=$expectedFen expectedFenByPackage=$expectedFenByPackage checkoutTimeMs=$checkoutTimeMs baselineKeys=${baselineKeys.length}',
);
_timeoutTimer = Timer(const Duration(minutes: 5), () async {
if (!_active) return;
@@ -115,6 +135,7 @@ class PaymentNotificationWatchService {
await _evaluateNotification(
notification: Map.from(payload),
expectedFen: expectedFen,
+ expectedFenByPackage: expectedFenByPackage,
orderId: orderId,
checkoutTimeMs: checkoutTimeMs,
baselineKeys: baselineKeys,
@@ -125,27 +146,28 @@ class PaymentNotificationWatchService {
_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) {
+ final alipaySnapshot = await _notificationListenerService
+ .getActiveAlipayNotificationsSnapshot();
+ final wechatSnapshot = await _notificationListenerService
+ .getActiveWechatNotificationsSnapshot();
+ final latestAlipayPayment =
+ await _notificationListenerService.getLatestAlipayPaymentNotification();
+ final latestWechatPayment =
+ await _notificationListenerService.getLatestWechatPaymentNotification();
+
+ final polledNotifications = buildPolledNotifications(
+ alipaySnapshot: alipaySnapshot,
+ wechatSnapshot: wechatSnapshot,
+ latestAlipayPayment: latestAlipayPayment,
+ latestWechatPayment: latestWechatPayment,
+ );
+
+ for (final n in polledNotifications) {
if (!_active) return;
await _evaluateNotification(
notification: n,
expectedFen: expectedFen,
+ expectedFenByPackage: expectedFenByPackage,
orderId: orderId,
checkoutTimeMs: checkoutTimeMs,
baselineKeys: baselineKeys,
@@ -156,9 +178,41 @@ class PaymentNotificationWatchService {
});
}
+ static List