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> buildPolledNotifications({ + required List> alipaySnapshot, + required List> wechatSnapshot, + required Map? latestAlipayPayment, + required Map? latestWechatPayment, + }) { + final result = >[]; + final dedupKeys = {}; + + void addNotification(Map? n) { + if (n == null) return; + final pkg = n['package']; + final key = n['key']; + final postTime = n['postTime']; + final dedup = '$pkg|$key|$postTime'; + if (dedupKeys.add(dedup)) { + result.add(n); + } + } + + for (final n in alipaySnapshot) { + addNotification(n); + } + for (final n in wechatSnapshot) { + addNotification(n); + } + addNotification(latestAlipayPayment); + addNotification(latestWechatPayment); + return result; + } + Future _evaluateNotification({ required Map notification, required int expectedFen, + required Map expectedFenByPackage, required String orderId, required int checkoutTimeMs, required Set baselineKeys, @@ -198,11 +252,15 @@ class PaymentNotificationWatchService { return; } - if (parsedFen != expectedFen) { + final providerExpectedFen = + expectedFenByPackage[providerPackage] ?? expectedFen; + if (parsedFen != providerExpectedFen) { debugPrint( - 'PaymentWatch mismatch orderId=$orderId provider=$providerPackage key=$key postTime=$postTime expectedFen=$expectedFen parsedFen=$parsedFen text=${text ?? bigText ?? ""}', + 'PaymentWatch mismatch orderId=$orderId provider=$providerPackage key=$key postTime=$postTime expectedFen=$providerExpectedFen parsedFen=$parsedFen text=${text ?? bigText ?? ""}', + ); + onMismatch( + 'mismatch: expectedFen=$providerExpectedFen parsedFen=$parsedFen', ); - onMismatch('mismatch: expectedFen=$expectedFen parsedFen=$parsedFen'); return; } @@ -255,10 +313,36 @@ class PaymentNotificationWatchService { return _decimalStringToFen(s); } + static int expectedFenForPackage({ + required double orderAmount, + required String packageName, + required Map providerDiscountFolds, + }) { + final orderFen = _amountToFen(orderAmount); + final providerKey = _providerKeyForPackage(packageName); + if (providerKey == null) return orderFen; + final fold = providerDiscountFolds[providerKey]; + if (fold == null || !fold.isFinite || fold <= 0 || fold > 10) { + return orderFen; + } + final basisPoints = (fold * 1000).round(); + return (orderFen * basisPoints) ~/ 10000; + } + + static String? _providerKeyForPackage(String packageName) { + if (packageName == alipayPackage) return 'alipay'; + if (packageName == wechatPackage) return 'wechat'; + return null; + } + 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 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'; @@ -266,8 +350,9 @@ class PaymentNotificationWatchService { } static int? parseAlipaySuccessAmountFen(String s) { - final m = RegExp(r'成功收款\s*[¥¥]?\s*([0-9][0-9,]*)(?:\.([0-9]{1,2}))?\s*元') - .firstMatch(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; diff --git a/Kiosk/lib/services/scanner_keyboard_buffer.dart b/Kiosk/lib/services/scanner_keyboard_buffer.dart new file mode 100644 index 0000000..e711f32 --- /dev/null +++ b/Kiosk/lib/services/scanner_keyboard_buffer.dart @@ -0,0 +1,32 @@ +class ScannerKeyboardBuffer { + ScannerKeyboardBuffer({this.minLength = 4}); + + final int minLength; + final StringBuffer _buffer = StringBuffer(); + + String? pushCharacter(String? character) { + if (!_isVisibleCharacter(character)) return null; + _buffer.write(character); + return null; + } + + String? commit() { + final value = _buffer.toString().trim(); + _buffer.clear(); + if (value.length < minLength) return null; + return value; + } + + void clear() { + _buffer.clear(); + } + + bool _isVisibleCharacter(String? character) { + if (character == null || character.isEmpty) return false; + if (character == '\n' || character == '\r' || character == '\t') return false; + for (final rune in character.runes) { + if (rune < 32 || rune == 127) return false; + } + return true; + } +} diff --git a/Kiosk/lib/services/screen_wake_bus.dart b/Kiosk/lib/services/screen_wake_bus.dart new file mode 100644 index 0000000..150507b --- /dev/null +++ b/Kiosk/lib/services/screen_wake_bus.dart @@ -0,0 +1,15 @@ +import 'package:flutter/foundation.dart'; + +class ScreenWakeBus extends ChangeNotifier { + ScreenWakeBus(); + + static final ScreenWakeBus instance = ScreenWakeBus(); + + int _sequence = 0; + int get sequence => _sequence; + + void requestWake() { + _sequence++; + notifyListeners(); + } +} diff --git a/Kiosk/lib/services/settings_service.dart b/Kiosk/lib/services/settings_service.dart index a31e22e..e216358 100644 --- a/Kiosk/lib/services/settings_service.dart +++ b/Kiosk/lib/services/settings_service.dart @@ -10,6 +10,10 @@ class SettingsService { static const String _pendingPaymentOrderIdKey = 'pending_payment_order_id'; static const String _homeAppPackageKey = 'home_app_package'; static const String _homeAppLabelKey = 'home_app_label'; + static const String _paymentDiscountFoldsKey = 'payment_discount_folds'; + static const String _cameraBarcodeRecognitionEnabledKey = + 'camera_barcode_recognition_enabled'; + static const String _faceWakeupEnabledKey = 'face_wakeup_enabled'; Future init() async { await Hive.openBox(_boxName); @@ -70,6 +74,64 @@ class SettingsService { await _box.put(_paymentQrsKey, current); } + Map getPaymentDiscountFolds() { + final v = _box.get(_paymentDiscountFoldsKey); + if (v is! Map) return {}; + final result = {}; + for (final entry in v.entries) { + final key = entry.key?.toString().trim().toLowerCase(); + if (key == null || key.isEmpty) continue; + final raw = entry.value; + double? fold; + if (raw is num) { + fold = raw.toDouble(); + } else if (raw is String) { + fold = double.tryParse(raw.trim()); + } + if (fold == null || !fold.isFinite || fold <= 0 || fold > 10) continue; + result[key] = fold; + } + return result; + } + + double getPaymentDiscountFold(String provider) { + final key = provider.trim().toLowerCase(); + if (key.isEmpty) return 10.0; + return getPaymentDiscountFolds()[key] ?? 10.0; + } + + Future setPaymentDiscountFold(String provider, double fold) async { + final key = provider.trim().toLowerCase(); + if (key.isEmpty) return; + if (!fold.isFinite || fold <= 0 || fold > 10) return; + final current = getPaymentDiscountFolds(); + current[key] = fold; + await _box.put(_paymentDiscountFoldsKey, current); + } + + bool getCameraBarcodeRecognitionEnabled() { + final stored = _box.get(_cameraBarcodeRecognitionEnabledKey); + if (stored is bool) return stored; + + // Keep upgraded installs behavior unchanged (camera recognition on), + // while fresh installs default to off. + return _box.isEmpty ? false : true; + } + + Future setCameraBarcodeRecognitionEnabled(bool enabled) async { + await _box.put(_cameraBarcodeRecognitionEnabledKey, enabled); + } + + bool getFaceWakeupEnabled() { + final stored = _box.get(_faceWakeupEnabledKey); + if (stored is bool) return stored; + return true; + } + + Future setFaceWakeupEnabled(bool enabled) async { + await _box.put(_faceWakeupEnabledKey, enabled); + } + String? getDeviceId() { return _box.get(_deviceIdKey); } @@ -130,7 +192,6 @@ class SettingsService { await _box.delete(_homeAppLabelKey); } - Future getOrCreateDeviceId() async { final existing = getDeviceId(); if (existing != null && existing.isNotEmpty) { diff --git a/Kiosk/lib/widgets/camera_monitor_badge.dart b/Kiosk/lib/widgets/camera_monitor_badge.dart new file mode 100644 index 0000000..a1c30b0 --- /dev/null +++ b/Kiosk/lib/widgets/camera_monitor_badge.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +class CameraMonitorBadge extends StatelessWidget { + const CameraMonitorBadge({ + super.key, + this.label = '实时监控中', + }); + + final String label; + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.topCenter, + child: Container( + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + label, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } +} diff --git a/Kiosk/lib/widgets/screen_saver.dart b/Kiosk/lib/widgets/screen_saver.dart index c95eaee..8306c70 100644 --- a/Kiosk/lib/widgets/screen_saver.dart +++ b/Kiosk/lib/widgets/screen_saver.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:kiosk/services/screen_wake_bus.dart'; class ScreenSaver extends StatefulWidget { final Widget child; @@ -18,19 +19,27 @@ class ScreenSaver extends StatefulWidget { class _ScreenSaverState extends State { Timer? _timer; bool _isActive = false; + late final ScreenWakeBus _wakeBus; @override void initState() { super.initState(); + _wakeBus = ScreenWakeBus.instance; + _wakeBus.addListener(_onWakeRequested); _resetTimer(); } @override void dispose() { + _wakeBus.removeListener(_onWakeRequested); _timer?.cancel(); super.dispose(); } + void _onWakeRequested() { + _wakeUp(); + } + void _resetTimer() { _timer?.cancel(); if (_isActive) return; // Don't restart timer if already active (waiting for tap) diff --git a/Kiosk/linux/flutter/generated_plugin_registrant.cc b/Kiosk/linux/flutter/generated_plugin_registrant.cc index e71a16d..1830e5c 100644 --- a/Kiosk/linux/flutter/generated_plugin_registrant.cc +++ b/Kiosk/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); + audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); } diff --git a/Kiosk/linux/flutter/generated_plugins.cmake b/Kiosk/linux/flutter/generated_plugins.cmake index 2e1de87..e9abb91 100644 --- a/Kiosk/linux/flutter/generated_plugins.cmake +++ b/Kiosk/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/Kiosk/macos/Flutter/GeneratedPluginRegistrant.swift b/Kiosk/macos/Flutter/GeneratedPluginRegistrant.swift index 6d0fe86..b088f3b 100644 --- a/Kiosk/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/Kiosk/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,8 +5,8 @@ import FlutterMacOS import Foundation +import audioplayers_darwin import connectivity_plus -import mobile_scanner import network_info_plus import package_info_plus import path_provider_foundation @@ -15,8 +15,8 @@ import sqflite_darwin import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) - MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) NetworkInfoPlusPlugin.register(with: registry.registrar(forPlugin: "NetworkInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) diff --git a/Kiosk/pubspec.lock b/Kiosk/pubspec.lock index 7c92d1e..9f34c06 100644 --- a/Kiosk/pubspec.lock +++ b/Kiosk/pubspec.lock @@ -33,6 +33,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: "5441fa0ceb8807a5ad701199806510e56afde2b4913d9d17c2f19f2902cf0ae4" + url: "https://pub.dev" + source: hosted + version: "6.5.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333" + url: "https://pub.dev" + source: hosted + version: "6.3.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 + url: "https://pub.dev" + source: hosted + version: "4.2.1" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" + url: "https://pub.dev" + source: hosted + version: "7.1.1" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7" + url: "https://pub.dev" + source: hosted + version: "4.2.1" boolean_selector: dependency: transitive description: @@ -105,14 +161,54 @@ packages: url: "https://pub.dev" source: hosted version: "8.12.3" + camera: + dependency: "direct main" + description: + name: camera + sha256: "4142a19a38e388d3bab444227636610ba88982e36dff4552d5191a86f65dc437" + url: "https://pub.dev" + source: hosted + version: "0.11.4" + camera_android_camerax: + dependency: transitive + description: + name: camera_android_camerax + sha256: "8516fe308bc341a5067fb1a48edff0ddfa57c0d3cdcc9dbe7ceca3ba119e2577" + url: "https://pub.dev" + source: hosted + version: "0.6.30" + camera_avfoundation: + dependency: transitive + description: + name: camera_avfoundation + sha256: "11b4aee2f5e5e038982e152b4a342c749b414aa27857899d20f4323e94cb5f0b" + url: "https://pub.dev" + source: hosted + version: "0.9.23+2" + camera_platform_interface: + dependency: transitive + description: + name: camera_platform_interface + sha256: "98cfc9357e04bad617671b4c1f78a597f25f08003089dd94050709ae54effc63" + url: "https://pub.dev" + source: hosted + version: "2.12.0" + camera_web: + dependency: transitive + description: + name: camera_web + sha256: "57f49a635c8bf249d07fb95eb693d7e4dda6796dedb3777f9127fb54847beba7" + url: "https://pub.dev" + source: hosted + version: "0.3.5+3" characters: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -169,6 +265,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -256,6 +360,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.dev" + source: hosted + version: "2.0.33" flutter_test: dependency: "direct dev" description: flutter @@ -287,6 +399,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + google_mlkit_barcode_scanning: + dependency: "direct main" + description: + name: google_mlkit_barcode_scanning + sha256: dbf4df99cb490b12e95dcedde3e6741ce711942904f7049da601dfede91c1e0f + url: "https://pub.dev" + source: hosted + version: "0.14.2" + google_mlkit_commons: + dependency: transitive + description: + name: google_mlkit_commons + sha256: "3e69fea4211727732cc385104e675ad1e40b29f12edd492ee52fa108423a6124" + url: "https://pub.dev" + source: hosted + version: "0.11.1" + google_mlkit_face_detection: + dependency: "direct main" + description: + name: google_mlkit_face_detection + sha256: "7b6ddcc69dbd6fbfa313fb2d974ad0f0c3a0d1657560f0da6be465baf1889687" + url: "https://pub.dev" + source: hosted + version: "0.13.2" graphs: dependency: transitive description: @@ -432,26 +568,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -460,14 +596,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" - mobile_scanner: + native_device_orientation: dependency: "direct main" description: - name: mobile_scanner - sha256: c6184bf2913dd66be244108c9c27ca04b01caf726321c44b0e7a7a1e32d41044 + name: native_device_orientation + sha256: "711aabfd7c67396f6562437cba078d17291f8b7c454c2fbc9739c9d7141b041d" url: "https://pub.dev" source: hosted - version: "7.1.4" + version: "2.1.0" nested: dependency: transitive description: @@ -925,10 +1061,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.9" timing: dependency: transitive description: diff --git a/Kiosk/pubspec.yaml b/Kiosk/pubspec.yaml index 381b7f9..a98dfa3 100644 --- a/Kiosk/pubspec.yaml +++ b/Kiosk/pubspec.yaml @@ -16,7 +16,9 @@ dependencies: hive_flutter: ^1.1.0 http: ^1.6.0 intl: ^0.20.2 - mobile_scanner: ^7.1.4 + camera: ^0.11.0+2 + google_mlkit_barcode_scanning: ^0.14.1 + google_mlkit_face_detection: ^0.13.1 connectivity_plus: ^6.1.4 network_info_plus: ^7.0.0 path: ^1.9.1 @@ -29,6 +31,8 @@ dependencies: sqflite: ^2.4.2 uuid: ^4.5.2 wakelock_plus: ^1.4.0 + native_device_orientation: ^2.1.0 + audioplayers: ^6.1.0 dev_dependencies: integration_test: @@ -45,3 +49,4 @@ flutter: uses-material-design: true assets: - .env + - assets/audio/ diff --git a/Kiosk/test/add_success_sound_service_test.dart b/Kiosk/test/add_success_sound_service_test.dart new file mode 100644 index 0000000..1456d3b --- /dev/null +++ b/Kiosk/test/add_success_sound_service_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kiosk/services/add_success_sound_service.dart'; + +class _FakeBackend implements AddSuccessSoundBackend { + int playCount = 0; + bool throwOnPlay = false; + bool disposed = false; + + @override + Future playAsset(String assetPath) async { + if (throwOnPlay) { + throw Exception('play failed'); + } + playCount++; + } + + @override + Future dispose() async { + disposed = true; + } +} + +void main() { + test('plays configured asset every time', () async { + final backend = _FakeBackend(); + final service = AddSuccessSoundService( + backend: backend, + assetPath: 'assets/audio/add_success.mp3', + ); + + await service.play(); + await service.play(); + + expect(backend.playCount, 2); + }); + + test('swallows playback errors and remains usable', () async { + final backend = _FakeBackend()..throwOnPlay = true; + final service = AddSuccessSoundService( + backend: backend, + assetPath: 'assets/audio/add_success.mp3', + ); + + await expectLater(service.play(), completes); + }); + + test('disposes backend', () async { + final backend = _FakeBackend(); + final service = AddSuccessSoundService( + backend: backend, + assetPath: 'assets/audio/add_success.mp3', + ); + + await service.dispose(); + + expect(backend.disposed, isTrue); + }); +} diff --git a/Kiosk/test/camera_monitor_badge_test.dart b/Kiosk/test/camera_monitor_badge_test.dart new file mode 100644 index 0000000..4fc4f12 --- /dev/null +++ b/Kiosk/test/camera_monitor_badge_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kiosk/widgets/camera_monitor_badge.dart'; + +void main() { + testWidgets('camera monitor badge shows centered top label', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SizedBox( + width: 200, + height: 150, + child: CameraMonitorBadge(), + ), + ), + ), + ); + + expect(find.text('实时监控中'), findsOneWidget); + final align = tester.widget(find.byType(Align)); + expect(align.alignment, Alignment.topCenter); + }); +} diff --git a/Kiosk/test/payment_discount_test.dart b/Kiosk/test/payment_discount_test.dart new file mode 100644 index 0000000..587f206 --- /dev/null +++ b/Kiosk/test/payment_discount_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kiosk/services/payment_notification_watch_service.dart'; + +void main() { + group('PaymentDiscount', () { + test('wechat 9.9 fold turns 4.00 into 3.96', () { + final expectedFen = PaymentNotificationWatchService.expectedFenForPackage( + orderAmount: 4.00, + packageName: PaymentNotificationWatchService.wechatPackage, + providerDiscountFolds: const {'wechat': 9.9}, + ); + + expect(expectedFen, 396); + }); + + test('default keeps full amount when provider has no discount', () { + final expectedFen = PaymentNotificationWatchService.expectedFenForPackage( + orderAmount: 4.00, + packageName: PaymentNotificationWatchService.alipayPackage, + providerDiscountFolds: const {'wechat': 9.9}, + ); + + expect(expectedFen, 400); + }); + + test('wechat 9.9 fold truncates 5.50 to 5.44', () { + final expectedFen = PaymentNotificationWatchService.expectedFenForPackage( + orderAmount: 5.50, + packageName: PaymentNotificationWatchService.wechatPackage, + providerDiscountFolds: const {'wechat': 9.9}, + ); + + expect(expectedFen, 544); + }); + }); +} diff --git a/Kiosk/test/payment_screen_auto_confirm_gate_test.dart b/Kiosk/test/payment_screen_auto_confirm_gate_test.dart new file mode 100644 index 0000000..00575aa --- /dev/null +++ b/Kiosk/test/payment_screen_auto_confirm_gate_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kiosk/screens/payment_screen.dart'; + +void main() { + group('PaymentScreen auto confirm gating', () { + test('cannot start when checkout flag is false', () { + final canStart = PaymentScreen.canStartAutoConfirm( + checkoutAutoConfirmEnabled: false, + notificationListenerEnabled: true, + ); + expect(canStart, isFalse); + }); + + test('cannot start when listener is disabled', () { + final canStart = PaymentScreen.canStartAutoConfirm( + checkoutAutoConfirmEnabled: true, + notificationListenerEnabled: false, + ); + expect(canStart, isFalse); + }); + + test('can start when both are enabled', () { + final canStart = PaymentScreen.canStartAutoConfirm( + checkoutAutoConfirmEnabled: true, + notificationListenerEnabled: true, + ); + expect(canStart, isTrue); + }); + }); +} diff --git a/Kiosk/test/payment_screen_latest_fallback_test.dart b/Kiosk/test/payment_screen_latest_fallback_test.dart new file mode 100644 index 0000000..2b7d39d --- /dev/null +++ b/Kiosk/test/payment_screen_latest_fallback_test.dart @@ -0,0 +1,18 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kiosk/screens/payment_screen.dart'; +import 'package:kiosk/services/payment_notification_watch_service.dart'; + +void main() { + test('latest wechat payment text can match discounted expected amount', () { + final matched = PaymentScreen.latestPaymentMatchesExpected( + packageName: PaymentNotificationWatchService.wechatPackage, + title: '微信收款助手', + text: '[10条]微信收款助手: [店员消息]收款到账5.44元, 原价5.50元, 有优惠', + bigText: null, + orderAmount: 5.50, + providerDiscountFolds: const {'wechat': 9.9}, + ); + + expect(matched, isTrue); + }); +} diff --git a/Kiosk/test/payment_watch_polling_test.dart b/Kiosk/test/payment_watch_polling_test.dart new file mode 100644 index 0000000..3e3b83f --- /dev/null +++ b/Kiosk/test/payment_watch_polling_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kiosk/services/payment_notification_watch_service.dart'; + +void main() { + group('PaymentWatch polling fallback', () { + test('includes latest payment notifications when snapshots are empty', () { + final combined = PaymentNotificationWatchService.buildPolledNotifications( + alipaySnapshot: const [], + wechatSnapshot: const [], + latestAlipayPayment: const { + 'package': PaymentNotificationWatchService.alipayPackage, + 'key': 'k-a', + 'postTime': 1001, + }, + latestWechatPayment: const { + 'package': PaymentNotificationWatchService.wechatPackage, + 'key': 'k-w', + 'postTime': 1002, + }, + ); + + expect(combined.length, 2); + expect(combined.any((e) => e['package'] == PaymentNotificationWatchService.alipayPackage), isTrue); + expect(combined.any((e) => e['package'] == PaymentNotificationWatchService.wechatPackage), isTrue); + }); + }); +} diff --git a/Kiosk/test/pin_confirm_key_layout_test.dart b/Kiosk/test/pin_confirm_key_layout_test.dart new file mode 100644 index 0000000..ef5ddf0 --- /dev/null +++ b/Kiosk/test/pin_confirm_key_layout_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kiosk/l10n/app_localizations.dart'; +import 'package:kiosk/screens/pin_input_dialog.dart'; +import 'package:kiosk/screens/pin_setup_screen.dart'; + +Widget _wrap(Widget child) { + return MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: child, + ); +} + +void main() { + testWidgets('pin setup screen uses confirm key and removes save button', ( + WidgetTester tester, + ) async { + await tester.binding.setSurfaceSize(const Size(1280, 2200)); + await tester.pumpWidget(_wrap(const PinSetupScreen())); + + expect(find.text('Clear'), findsNothing); + expect(find.text('Save & Continue'), findsNothing); + expect(find.text('Confirm'), findsOneWidget); + }); + + testWidgets('pin input dialog uses confirm key and has no clear key', ( + WidgetTester tester, + ) async { + await tester.binding.setSurfaceSize(const Size(1280, 2200)); + await tester.pumpWidget( + _wrap( + const Scaffold( + body: Center(child: PinInputDialog(expectedPin: '1234')), + ), + ), + ); + + expect(find.text('Clear'), findsNothing); + expect(find.text('Confirm'), findsOneWidget); + }); +} diff --git a/Kiosk/test/scanner_keyboard_buffer_test.dart b/Kiosk/test/scanner_keyboard_buffer_test.dart new file mode 100644 index 0000000..4eda568 --- /dev/null +++ b/Kiosk/test/scanner_keyboard_buffer_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kiosk/services/scanner_keyboard_buffer.dart'; + +void main() { + group('ScannerKeyboardBuffer', () { + test('commits on enter and trims whitespace', () { + final buffer = ScannerKeyboardBuffer(minLength: 4); + + expect(buffer.pushCharacter(' '), isNull); + expect(buffer.pushCharacter('1'), isNull); + expect(buffer.pushCharacter('2'), isNull); + expect(buffer.pushCharacter('3'), isNull); + expect(buffer.pushCharacter('4'), isNull); + expect(buffer.commit(), '1234'); + }); + + test('ignores non-printable chars and rejects short barcode', () { + final buffer = ScannerKeyboardBuffer(minLength: 4); + + expect(buffer.pushCharacter('\n'), isNull); + expect(buffer.pushCharacter('A'), isNull); + expect(buffer.pushCharacter('1'), isNull); + expect(buffer.pushCharacter('2'), isNull); + expect(buffer.commit(), isNull); + }); + + test('supports alphanumeric payload', () { + final buffer = ScannerKeyboardBuffer(minLength: 4); + + for (final ch in 'AB12-XYZ'.split('')) { + buffer.pushCharacter(ch); + } + + expect(buffer.commit(), 'AB12-XYZ'); + }); + }); +} diff --git a/Kiosk/test/screen_wake_bus_test.dart b/Kiosk/test/screen_wake_bus_test.dart new file mode 100644 index 0000000..e20271b --- /dev/null +++ b/Kiosk/test/screen_wake_bus_test.dart @@ -0,0 +1,18 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kiosk/services/screen_wake_bus.dart'; + +void main() { + test('screen wake bus notifies listeners on wake request', () { + final bus = ScreenWakeBus(); + var notified = 0; + bus.addListener(() { + notified++; + }); + + bus.requestWake(); + bus.requestWake(); + + expect(notified, 2); + expect(bus.sequence, 2); + }); +} diff --git a/Kiosk/test/settings_service_camera_scan_test.dart b/Kiosk/test/settings_service_camera_scan_test.dart new file mode 100644 index 0000000..f43863d --- /dev/null +++ b/Kiosk/test/settings_service_camera_scan_test.dart @@ -0,0 +1,42 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:kiosk/services/settings_service.dart'; + +void main() { + group('SettingsService camera barcode recognition', () { + late Directory tempDir; + late SettingsService service; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('kiosk_settings_test_'); + Hive.init(tempDir.path); + service = SettingsService(); + await service.init(); + }); + + tearDown(() async { + await Hive.close(); + await tempDir.delete(recursive: true); + }); + + test('defaults to disabled on fresh install', () { + expect(service.getCameraBarcodeRecognitionEnabled(), isFalse); + }); + + test('persists toggle value once configured', () async { + await service.setCameraBarcodeRecognitionEnabled(true); + expect(service.getCameraBarcodeRecognitionEnabled(), isTrue); + + await service.setCameraBarcodeRecognitionEnabled(false); + expect(service.getCameraBarcodeRecognitionEnabled(), isFalse); + }); + + test('defaults to enabled for existing installs without explicit toggle', () async { + await service.setPin('1234'); + + expect(service.getCameraBarcodeRecognitionEnabled(), isTrue); + }); + }); +} diff --git a/Kiosk/test/settings_service_face_wakeup_test.dart b/Kiosk/test/settings_service_face_wakeup_test.dart new file mode 100644 index 0000000..6da4cbe --- /dev/null +++ b/Kiosk/test/settings_service_face_wakeup_test.dart @@ -0,0 +1,36 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:kiosk/services/settings_service.dart'; + +void main() { + group('SettingsService face wakeup setting', () { + late Directory tempDir; + late SettingsService service; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('kiosk_face_wakeup_'); + Hive.init(tempDir.path); + service = SettingsService(); + await service.init(); + }); + + tearDown(() async { + await Hive.close(); + await tempDir.delete(recursive: true); + }); + + test('defaults to enabled on fresh install', () { + expect(service.getFaceWakeupEnabled(), isTrue); + }); + + test('persists configured value', () async { + await service.setFaceWakeupEnabled(false); + expect(service.getFaceWakeupEnabled(), isFalse); + + await service.setFaceWakeupEnabled(true); + expect(service.getFaceWakeupEnabled(), isTrue); + }); + }); +} diff --git a/Kiosk/test/tmp_exact_wechat_text_test.dart b/Kiosk/test/tmp_exact_wechat_text_test.dart new file mode 100644 index 0000000..bb1fa49 --- /dev/null +++ b/Kiosk/test/tmp_exact_wechat_text_test.dart @@ -0,0 +1,10 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kiosk/services/payment_notification_watch_service.dart'; + +void main() { + test('parse exact device wechat text', () { + const s = '[10条]微信收款助手: [店员消息]收款到账5.44元, 原价5.50元, 有优惠'; + final fen = PaymentNotificationWatchService.parseWeChatSuccessAmountFen(s); + expect(fen, 544); + }); +} diff --git a/Kiosk/windows/flutter/generated_plugin_registrant.cc b/Kiosk/windows/flutter/generated_plugin_registrant.cc index 1523d31..5e306b4 100644 --- a/Kiosk/windows/flutter/generated_plugin_registrant.cc +++ b/Kiosk/windows/flutter/generated_plugin_registrant.cc @@ -6,10 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + AudioplayersWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); ConnectivityPlusWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); PermissionHandlerWindowsPluginRegisterWithRegistrar( diff --git a/Kiosk/windows/flutter/generated_plugins.cmake b/Kiosk/windows/flutter/generated_plugins.cmake index a103c74..b9e74ea 100644 --- a/Kiosk/windows/flutter/generated_plugins.cmake +++ b/Kiosk/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_windows connectivity_plus permission_handler_windows ) diff --git a/Manager/lib/screens/product_list_screen.dart b/Manager/lib/screens/product_list_screen.dart index 62246de..d127941 100644 --- a/Manager/lib/screens/product_list_screen.dart +++ b/Manager/lib/screens/product_list_screen.dart @@ -8,7 +8,14 @@ import 'package:manager/services/kiosk_client/kiosk_client.dart'; import 'package:intl/intl.dart'; class ProductListScreen extends StatefulWidget { - const ProductListScreen({super.key}); + final KioskConnectionService? connectionService; + final KioskClientService? kioskService; + + const ProductListScreen({ + super.key, + this.connectionService, + this.kioskService, + }); @override State createState() => _ProductListScreenState(); @@ -17,14 +24,16 @@ class ProductListScreen extends StatefulWidget { class _ProductListScreenState extends State { List _products = []; bool _isLoading = false; - final KioskConnectionService _connectionService = KioskConnectionService(); - final KioskClientService _kioskService = KioskClientService(); + late final KioskConnectionService _connectionService; + late final KioskClientService _kioskService; final TextEditingController _searchController = TextEditingController(); List _filteredProducts = []; @override void initState() { super.initState(); + _connectionService = widget.connectionService ?? KioskConnectionService(); + _kioskService = widget.kioskService ?? KioskClientService(); _connectionService.addListener(_onConnectionChange); _searchController.addListener(_onSearchChanged); _loadProducts(); @@ -66,6 +75,10 @@ class _ProductListScreenState extends State { if (!mounted) return; setState(() => _isLoading = true); try { + final kiosk = _connectionService.connectedKiosk; + if (kiosk != null) { + await _kioskService.syncProductsToKiosk(kiosk.ip, kiosk.port, kiosk.pin); + } final products = await DatabaseHelper.instance.getAllProducts(); if (!mounted) return; setState(() { diff --git a/Manager/test/screens/product_list_screen_sync_test.dart b/Manager/test/screens/product_list_screen_sync_test.dart new file mode 100644 index 0000000..fbae7e4 --- /dev/null +++ b/Manager/test/screens/product_list_screen_sync_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/testing.dart'; +import 'package:manager/db/database_helper.dart'; +import 'package:manager/l10n/app_localizations.dart'; +import 'package:manager/models/kiosk.dart'; +import 'package:manager/models/product.dart'; +import 'package:manager/screens/product_list_screen.dart'; +import 'package:manager/services/kiosk_client/kiosk_client.dart'; +import 'package:manager/services/kiosk_connection_service.dart'; + +class _FakeDatabaseHelper extends DatabaseHelper { + _FakeDatabaseHelper() : super.testing(); + + final List _products = []; + + @override + Future> getAllProducts() async => List.from(_products); + + @override + Future upsertProduct(Product product) async { + _products.removeWhere((p) => p.barcode == product.barcode); + _products.add(product); + } +} + +class _FakeConnectionService extends KioskConnectionService { + _FakeConnectionService(this._kiosk) : super.testing(); + + final Kiosk? _kiosk; + + @override + bool get hasConnectedKiosk => _kiosk != null; + + @override + Kiosk? get connectedKiosk => _kiosk; +} + +class _FakeKioskClientService extends KioskClientService { + _FakeKioskClientService(this._db) + : super(client: MockClient((_) async => throw UnimplementedError())); + + final _FakeDatabaseHelper _db; + int syncCallCount = 0; + + @override + Future syncProductsToKiosk(String ip, int port, String pin) async { + syncCallCount += 1; + await _db.upsertProduct( + Product( + barcode: '1234567890123', + name: 'Pulled Cola', + price: 4.0, + lastUpdated: DateTime.now().millisecondsSinceEpoch, + ), + ); + return true; + } +} + +void main() { + testWidgets('loads products from kiosk sync for a newly connected manager', ( + tester, + ) async { + final fakeDb = _FakeDatabaseHelper(); + DatabaseHelper.mockInstance = fakeDb; + + final kiosk = Kiosk(id: 1, ip: '192.168.1.8', port: 8081, pin: '1234'); + final connectionService = _FakeConnectionService(kiosk); + final kioskService = _FakeKioskClientService(fakeDb); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ProductListScreen( + connectionService: connectionService, + kioskService: kioskService, + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(kioskService.syncCallCount, 1); + expect(find.text('Pulled Cola'), findsOneWidget); + + DatabaseHelper.mockInstance = null; + }); +} diff --git a/adb_props.txt b/adb_props.txt new file mode 100644 index 0000000..43f1a29 --- /dev/null +++ b/adb_props.txt @@ -0,0 +1,281 @@ +[UserVolumeLabel]: [RockChips] +[camera2.portability.force_api]: [1] +[dalvik.vm.appimageformat]: [lz4] +[dalvik.vm.boot-dex2oat-threads]: [2] +[dalvik.vm.dex2oat-Xms]: [64m] +[dalvik.vm.dex2oat-Xmx]: [512m] +[dalvik.vm.dex2oat-threads]: [2] +[dalvik.vm.heapgrowthlimit]: [192m] +[dalvik.vm.heapmaxfree]: [8m] +[dalvik.vm.heapminfree]: [512k] +[dalvik.vm.heapsize]: [512m] +[dalvik.vm.heapstartsize]: [16m] +[dalvik.vm.heaptargetutilization]: [0.75] +[dalvik.vm.image-dex2oat-Xms]: [64m] +[dalvik.vm.image-dex2oat-Xmx]: [64m] +[dalvik.vm.image-dex2oat-threads]: [2] +[dalvik.vm.isa.arm.features]: [default] +[dalvik.vm.isa.arm.variant]: [cortex-a53.a57] +[dalvik.vm.isa.arm64.features]: [default] +[dalvik.vm.isa.arm64.variant]: [cortex-a53] +[dalvik.vm.stack-trace-file]: [/data/anr/traces.txt] +[dalvik.vm.usejit]: [true] +[dalvik.vm.usejitprofiles]: [true] +[debug.atrace.tags.enableflags]: [0] +[debug.force_rtl]: [0] +[debug.nfc.fw_download]: [false] +[debug.nfc.se]: [false] +[dev.bootcomplete]: [1] +[gsm.current.phone-type]: [1] +[gsm.network.type]: [Unknown] +[gsm.operator.alpha]: [] +[gsm.operator.iso-country]: [] +[gsm.operator.isroaming]: [false] +[gsm.operator.numeric]: [] +[gsm.sim.operator.alpha]: [] +[gsm.sim.operator.iso-country]: [] +[gsm.sim.operator.numeric]: [] +[gsm.sim.state]: [NOT_READY] +[gsm.version.ril-impl]: [Neoway_Android_RIL_V3.03_6x_7x_COMMON0215] +[init.svc.adbd]: [running] +[init.svc.akmd]: [stopped] +[init.svc.audioserver]: [running] +[init.svc.bootanim]: [stopped] +[init.svc.cameraserver]: [running] +[init.svc.debuggerd]: [running] +[init.svc.debuggerd64]: [running] +[init.svc.drm]: [running] +[init.svc.drmservice]: [stopped] +[init.svc.flash_recovery]: [stopped] +[init.svc.gatekeeperd]: [running] +[init.svc.healthd]: [running] +[init.svc.installd]: [running] +[init.svc.keystore]: [running] +[init.svc.lmkd]: [running] +[init.svc.logd]: [running] +[init.svc.logd-reinit]: [stopped] +[init.svc.media]: [running] +[init.svc.mediacodec]: [running] +[init.svc.mediadrm]: [running] +[init.svc.mediaextractor]: [running] +[init.svc.netd]: [running] +[init.svc.p2p_supplicant]: [running] +[init.svc.ril-daemon]: [running] +[init.svc.servicemanager]: [running] +[init.svc.surfaceflinger]: [running] +[init.svc.ueventd]: [running] +[init.svc.vold]: [running] +[init.svc.zygote]: [running] +[init.svc.zygote_secondary]: [running] +[keyguard.no_require_sim]: [true] +[media.audio.slice]: [0] +[media.sink.audio]: [] +[net.bt.name]: [Android] +[net.change]: [net.dns2] +[net.dns1]: [2408:844c:4d08:2496::78] +[net.dns2]: [10.190.45.121] +[net.hostname]: [android-57117681e8f3012] +[net.qtaguid_enabled]: [1] +[net.tcp.default_init_rwnd]: [60] +[persist.enable.3g.dongle]: [true] +[persist.sys.alarm.fixed]: [300000] +[persist.sys.alarm.strategy]: [fixed2] +[persist.sys.country]: [] +[persist.sys.dalvik.vm.lib.2]: [libart.so] +[persist.sys.first_booting]: [false] +[persist.sys.hid]: [] +[persist.sys.language]: [] +[persist.sys.locale]: [zh-CN] +[persist.sys.localevar]: [] +[persist.sys.profiler_ms]: [0] +[persist.sys.strictmode.visual]: [false] +[persist.sys.timezone]: [Asia/Shanghai] +[persist.sys.ui.hw]: [true] +[persist.sys.usb.config]: [adb] +[persist.sys.webview.vmsize]: [118564800] +[persist.tegra.nvmmlite]: [1] +[pm.dexopt.ab-ota]: [speed-profile] +[pm.dexopt.bg-dexopt]: [speed-profile] +[pm.dexopt.boot]: [verify-profile] +[pm.dexopt.core-app]: [speed] +[pm.dexopt.first-boot]: [interpret-only] +[pm.dexopt.forced-dexopt]: [speed] +[pm.dexopt.install]: [interpret-only] +[pm.dexopt.nsys-library]: [speed] +[pm.dexopt.shared-apk]: [speed] +[ril.function.dataonly]: [1] +[rild.libargs]: [-d /dev/ttyUSB2] +[rild.libpath]: [/system/lib64/libreference-ril.so] +[ro.adb.secure]: [1] +[ro.allow.mock.location]: [0] +[ro.audio.monitorOrientation]: [true] +[ro.baseband]: [N/A] +[ro.board.platform]: [rk3399] +[ro.boot.baseband]: [N/A] +[ro.boot.console]: [ttyFIQ0] +[ro.boot.hardware]: [rk30board] +[ro.boot.mode]: [emmc] +[ro.boot.noril]: [false] +[ro.boot.selinux]: [permissive] +[ro.bootimage.build.date]: [Thu Jun 20 10:41:04 CST 2019] +[ro.bootimage.build.date.utc]: [1560998464] +[ro.bootimage.build.fingerprint]: [Android/rk3399_mid/rk3399_mid:7.1.2/NHG47K/user.root.20190620.104104:user/release-keys] +[ro.bootloader]: [unknown] +[ro.bootmode]: [emmc] +[ro.bt.bdaddr_path]: [/data/misc/bluetooth/bdaddr] +[ro.build.characteristics]: [tablet] +[ro.build.date]: [Thu Jun 20 10:41:04 CST 2019] +[ro.build.date.utc]: [1560998464] +[ro.build.description]: [rk3399_mid-user 7.1.2 NHG47K user.root.20190620.104104 release-keys] +[ro.build.display.id]: [NHG47K release-keys] +[ro.build.fingerprint]: [Android/rk3399_mid/rk3399_mid:7.1.2/NHG47K/user.root.20190620.104104:user/release-keys] +[ro.build.flavor]: [rk3399_mid-user] +[ro.build.host]: [d1compile-Aspire-TC-705] +[ro.build.id]: [NHG47K] +[ro.build.product]: [rk3399_mid] +[ro.build.tags]: [release-keys] +[ro.build.type]: [user] +[ro.build.user]: [root] +[ro.build.version.all_codenames]: [REL] +[ro.build.version.base_os]: [] +[ro.build.version.codename]: [REL] +[ro.build.version.incremental]: [user.root.20190620.104104] +[ro.build.version.preview_sdk]: [0] +[ro.build.version.release]: [7.1.2] +[ro.build.version.sdk]: [25] +[ro.build.version.security_patch]: [2017-04-05] +[ro.carrier]: [unknown] +[ro.com.android.dataroaming]: [true] +[ro.config.alarm_alert]: [Alarm_Classic.ogg] +[ro.config.enable.remotecontrol]: [false] +[ro.config.notification_sound]: [pixiedust.ogg] +[ro.config.ringtone]: [Ring_Synth_04.ogg] +[ro.crypto.fs_crypto_blkdev]: [/dev/block/dm-1] +[ro.crypto.state]: [encrypted] +[ro.crypto.type]: [block] +[ro.dalvik.vm.native.bridge]: [0] +[ro.debuggable]: [0] +[ro.default.size]: [100] +[ro.device_owner]: [false] +[ro.enable_boot_charger_mode]: [0] +[ro.expect.recovery_id]: [0xab28b5847d4c854a9cd73a655adad369ab1e82ff000000000000000000000000] +[ro.factory.hasGPS]: [false] +[ro.factory.hasUMS]: [false] +[ro.factory.storage_suppntfs]: [true] +[ro.factory.tool]: [0] +[ro.factory.without_battery]: [false] +[ro.fota.app]: [5] +[ro.fota.device]: [rk3399box-HST] +[ro.fota.oem]: [Inpor-HST-D1] +[ro.fota.platform]: [RK3399_7.1] +[ro.fota.type]: [box] +[ro.fota.version]: [D1_OS_2.3.6.8] +[ro.hardware]: [rk30board] +[ro.hwui.disable_scissor_opt]: [true] +[ro.hwui.drop_shadow_cache_size]: [6] +[ro.hwui.gradient_cache_size]: [1] +[ro.hwui.layer_cache_size]: [48] +[ro.hwui.path_cache_size]: [32] +[ro.hwui.r_buffer_cache_size]: [8] +[ro.hwui.text_large_cache_height]: [1024] +[ro.hwui.text_large_cache_width]: [2048] +[ro.hwui.text_small_cache_height]: [1024] +[ro.hwui.text_small_cache_width]: [1024] +[ro.hwui.texture_cache_flushrate]: [0.4] +[ro.hwui.texture_cache_size]: [72] +[ro.lockscreen.disable.default]: [true] +[ro.opengles.version]: [196610] +[ro.product.board]: [rk30sdk] +[ro.product.brand]: [Android] +[ro.product.cpu.abi]: [arm64-v8a] +[ro.product.cpu.abilist]: [arm64-v8a,armeabi-v7a,armeabi] +[ro.product.cpu.abilist32]: [armeabi-v7a,armeabi] +[ro.product.cpu.abilist64]: [arm64-v8a] +[ro.product.device]: [rk3399_mid] +[ro.product.first_api_level]: [25] +[ro.product.locale]: [en-US] +[ro.product.locale.language]: [zh] +[ro.product.manufacturer]: [rockchip] +[ro.product.model]: [rk3399-mid] +[ro.product.name]: [rk3399_mid] +[ro.product.region]: [CN] +[ro.product.usbfactory]: [rockchip_usb] +[ro.radio.noril]: [false] +[ro.revision]: [0] +[ro.ril.ecclist]: [112,911] +[ro.rk.LowBatteryBrightness]: [true] +[ro.rk.MassStorage]: [false] +[ro.rk.bt_enable]: [true] +[ro.rk.def_brightness]: [200] +[ro.rk.flash_enable]: [true] +[ro.rk.hdmi_enable]: [true] +[ro.rk.homepage_base]: [http://www.google.com/webhp?client={CID}&source=android-home] +[ro.rk.install_non_market_apps]: [false] +[ro.rk.screenoff_time]: [60000] +[ro.rk.screenshot_enable]: [true] +[ro.rk.soc]: [rk3399] +[ro.rk.systembar.tabletUI]: [false] +[ro.rk.systembar.voiceicon]: [true] +[ro.rksdk.version]: [RK30_ANDROID7.1.2-SDK-v1.00.00] +[ro.runtime.firstboot]: [1771941184609] +[ro.safemode.disabled]: [true] +[ro.secure]: [1] +[ro.serialno]: [HD10618120039] +[ro.sf.fakerotation]: [false] +[ro.sf.hwrotation]: [0] +[ro.sf.lcd_density]: [280] +[ro.target.product]: [tablet] +[ro.tether.denied]: [false] +[ro.udisk.visible]: [true] +[ro.wifi.channels]: [] +[ro.zygote]: [zygote64_32] +[security.perf_harden]: [1] +[selinux.reload_policy]: [1] +[service.bootanim.exit]: [1] +[sf.power.control]: [1] +[sys.ID.mID]: [0] +[sys.boot_completed]: [1] +[sys.camera.callprocess]: [com.android.camera2] +[sys.cts_camera.status]: [false] +[sys.device_locked.status]: [0] +[sys.ggralloc.version]: [1.1.7] +[sys.ghwc.commit]: [commit-id:7e727c3cb8] +[sys.ghwc.version]: [0.32-rk3399-MID] +[sys.gmali.version]: [r14p0-01rel0-13-5@0] +[sys.hdmiin.display]: [1] +[sys.hwc.compose_policy]: [6] +[sys.hwc.device.aux]: [] +[sys.hwc.device.main]: [eDP] +[sys.logbootcomplete]: [1] +[sys.resolution.changed]: [false] +[sys.rkadb.root]: [0] +[sys.secureboot]: [false] +[sys.serialno]: [HD10618120039] +[sys.status.hidebar_enable]: [false] +[sys.sysctl.extra_free_kbytes]: [24300] +[sys.sysctl.tcp_def_init_rwnd]: [60] +[sys.usb.config]: [adb] +[sys.usb.configfs]: [1] +[sys.usb.controller]: [fe800000.dwc3] +[sys.usb.ffs.ready]: [1] +[sys.usb.state]: [adb] +[sys.wallpaper.rgb565]: [0] +[sys_graphic.IMX307.ver]: [0.1.0] +[sys_graphic.cam_camboard.ver]: [0.15.0] +[sys_graphic.cam_drv_camsys.ver]: [0.0.1] +[sys_graphic.cam_front.iq]: [/etc/IMX307_CMK-OT0712-FV2.xml] +[sys_graphic.cam_front.iq.ver]: [2019-05-16_lsl_IMX307_CMK-OT0712-FV2_v1.0.9] +[sys_graphic.cam_front.len]: [CMK-OT0712-FV2] +[sys_graphic.cam_hal.ver]: [1.80.2] +[sys_graphic.cam_isi.ver]: [0.13.0] +[sys_graphic.cam_libisp.ver]: [1.71.0] +[sys_graphic.cam_otp]: [true] +[sys_graphic.cam_trace]: [0] +[telephony.lteOnCdmaDevice]: [0] +[testing.mediascanner.skiplist]: [/mnt/shell/emulated/Android/] +[vold.decrypt]: [trigger_restart_framework] +[vold.has_adoptable]: [1] +[vold.post_fs_data_done]: [1] +[wifi.interface]: [wlan0] +[wifi.supplicant_scan_interval]: [15] +[wlan.driver.status]: [unloaded] diff --git a/skills/flutter-adaptive-ui/SKILL.md b/skills/flutter-adaptive-ui/SKILL.md new file mode 100644 index 0000000..790fc10 --- /dev/null +++ b/skills/flutter-adaptive-ui/SKILL.md @@ -0,0 +1,273 @@ +--- +name: flutter-adaptive-ui +description: Build adaptive and responsive Flutter UIs that work beautifully across all platforms and screen sizes. Use when creating Flutter apps that need to adapt layouts based on screen size, support multiple platforms including mobile tablet desktop and web, handle different input devices like touch mouse and keyboard, implement responsive navigation patterns, optimize for large screens and foldables, or use Capability and Policy patterns for platform-specific behavior. +metadata: + author: Stanislav [MADTeacher] Chernyshev + version: "1.0" +--- + +# Flutter Adaptive UI + +## Overview + +Create Flutter applications that adapt gracefully to any screen size, platform, or input device. This skill provides comprehensive guidance for building responsive layouts that scale from mobile phones to large desktop displays while maintaining excellent user experience across touch, mouse, and keyboard interactions. + +## Quick Reference + +**Core Layout Rule:** Constraints go down. Sizes go up. Parent sets position. + +**3-Step Adaptive Approach:** +1. **Abstract** - Extract common data from widgets +2. **Measure** - Determine available space (MediaQuery/LayoutBuilder) +3. **Branch** - Select appropriate UI based on breakpoints + +**Key Breakpoints:** +* Compact (Mobile): width < 600 +* Medium (Tablet): 600 <= width < 840 +* Expanded (Desktop): width >= 840 + +## Adaptive Workflow + +Follow the 3-step approach to make your app adaptive. + +### Step 1: Abstract + +Identify widgets that need adaptability and extract common data. Common patterns: +- Navigation UI (switch between bottom bar and side rail) +- Dialogs (fullscreen on mobile, modal on desktop) +- Content lists (reflow from single to multi-column) + +For navigation, create a shared `Destination` class with icon and label used by both `NavigationBar` and `NavigationRail`. + +### Step 2: Measure + +Choose the right measurement tool: + +**MediaQuery.sizeOf(context)** - Use when you need app window size for top-level layout decisions +- Returns entire app window dimensions +- Better performance than `MediaQuery.of()` for size queries +- Rebuilds widget when window size changes + +**LayoutBuilder** - Use when you need constraints for specific widget subtree +- Provides parent widget's constraints as `BoxConstraints` +- Local sizing information, not global window size +- Returns min/max width and height ranges + +Example: +```dart +// For app-level decisions +final width = MediaQuery.sizeOf(context).width; + +// For widget-specific constraints +LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 600) { + return MobileLayout(); + } + return DesktopLayout(); + }, +) +``` + +### Step 3: Branch + +Apply breakpoints to select appropriate UI. Don't base decisions on device type - use window size instead. + +Example breakpoints (from Material guidelines): +```dart +class AdaptiveLayout extends StatelessWidget { + @override + Widget build(BuildContext context) { + final width = MediaQuery.sizeOf(context).width; + + if (width >= 840) { + return DesktopLayout(); + } else if (width >= 600) { + return TabletLayout(); + } + return MobileLayout(); + } +} +``` + +## Layout Fundamentals + +### Understanding Constraints + +Flutter layout follows one rule: **Constraints go down. Sizes go up. Parent sets position.** + +Widgets receive constraints from parents, determine their size, then report size up to parent. Parents then position children. + +Key limitation: Widgets can only decide size within parent constraints. They cannot know or control their own position. + +For detailed examples and edge cases, see [layout-constraints.md](references/layout-constraints.md). + +### Common Layout Patterns + +**Row/Column** +- `Row` arranges children horizontally +- `Column` arranges children vertically +- Control alignment with `mainAxisAlignment` and `crossAxisAlignment` +- Use `Expanded` to make children fill available space proportionally + +**Container** +- Add padding, margins, borders, background +- Can constrain size with width/height +- Without child/size, expands to fill constraints + +**Expanded/Flexible** +- `Expanded` forces child to use available space +- `Flexible` allows child to use available space but can be smaller +- Use `flex` parameter to control proportions + +For complete widget documentation, see [layout-basics.md](references/layout-basics.md) and [layout-common-widgets.md](references/layout-common-widgets.md). + +## Best Practices + +### Design Principles + +**Break down widgets** +- Create small, focused widgets instead of large complex ones +- Improves performance with `const` widgets +- Makes testing and refactoring easier +- Share common components across different layouts + +**Design to platform strengths** +- Mobile: Focus on capturing content, quick interactions, location awareness +- Tablet/Desktop: Focus on organization, manipulation, detailed work +- Web: Leverage deep linking and easy sharing + +**Solve touch first** +- Start with great touch UI +- Test frequently on real mobile devices +- Layer on mouse/keyboard as accelerators, not replacements + +### Implementation Guidelines + +**Never lock orientation** +- Support both portrait and landscape +- Multi-window and foldable devices require flexibility +- Locked screens can be accessibility issues + +**Avoid device type checks** +- Don't use `Platform.isIOS`, `Platform.isAndroid` for layout decisions +- Use window size instead +- Device type ≠ window size (windows, split screens, PiP) + +**Use breakpoints, not orientation** +- Don't use `OrientationBuilder` for layout changes +- Use `MediaQuery.sizeOf` or `LayoutBuilder` with breakpoints +- Orientation doesn't indicate available space + +**Don't fill entire width** +- On large screens, avoid full-width content +- Use multi-column layouts with `GridView` or flex patterns +- Constrain content width for readability + +**Support multiple inputs** +- Implement keyboard navigation for accessibility +- Support mouse hover effects +- Handle focus properly for custom widgets + +For complete best practices, see [adaptive-best-practices.md](references/adaptive-best-practices.md). + +## Capabilities and Policies + +Separate what your code *can* do from what it *should* do. + +**Capabilities** (what code can do) +- API availability checks +- OS-enforced restrictions +- Hardware requirements (camera, GPS, etc.) + +**Policies** (what code should do) +- App store guidelines compliance +- Design preferences +- Platform-specific features +- Feature flags + +### Implementation Pattern + +```dart +// Capability class +class Capability { + bool hasCamera() { + // Check if camera API is available + return Platform.isAndroid || Platform.isIOS; + } +} + +// Policy class +class Policy { + bool shouldShowCameraFeature() { + // Business logic - maybe disabled by store policy + return hasCamera() && !Platform.isIOS; + } +} +``` + +Benefits: +- Clear separation of concerns +- Easy to test (mock Capability/Policy independently) +- Simple to update when platforms evolve +- Business logic doesn't depend on device detection + +For detailed examples, see [adaptive-capabilities.md](references/adaptive-capabilities.md) and [capability_policy_example.dart](assets/capability_policy_example.dart). + +## Examples + +### Responsive Navigation + +Switch between bottom navigation (small screens) and navigation rail (large screens): + +```dart +Widget build(BuildContext context) { + final width = MediaQuery.sizeOf(context).width; + + return width >= 600 + ? _buildNavigationRailLayout() + : _buildBottomNavLayout(); +} +``` + +Complete example: [responsive_navigation.dart](assets/responsive_navigation.dart) + +### Adaptive Grid + +Use `GridView.extent` with responsive maximum width: + +```dart +LayoutBuilder( + builder: (context, constraints) { + return GridView.extent( + maxCrossAxisExtent: constraints.maxWidth < 600 ? 150 : 200, + // ... + ); + }, +) +``` + +## Resources + +### Reference Documentation +- [layout-constraints.md](references/layout-constraints.md) - Complete guide to Flutter's constraint system with 29 examples +- [layout-basics.md](references/layout-basics.md) - Core layout widgets and patterns +- [layout-common-widgets.md](references/layout-common-widgets.md) - Container, GridView, ListView, Stack, Card, ListTile +- [adaptive-workflow.md](references/adaptive-workflow.md) - Detailed 3-step adaptive design approach +- [adaptive-best-practices.md](references/adaptive-best-practices.md) - Design and implementation guidelines +- [adaptive-capabilities.md](references/adaptive-capabilities.md) - Capability/Policy pattern for platform behavior + +### Example Code +- [responsive_navigation.dart](assets/responsive_navigation.dart) - NavigationBar ↔ NavigationRail switching +- [capability_policy_example.dart](assets/capability_policy_example.dart) - Capability/Policy class examples + +### Scripts +This skill currently has no executable scripts. All guidance is in reference documentation. + +### Assets +This skill includes complete Dart example files demonstrating: +- Responsive navigation patterns +- Capability and Policy implementation +- Adaptive layout strategies + +These assets can be copied directly into your Flutter project or adapted to your needs. diff --git a/skills/flutter-adaptive-ui/assets/capability_policy_example.dart b/skills/flutter-adaptive-ui/assets/capability_policy_example.dart new file mode 100644 index 0000000..409ab4f --- /dev/null +++ b/skills/flutter-adaptive-ui/assets/capability_policy_example.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'dart:io'; + +/// Example of Capability and Policy classes +/// for handling platform-specific behavior +class CapabilityPolicyExample extends StatelessWidget { + const CapabilityPolicyExample({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Capability & Policy Example')), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Using Policy class for business logic + if (Policy().shouldShowPurchaseButton()) + ElevatedButton( + onPressed: () => Capability().openBrowser(), + child: const Text('Buy in Browser'), + ) + else + const Text('Purchase not available on this platform'), + ], + ), + ), + ); + } +} + +/// Capability class - defines what the code CAN do +class Capability { + /// Check if browser is available + bool hasBrowserCapability() { + return Platform.isAndroid || Platform.isIOS || Platform.isMacOS; + } + + /// Open browser (implementation depends on platform) + void openBrowser() { + if (hasBrowserCapability()) { + // Launch browser implementation would go here + print('Opening browser...'); + } + } +} + +/// Policy class - defines what the code SHOULD do +class Policy { + /// Policy: don't show purchase button on iOS + bool shouldShowPurchaseButton() { + return !Platform.isIOS; + } + + /// Policy: use specific payment provider based on platform + String getPaymentProvider() { + if (Platform.isAndroid) { + return 'Google Play'; + } else if (Platform.isIOS) { + return 'Apple App Store'; + } else { + return 'Web Payment'; + } + } +} diff --git a/skills/flutter-adaptive-ui/assets/responsive_navigation.dart b/skills/flutter-adaptive-ui/assets/responsive_navigation.dart new file mode 100644 index 0000000..6829eb3 --- /dev/null +++ b/skills/flutter-adaptive-ui/assets/responsive_navigation.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +/// Example of responsive navigation that switches between +/// NavigationBar (bottom) and NavigationRail (side) +/// based on window width. +class ResponsiveNavigationExample extends StatelessWidget { + const ResponsiveNavigationExample({super.key}); + + @override + Widget build(BuildContext context) { + final width = MediaQuery.sizeOf(context).width; + + return Scaffold( + body: width >= 600 ? _buildLargeLayout() : _buildSmallLayout(), + ); + } + + /// Layout for small screens - bottom navigation + Widget _buildSmallLayout() { + return Scaffold( + bottomNavigationBar: NavigationBar( + destinations: const [ + NavigationDestination(icon: Icon(Icons.home), label: 'Home'), + NavigationDestination(icon: Icon(Icons.search), label: 'Search'), + NavigationDestination(icon: Icon(Icons.person), label: 'Profile'), + ], + ), + body: const Center(child: Text('Small Layout')), + ); + } + + /// Layout for large screens - side navigation rail + Widget _buildLargeLayout() { + return Scaffold( + body: Row( + children: [ + NavigationRail( + destinations: const [ + NavigationRailDestination(icon: Icon(Icons.home), label: 'Home'), + NavigationRailDestination( + icon: Icon(Icons.search), + label: 'Search', + ), + NavigationRailDestination( + icon: Icon(Icons.person), + label: 'Profile', + ), + ], + ), + const Expanded(child: Center(child: Text('Large Layout'))), + ], + ), + ); + } +} diff --git a/skills/flutter-adaptive-ui/references/adaptive-best-practices.md b/skills/flutter-adaptive-ui/references/adaptive-best-practices.md new file mode 100644 index 0000000..ae0fb0d --- /dev/null +++ b/skills/flutter-adaptive-ui/references/adaptive-best-practices.md @@ -0,0 +1,100 @@ +# Best Practices for Adaptive Design + +## Design Considerations + +### Break down your widgets + +While designing your app, try to break down large, complex widgets into smaller, simpler ones. + +Refactoring widgets can reduce the complexity of adopting an adaptive UI by sharing core pieces of code. There are other benefits as well: + +* On the performance side, having lots of small `const` widgets improves rebuild times over having large, complex widgets. +* Flutter can reuse `const` widget instances, while a larger complex widget has to be set up for every rebuild. +* From a code health perspective, organizing your UI into smaller bite sized pieces helps keep the complexity of each `Widget` down. A less-complex `Widget` is more readable, easier to refactor, and less likely to have surprising behavior. + +### Design to the strengths of each form factor + +Beyond screen size, you should also spend time considering the unique strengths and weaknesses of different form factors. It isn't always ideal for your multiplatform app to offer identical functionality everywhere. Consider whether it makes sense to focus on specific capabilities, or even remove certain features, on some device categories. + +For example, mobile devices are portable and have cameras, but they aren't well suited for detailed creative work. With this in mind, you might focus more on capturing content and tagging it with location data for a mobile UI, but focus on organizing or manipulating that content for a tablet or desktop UI. + +Another example is leveraging the web's extremely low barrier for sharing. If you're deploying a web app, decide which deep links to support, and design your navigation routes with those in mind. + +The key takeaway here is to think about what each platform does best and see if there are unique capabilities you can leverage. + +### Solve touch first + +Building a great touch UI can often be more difficult than a traditional desktop UI due, in part, to the lack of input accelerators like right-click, scroll wheel, or keyboard shortcuts. + +One way to approach this challenge is to focus initially on a great touch-oriented UI. You can still do most of your testing using the desktop target for its iteration speed. But, remember to switch frequently to a mobile device to verify that everything feels right. + +After you have the touch interface polished, you can tweak the visual density for mouse users, and then layer on all the additional inputs. Approach these other inputs as accelerator—alternatives that make a task faster. The important thing to consider is what a user expects when using a particular input device, and work to reflect that in your app. + +## Implementation Details + +### Don't lock the orientation of your app. + +An adaptive app should look good on windows of different sizes and shapes. While locking an app to portrait mode on phones can help narrow the scope of a minimum viable product, it can increase the effort required to make the app adaptive in the future. + +For example, the assumption that phones will only render your app in a full screen portrait mode is not a guarantee. Multi window app support is becoming common, and foldables have many use cases that work best with multiple apps running side by side. + +To summarize: + +* Locked screens can be an accessibility issue for some users +* Android large format tiers require portrait and landscape support at the lowest level +* Android devices can override a locked screen +* Apple guidelines say aim to support both orientations + +### Avoid device orientation-based layouts + +Avoid using `MediaQuery`'s orientation field or `OrientationBuilder` near the top of your widget tree to switch between different app layouts. This is similar to the guidance of not checking device types to determine screen size. The device's orientation also doesn't necessarily inform you of how much space your app window has. + +Instead, use `MediaQuery`'s `sizeOf` or `LayoutBuilder`, then use adaptive breakpoints. + +### Don't gobble up all of the horizontal space + +Apps that use the full width of the window to display boxes or text fields don't play well when these apps run on large screens. + +Use `LayoutBuilder` and `GridView` to optimize layout for large screens, creating multi-column layouts that effectively use horizontal space. + +### Avoid checking for hardware types + +Avoid writing code that checks whether the device you're running on is a "phone" or a "tablet", or any other type of device when making layout decisions. + +What space your app is actually given to render in isn't always tied to the full screen size of the device. Flutter can run on many different platforms, and your app might be running in a resizeable window on ChromeOS, side by side with another app on tablets in a multi-window mode, or even in a picture-in-picture on phones. Therefore, device type and app window size aren't really strongly connected. + +Instead, use `MediaQuery` to get the size of the window your app is currently running in. + +### Support a variety of input devices + +Apps should support basic mice, trackpads, and keyboard shortcuts. The most common user flows should support keyboard navigation to ensure accessibility. In particular, your app should follow accessible best practices for keyboards on large devices. + +The Material library provides widgets with excellent default behavior for touch, mouse, and keyboard interaction. + +For custom widgets, ensure they properly handle: + +* `MouseRegion` for hover effects +* `FocusNode` for keyboard navigation +* `Semantics` properties for screen readers + +### Restore List state + +To maintain the scroll position in a list that doesn't change its layout when the device's orientation changes, use the [`PageStorageKey`][] class. `PageStorageKey` persists the widget state in storage after the widget is destroyed and restores state when recreated. + +If the `List` widget changes its layout when the device's orientation changes, you might have to do a bit of math to change the scroll position on screen rotation. + +### Save app state + +Apps should retain or restore app state as the device rotates, changes window size, or folds and unfolds. By default, an app should maintain state. + +If your app loses state during device configuration, verify that the plugins and native extensions that your app uses support the device type, such as a large screen. Some native extensions might lose state when the device changes position. + +### Use const widgets whenever possible + +Creating widgets with `const` constructor improves performance by allowing Flutter to reuse widget instances instead of recreating them on every rebuild. This is especially important for adaptive layouts where widgets may rebuild frequently due to size changes. + +### Avoid unnecessary rebuilds + +Use `const` constructors for widgets that don't change. Use `ValueKey` when widgets need stable identity across rebuilds. Consider using `LayoutBuilder` to limit rebuilds to only the subtree that needs to respond to size changes. + +[PageStorageKey]: https://api.flutter.dev/flutter/widgets/PageStorageKey-class.html diff --git a/skills/flutter-adaptive-ui/references/adaptive-capabilities.md b/skills/flutter-adaptive-ui/references/adaptive-capabilities.md new file mode 100644 index 0000000..364df4b --- /dev/null +++ b/skills/flutter-adaptive-ui/references/adaptive-capabilities.md @@ -0,0 +1,96 @@ +# Capabilities and Policies + +## Design to the strengths of each device type + +Consider the unique strengths and weaknesses of different devices. Beyond their screen size and inputs, such as touch, mouse, keyboard, what other unique capabilities can you leverage? Flutter enables your code to run on different devices, but strong design is more than just running code. Think about what each platform does best and see if there are unique capabilities to leverage. + +Flutter's recommended pattern for handling different behavior based on these unique capabilities is to create a set of `Capability` and `Policy` classes for your app. + +## Capabilities + +A _capability_ defines what the code or device _can_ do. Examples of capabilities include: + +* The existence of an API +* OS-enforced restrictions +* Physical hardware requirements (like a camera) + +## Policies + +A _policy_ defines what the code _should_ do. + +Examples of policies include: + +* App store guidelines +* Design preferences +* Assets or copy that refers to the host device +* Features enabled on the server side + +## How to structure policy code + +The simplest mechanical way is `Platform.isAndroid`, `Platform.isIOS`, and `kIsWeb`. These APIs mechanically let you know where the code is running but have some problems as the app expands where it can run, and as host platforms add functionality. + +The following guidelines explain best practices when developing the capabilities and policies for your app: + +**Avoid using `Platform.isAndroid` and similar functions to make layout decisions or assumptions about what a device can do.** + +Instead, describe what you want to branch on in a method. + +Example: Your app has a link to buy something in a website, but you don't want to show that link on iOS devices for policy reasons. + +```dart +bool shouldAllowPurchaseClick() { + // Banned by Apple App Store guidelines. + return !Platform.isIOS; +} + +... +TextSpan( + text: 'Buy in browser', + style: new TextStyle(color: Colors.blue), + recognizer: shouldAllowPurchaseClick ? TapGestureRecognizer() + ..onTap = () { launch('') } : null, +) +``` + +What did you get by adding an additional layer of indirection? The code makes it more clear why the branched path exists. This method can exist directly in the class but it's likely that other parts of code might need this same check. If so, put the code in a class. + +```dart +class Policy { + bool shouldAllowPurchaseClick() { + // Banned by Apple App Store guidelines. + return !Platform.isIOS; + } +} +``` + +With this code in a class, any widget test can mock `Policy().shouldAllowPurchaseClick` and verify the behavior independently of where the device runs. It also means that later, if you decide that buying on the web isn't the right flow for Android users, you can change the implementation and the tests for clickable text won't need to change. + +## Capabilities in Detail + +Sometimes you want your code to do something but the API doesn't exist, or maybe you depend on a plugin feature that isn't yet implemented on all of the platforms you support. This is a limitation of what the device _can_ do. + +Those situations are similar to the policy decisions described above, but these are referred to as _capabilities_. Why separate policy classes from capabilities when the structure of the classes is similar? The Flutter team has found with productionized apps that making a logical distinction between what apps _can_ do and what they _should_ do helps larger products respond to changes in what platforms can do or require in addition to your own preferences after the initial code is written. + +For example, consider the case where one platform adds a new permission that requires users to interact with a system dialog before your code calls a sensitive API. Your team does the work for platform 1 and creates a capability named `requirePermissionDialogFlow`. Then, if and when platform 2 adds a similar requirement but only for new API versions, then the implementation of `requirePermissionDialogFlow` can now check the API level and return true for platform 2. You've leveraged the work you already did. + +## Policies in Detail + +We encourage starting with a `Policy` class initially even if it seems like you won't make many policy based decisions. As the complexity of the class grows or the number of inputs expands, you might decide to break up the policy class by feature or some other criteria. + +For policy implementation, you can use compile time, run time, or Remote Procedure Call (RPC) backed implementations. + +Compile-time policy checks are good for platforms where the preference is unlikely to change and where accidentally changing the value might have large consequences. For example, if a platform requires that you not link to the Play store, or requires that you use a specific payment provider given the content of your app. + +Runtime checks can be good for determining if there is a touch screen the user can use. Android has a feature you can check and your web implementation could check for max touch points. + +RPC-backed policy changes are good for incremental feature rollout or for decisions that might change later. + +## Summary + +Use a `Capability` class to define what the code *can* do. You might check against the existence of an API, OS-enforced restrictions, and physical hardware requirements (like a camera). A capability usually involves compile or runtime checks. + +Use a `Policy` class (or classes depending on complexity) to define what the code *should* do to comply with App store guidelines, design preferences, and assets or copy that need to refer to the host device. Policies can be a mix of compile, runtime, or RPC checks. + +Test the branching code by mocking capabilities and policies so that widget tests don't need to change when capabilities or policies change. + +Name the methods in your capabilities and policies classes based on what they are trying to branch, rather than on device type. diff --git a/skills/flutter-adaptive-ui/references/adaptive-workflow.md b/skills/flutter-adaptive-ui/references/adaptive-workflow.md new file mode 100644 index 0000000..f221ffc --- /dev/null +++ b/skills/flutter-adaptive-ui/references/adaptive-workflow.md @@ -0,0 +1,55 @@ +# Adaptive Design Workflow + +## Three-Step Approach + +Google engineers recommend following 3-step approach to make your app adaptive. + +### Step 1: Abstract + +First, identify the widgets that you plan to make dynamic. Analyze the constructors for those widgets and abstract out the data that you can share. + +Common widgets that require adaptability are: + +* Dialogs, both fullscreen and modal +* Navigation UI, both rail and bottom bar +* Custom layout, such as "is the UI area taller or wider?" + +For example, in a `Dialog` widget, you can share the info that contains the _content_ of the dialog. + +Or, perhaps you want to switch between a `NavigationBar` when the app window is small, and a `NavigationRail` when the app window is large. These widgets would likely share a list of navigable destinations. In this case, you might create a `Destination` widget to hold this info, and specify the `Destination` as having both an icon and a text label. + +### Step 2: Measure + +You have two ways to determine the size of your display area: `MediaQuery` and `LayoutBuilder`. + +#### MediaQuery + +In the past, you might have used `MediaQuery.of` to determine the size of the device's screen. However, devices today feature screens with a wide variety of sizes and shapes, and this test can be misleading. + +For example, maybe your app currently occupies a small window on a large screen. If you use the `MediaQuery.of` method and conclude the screen to be small (when, in fact, the app displays in a tiny window on a large screen), and you've portrait locked your app, it causes the app's window to lock to the center of the screen, surrounded with black. This is hardly an ideal UI on a large screen. + +Use `MediaQuery.sizeOf` instead of `MediaQuery.of` for performance reasons. `MediaQuery` contains a lot of data, but if you're only interested in the size property, it's more efficient to use the `sizeOf` method. Both methods return the size of the app window in logical pixels. + +Requesting the size of the app window from inside the `build` method, as in `MediaQuery.sizeOf(context)`, causes the given `BuildContext` to rebuild any time the size property changes. + +If you want your widget to be fullscreen, even when the app window is small, use `MediaQuery.sizeOf` so you can choose the UI based on the size of the app window itself. + +#### LayoutBuilder + +`LayoutBuilder` accomplishes a similar goal as `MediaQuery.sizeOf`, with some distinctions. + +Rather than providing the size of the app's window, `LayoutBuilder` provides the layout constraints from the parent `Widget`. This means that you get sizing information based on the specific spot in the widget tree where you added the `LayoutBuilder`. Also, `LayoutBuilder` returns a `BoxConstraints` object instead of a `Size` object, so you are given the valid width and height ranges (minimum and maximum) for the content, rather than just a fixed size. + +For example, imagine a custom widget, where you want the sizing to be based on the space specifically given to that widget, and not the app window in general. In this scenario, use `LayoutBuilder`. + +### Step 3: Branch + +At this point, you must decide what sizing breakpoints to use when choosing what version of the UI to display. For example, the Material layout guidelines suggest using a bottom nav bar for windows less than 600 logical pixels wide, and a nav rail for those that are 600 pixels wide or greater. + +**Important:** Your choice shouldn't depend on the _type_ of device, but on the device's available window size. + +Example breakpoints commonly used: + +* Compact (Mobile): width < 600 +* Medium (Tablet): 600 <= width < 840 +* Expanded (Desktop): width >= 840 diff --git a/skills/flutter-adaptive-ui/references/layout-basics.md b/skills/flutter-adaptive-ui/references/layout-basics.md new file mode 100644 index 0000000..fb2f684 --- /dev/null +++ b/skills/flutter-adaptive-ui/references/layout-basics.md @@ -0,0 +1,57 @@ +# Layout Basics in Flutter + +## Core Concepts + +In Flutter, almost everything is a widget—even layout models are widgets. The images, icons, and text that you see in a Flutter app are all widgets. But things you don't see are also widgets, such as the rows, columns, and grids that arrange, constrain, and align the visible widgets. You create a layout by composing widgets to build more complex widgets. + +## Lay out a Single Widget + +### Select a layout widget + +Choose from a variety of layout widgets based on how you want to align or constrain a visible widget, as these characteristics are typically passed on to the contained widget. For example, you could use the [`Center`][] layout widget to center a visible widget horizontally and vertically. + +### Create a visible widget + +Choose a visible widget for your app to contain visible elements, such as text, images, or icons. + +### Add the visible widget to the layout widget + +All layout widgets have either of the following: + +* A `child` property if they take a single child—for example, `Center` or `Container` +* A `children` property if they take a list of widgets—for example, `Row`, `Column`, `ListView`, or `Stack`. + +### Add the layout widget to the page + +A Flutter app is itself a widget, and most widgets have a `build()` method. Instantiating and returning a widget in the app's `build()` method displays the widget. + +## Lay Out Multiple Widgets Vertically and Horizontally + +One of the most common layout patterns is to arrange widgets vertically or horizontally. You can use a `Row` widget to arrange widgets horizontally, and a `Column` widget to arrange widgets vertically. + +To create a row or column in Flutter, add a list of children widgets to a [`Row`][] or [`Column`][] widget. In turn, each child can itself be a row or column, and so on. + +### Aligning widgets + +You control how a row or column aligns its children using the `mainAxisAlignment` and `crossAxisAlignment` properties. For a row, the main axis runs horizontally and the cross axis runs vertically. For a column, the main axis runs vertically and the cross axis runs horizontally. + +The [`MainAxisAlignment`][] and [`CrossAxisAlignment`][] enums offer a variety of constants for controlling alignment. + +### Sizing widgets + +When a layout is too large to fit a device, a yellow and black striped pattern appears along the affected edge. Widgets can be sized to fit within a row or column by using the [`Expanded`][] widget. + +### Packing widgets + +By default, a row or column occupies as much space along its main axis as possible, but if you want to pack the children closely together, set its `mainAxisSize` to `MainAxisSize.min`. + +### Nesting rows and columns + +The layout framework allows you to nest rows and columns inside of rows and columns as deeply as you need. To minimize the visual confusion that can result from heavily nested layout code, implement pieces of the UI in variables and functions. + +[Center]: https://api.flutter.dev/flutter/widgets/Center-class.html +[Column]: https://api.flutter.dev/flutter/widgets/Column-class.html +[CrossAxisAlignment]: https://api.flutter.dev/flutter/rendering/CrossAxisAlignment.html +[Expanded]: https://api.flutter.dev/flutter/widgets/Expanded-class.html +[MainAxisAlignment]: https://api.flutter.dev/flutter/rendering/MainAxisAlignment.html +[Row]: https://api.flutter.dev/flutter/widgets/Row-class.html diff --git a/skills/flutter-adaptive-ui/references/layout-common-widgets.md b/skills/flutter-adaptive-ui/references/layout-common-widgets.md new file mode 100644 index 0000000..5434e3f --- /dev/null +++ b/skills/flutter-adaptive-ui/references/layout-common-widgets.md @@ -0,0 +1,178 @@ +# Common Layout Widgets + +## Container + +Adds padding, margins, borders, background color, or other decorations to a widget. + +**Summary:** +* Add padding, margins, borders +* Change background color or image +* Contains a single child widget, but that child can be a `Row`, `Column`, or even the root of a widget tree + +## GridView + +Lays widgets out as a two-dimensional list. `GridView` provides two pre-fabricated lists, or you can build your own custom grid. When a `GridView` detects that its contents are too long to fit the render box, it automatically scrolls. + +**Summary:** +* Lays widgets out in a grid +* Detects when the column content exceeds the render box and automatically provides scrolling +* Build your own custom grid, or use one of the provided grids: + * `GridView.count` allows you to specify the number of columns + * `GridView.extent` allows you to specify the maximum pixel width of a tile + +**Example using GridView.extent:** + +```dart +Widget _buildGrid() => GridView.extent( + maxCrossAxisExtent: 150, + padding: const EdgeInsets.all(4), + mainAxisSpacing: 4, + crossAxisSpacing: 4, + children: _buildGridTileList(30), +); + +List _buildGridTileList(int count) => + List.generate(count, (i) => Image.asset('images/pic$i.jpg')); +``` + +## ListView + +[`ListView`][], a column-like widget, automatically provides scrolling when its content is too long for its render box. + +**Summary:** +* A specialized [`Column`][] for organizing a list of boxes +* Can be laid out horizontally or vertically +* Detects when its content won't fit and provides scrolling +* Less configurable than `Column`, but easier to use and supports scrolling + +**Example:** + +```dart +Widget _buildList() { + return ListView( + children: [ + _tile('CineArts at the Empire', '85 W Portal Ave', Icons.theaters), + _tile('The Castro Theater', '429 Castro St', Icons.theaters), + _tile('Alamo Drafthouse Cinema', '2550 Mission St', Icons.theaters), + const Divider(), + _tile('K\'s Kitchen', '757 Monterey Blvd', Icons.restaurant), + _tile('Emmy\'s Restaurant', '1923 Ocean Ave', Icons.restaurant), + _tile('Chaiya Thai Restaurant', '272 Claremont Blvd', Icons.restaurant), + _tile('La Ciccia', '291 30th St', Icons.restaurant), + ], + ); +} + +ListTile _tile(String title, String subtitle, IconData icon) { + return ListTile( + title: Text( + title, + style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 20), + ), + subtitle: Text(subtitle), + leading: Icon(icon, color: Colors.blue[500]), + ); +} +``` + +## Stack + +Use [`Stack`][] to arrange widgets on top of a base widget—often an image. The widgets can completely or partially overlap the base widget. + +**Summary:** +* Use for widgets that overlap another widget +* The first widget in the list of children is the base widget; subsequent children are overlaid on top of that base widget +* A `Stack`'s content can't scroll +* You can choose to clip children that exceed the render box + +**Example:** + +```dart +Widget _buildStack() { + return Stack( + alignment: const Alignment(0.6, 0.6), + children: [ + const CircleAvatar( + backgroundImage: AssetImage('images/pic.jpg'), + radius: 100, + ), + Container( + decoration: const BoxDecoration(color: Colors.black45), + child: const Text( + 'Mia B', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ], + ); +} +``` + +## Card + +A [`Card`][], from the [Material library][], contains related nuggets of information and can be composed of almost any widget, but is often used with [`ListTile`][]. `Card` has a single child, but its child can be a column, row, list, grid, or other widget that supports multiple children. By default, a `Card` shrinks its size to 0 by 0 pixels. You can use [`SizedBox`][] to constrain the size of a card. + +**Summary:** +* Implements a Material card +* Used for presenting related nuggets of information +* Accepts a single child, but that child can be a `Row`, `Column`, or other widget that holds a list of children +* Displayed with rounded corners and a drop shadow +* A `Card`'s content can't scroll +* From the Material library + +**Example:** + +```dart +Widget _buildCard() { + return SizedBox( + height: 210, + child: Card( + child: Column( + children: [ + ListTile( + title: const Text( + '1625 Main Street', + style: TextStyle(fontWeight: FontWeight.w500), + ), + subtitle: const Text('My City, CA 99984'), + leading: Icon(Icons.restaurant_menu, color: Colors.blue[500]), + ), + const Divider(), + ListTile( + title: const Text( + '(408) 555-1212', + style: TextStyle(fontWeight: FontWeight.w500), + ), + leading: Icon(Icons.contact_phone, color: Colors.blue[500]), + ), + ListTile( + title: const Text('costa@example.com'), + leading: Icon(Icons.contact_mail, color: Colors.blue[500]), + ), + ], + ), + ), + ); +} +``` + +## ListTile + +Use [`ListTile`][], a specialized row widget from the [Material library][], for an easy way to create a row containing up to 3 lines of text and optional leading and trailing icons. `ListTile` is most commonly used in [`Card`][] or [`ListView`][], but can be used elsewhere. + +**Summary:** +* A specialized row that contains up to 3 lines of text and optional icons +* Less configurable than `Row`, but easier to use +* From the Material library + +[Card]: https://api.flutter.dev/flutter/material/Card-class.html +[Column]: https://api.flutter.dev/flutter/widgets/Column-class.html +[ListTile]: https://api.flutter.dev/flutter/material/ListTile-class.html +[ListView]: https://api.flutter.dev/flutter/widgets/ListView-class.html +[Material library]: https://api.flutter.dev/flutter/material/material-library.html +[Stack]: https://api.flutter.dev/flutter/widgets/Stack-class.html +[SizedBox]: https://api.flutter.dev/flutter/widgets/SizedBox-class.html diff --git a/skills/flutter-adaptive-ui/references/layout-constraints.md b/skills/flutter-adaptive-ui/references/layout-constraints.md new file mode 100644 index 0000000..45e74fe --- /dev/null +++ b/skills/flutter-adaptive-ui/references/layout-constraints.md @@ -0,0 +1,467 @@ +# Layout Constraints in Flutter + +## Core Rule + +**Constraints go down. Sizes go up. Parent sets position.** + +Flutter layout can't be understood without knowing this rule. + +In more detail: + +* A widget gets its **constraints** from its **parent**. A constraint is just a set of 4 doubles: a minimum and maximum width, and a minimum and maximum height. +* Then the widget goes through its own list of **children**. One by one, the widget tells its children what their **constraints** are, and then asks each child what size it wants to be. +* Then, widget positions its **children** (horizontally in the x axis, and vertically in the y axis), one by one. +* And, finally, widget tells its parent about its own **size** (within the original constraints, of course). + +## Limitations + +Flutter's layout engine is designed to be a one-pass process: + +* A widget can decide its own size only within the constraints given to it by its parent. This means a widget usually **can't have any size it wants**. +* A widget **can't know and doesn't decide its own position in the screen**, since it's the widget's parent who decides the position of the widget. +* Since the parent's size and position also depends on its own parent, it's impossible to precisely define the size and position of any widget without taking into consideration the tree as a whole. +* If a child wants a different size from its parent and parent doesn't have enough information to align it, then the child's size might be ignored. **Be specific when defining alignment.** + +## Widget Types + +Generally, there are three kinds of boxes, in terms of how they handle their constraints: + +* Those that try to be as big as possible (e.g., [`Center`][], [`ListView`][]) +* Those that try to be same size as their children (e.g., [`Transform`][], [`Opacity`][]) +* Those that try to be a particular size (e.g., [`Image`][], [`Text`][]) + +Some widgets vary from type to type based on their constructor arguments (e.g., [`Container`][] varies based on width/height parameters). + +## Examples + +### Example 1: Container fills screen + +```dart +Container(color: Colors.red) +``` + +The screen is parent of `Container`, and it forces `Container` to be exactly same size as screen. So `Container` fills screen and paints it red. + +### Example 2: Container with fixed size in screen + +```dart +Container(width: 100, height: 100, color: Colors.red) +``` + +The red `Container` wants to be 100×100, but it can't, because the screen forces it to be exactly the same size as the screen. So `Container` fills the screen. + +### Example 3: Centered Container + +```dart +Center(child: Container(width: 100, height: 100, color: Colors.red)) +``` + +The screen forces the `Center` to be exactly the same size as the screen, so `Center` fills the screen. The `Center` tells the `Container` that it can be any size it wants, but not bigger than the screen. Now the `Container` can indeed be 100×100. + +### Example 4: Aligned Container + +```dart +Align( + alignment: Alignment.bottomRight, + child: Container(width: 100, height: 100, color: Colors.red), +) +``` + +`Align` also tells the `Container` that it can be any size it wants, but if there is empty space it won't center the `Container`. Instead, it aligns the container to the bottom-right of the available space. + +### Example 5: Infinite Container + +```dart +Center( + child: Container( + width: double.infinity, + height: double.infinity, + color: Colors.red, + ), +) +``` + +The screen forces the `Center` to be exactly the same size as the screen, so `Center` fills the screen. The `Center` tells the `Container` that it can be any size it wants, but not bigger than the screen. The `Container` wants to be of infinite size, but since it can't be bigger than the screen, it just fills the screen. + +### Example 6: Empty Container + +```dart +Center(child: Container(color: Colors.red)) +``` + +The screen forces the `Center` to be exactly the same size as the screen, so `Center` fills the screen. The `Center` tells the `Container` that it can be any size it wants, but not bigger than the screen. Since the `Container` has no child and no fixed size, it decides it wants to be as big as possible, so it fills the whole screen. + +### Example 7: Nested Containers + +```dart +Center( + child: Container( + color: Colors.red, + child: Container(color: Colors.green, width: 30, height: 30), + ), +) +``` + +The screen forces the `Center` to be exactly the same size as the screen, so `Center` fills the screen. The `Center` tells the red `Container` that it can be any size it wants, but not bigger than the screen. Since the red `Container` has no size but has a child, it decides it wants to be the same size as its child. The red `Container` tells its child that it can be any size it wants, but not bigger than the screen. The child is a green `Container` that wants to be 30×30. The red `Container` sizes itself to the size of its child, so it is also 30×30. The red color isn't visible because the green `Container` entirely covers all of the red `Container`. + +### Example 8: Container with padding + +```dart +Center( + child: Container( + padding: const EdgeInsets.all(20), + color: Colors.red, + child: Container(color: Colors.green, width: 30, height: 30), + ), +) +``` + +The red `Container` sizes itself to its children's size, but it takes its own padding into consideration. So it is also 30×30 plus padding. The red color is visible because of the padding, and the green `Container` has the same size as in the previous example. + +### Example 9: ConstrainedBox without Center + +```dart +ConstrainedBox( + constraints: const BoxConstraints( + minWidth: 70, + minHeight: 70, + maxWidth: 150, + maxHeight: 150, + ), + child: Container(color: Colors.red, width: 10, height: 10), +) +``` + +You might guess that the `Container` has to be between 70 and 150 pixels, but you would be wrong. The `ConstrainedBox` only imposes **additional** constraints from those it receives from its parent. Here, the screen forces the `ConstrainedBox` to be exactly the same size as the screen, so it tells its child `Container` to also assume the size of the screen, thus ignoring its 'constraints' parameter. + +### Example 10: ConstrainedBox with Center + +```dart +Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + minWidth: 70, + minHeight: 70, + maxWidth: 150, + maxHeight: 150, + ), + child: Container(color: Colors.red, width: 10, height: 10), + ), +) +``` + +Now, `Center` allows `ConstrainedBox` to be any size up to the screen size. The `ConstrainedBox` imposes **additional** constraints from its 'constraints' parameter onto its child. The `Container` must be between 70 and 150 pixels. It wants to have 10 pixels, so it will end up having 70 (the minimum). + +### Example 11: ConstrainedBox with large Container + +```dart +Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + minWidth: 70, + minHeight: 70, + maxWidth: 150, + maxHeight: 150, + ), + child: Container(color: Colors.red, width: 1000, height: 1000), + ), +) +``` + +`Center` allows `ConstrainedBox` to be any size up to the screen size. The `ConstrainedBox` imposes **additional** constraints from its 'constraints' parameter onto its child. The `Container` must be between 70 and 150 pixels. It wants to have 1000 pixels, so it ends up having 150 (the maximum). + +### Example 12: ConstrainedBox with correct size + +```dart +Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + minWidth: 70, + minHeight: 70, + maxWidth: 150, + maxHeight: 150, + ), + child: Container(color: Colors.red, width: 100, height: 100), + ), +) +``` + +`Center` allows `ConstrainedBox` to be any size up to the screen size. `ConstrainedBox` imposes **additional** constraints from its 'constraints' parameter onto its child. The `Container` must be between 70 and 150 pixels. It wants to have 100 pixels, and that's the size it has, since that's between 70 and 150. + +### Example 13: UnconstrainedBox + +```dart +UnconstrainedBox( + child: Container(color: Colors.red, width: 20, height: 50), +) +``` + +The screen forces the `UnconstrainedBox` to be exactly the same size as the screen. However, the `UnconstrainedBox` lets its child `Container` be any size it wants. + +### Example 14: UnconstrainedBox with overflow + +```dart +UnconstrainedBox( + child: Container(color: Colors.red, width: 4000, height: 50), +) +``` + +The screen forces the `UnconstrainedBox` to be exactly the same size as the screen, and `UnconstrainedBox` lets its child `Container` be any size it wants. Unfortunately, in this case `Container` has 4000 pixels of width and is too big to fit in the `UnconstrainedBox`, so the `UnconstrainedBox` displays the much dreaded "overflow warning". + +### Example 15: OverflowBox + +```dart +OverflowBox( + minWidth: 0, + minHeight: 0, + maxWidth: double.infinity, + maxHeight: double.infinity, + child: Container(color: Colors.red, width: 4000, height: 50), +) +``` + +The screen forces the `OverflowBox` to be exactly the same size as the screen, and `OverflowBox` lets its child `Container` be any size it wants. `OverflowBox` is similar to `UnconstrainedBox`, and difference is that it won't display any warnings if child doesn't fit space. In this case `Container` is 4000 pixels wide, and is too big to fit in the `OverflowBox`, but the `OverflowBox` simply shows as much as it can, with no warnings given. + +### Example 16: UnconstrainedBox with infinite Container + +```dart +UnconstrainedBox( + child: Container(color: Colors.red, width: double.infinity, height: 100), +) +``` + +This won't render anything, and you'll see an error in the console. The `UnconstrainedBox` lets its child be any size it wants, however its child is a `Container` with infinite size. Flutter can't render infinite sizes, so it throws an error with following message: "BoxConstraints forces an infinite width." + +### Example 17: LimitedBox + +```dart +UnconstrainedBox( + child: LimitedBox( + maxWidth: 100, + child: Container( + color: Colors.red, + width: double.infinity, + height: 100, + ), + ), +) +``` + +Here you won't get an error anymore, because when the `LimitedBox` is given an infinite size by the `UnconstrainedBox`, it passes a maximum width of 100 down to its child. If you swap the `UnconstrainedBox` for a `Center` widget, the `LimitedBox` won't apply its limit anymore (since its limit is only applied when it gets infinite constraints), and the width of the `Container` is allowed to grow past 100. + +### Example 18: FittedBox + +```dart +FittedBox(child: Text('Some Example Text.')) +``` + +The screen forces the `FittedBox` to be exactly the same size as the screen. The `Text` has some natural width (also called its intrinsic width) that depends on the amount of text, its font size, and so on. The `FittedBox` lets the `Text` be any size it wants, but after the `Text` tells its size to the `FittedBox`, the `FittedBox` scales the `Text` until it fills all of the available width. + +### Example 19: FittedBox in Center + +```dart +Center(child: FittedBox(child: Text('Some Example Text.'))) +``` + +The `Center` lets the `FittedBox` be any size it wants, up to the screen size. The `FittedBox` then sizes itself to the `Text`, and lets the `Text` be any size it wants. Since both `FittedBox` and `Text` have the same size, no scaling happens. + +### Example 20: FittedBox with large text + +```dart +Center( + child: FittedBox( + child: Text( + 'This is some very very very large text that is too big to fit a regular screen in a single line.', + ), + ), +) +``` + +`FittedBox` tries to size itself to the `Text`, but it can't be bigger than the screen. It then assumes the screen size, and resizes `Text` so that it fits the screen, too. + +### Example 21: Large text without FittedBox + +```dart +Center( + child: Text( + 'This is some very very very large text that is too big to fit a regular screen in a single line.', + ), +) +``` + +If you remove the `FittedBox`, the `Text` gets its maximum width from the screen, and breaks the line so that it fits the screen. + +### Example 22: FittedBox with unbounded Container + +```dart +FittedBox( + child: Container( + height: 20, + width: double.infinity, + ), +) +``` + +`FittedBox` can only scale a widget that is BOUNDED (has non-infinite width and height). Otherwise, it won't render anything, and you'll see an error in the console. + +### Example 23: Row with text + +```dart +Row( + children: [ + Container( + color: Colors.red, + child: const Text('Hello!', style: TextStyle(fontSize: 30)), + ), + Container( + color: Colors.green, + child: const Text('Goodbye!', style: TextStyle(fontSize: 30)), + ), + ], +) +``` + +The screen forces the `Row` to be exactly the same size as the screen. Just like an `UnconstrainedBox`, the `Row` won't impose any constraints onto its children, and instead lets them be any size they want. The `Row` then puts them side-by-side, and any extra space remains empty. + +### Example 24: Row with overflow + +```dart +Row( + children: [ + Container( + color: Colors.red, + child: const Text( + 'This is a very long text that won\'t fit the line.', + style: TextStyle(fontSize: 30), + ), + ), + Container( + color: Colors.green, + child: const Text('Goodbye!', style: TextStyle(fontSize: 30)), + ), + ], +) +``` + +Since the `Row` won't impose any constraints onto its children, it's quite possible that children might be too big to fit the available width of `Row`. In this case, just like an `UnconstrainedBox`, the `Row` displays the "overflow warning". + +### Example 25: Row with Expanded + +```dart +Row( + children: [ + Expanded( + child: Center( + child: Container( + color: Colors.red, + child: const Text( + 'This is a very long text that won\'t fit the line.', + style: TextStyle(fontSize: 30), + ), + ), + ), + ), + Container( + color: Colors.green, + child: const Text('Goodbye!', style: TextStyle(fontSize: 30)), + ), + ], +) +``` + +When a `Row`'s child is wrapped in an `Expanded` widget, the `Row` won't let this child define its own width anymore. Instead, it defines the `Expanded` width according to the other children, and only then the `Expanded` widget forces the original child to have the `Expanded`'s width. In other words, once you use `Expanded`, the original child's width becomes irrelevant, and is ignored. + +### Example 26: Row with two Expanded + +```dart +Row( + children: [ + Expanded( + child: Container( + color: Colors.red, + child: const Text( + 'This is a very long text that won\'t fit the line.', + style: TextStyle(fontSize: 30), + ), + ), + ), + Expanded( + child: Container( + color: Colors.green, + child: const Text('Goodbye!', style: TextStyle(fontSize: 30)), + ), + ), + ], +) +``` + +If all of `Row`'s children are wrapped in `Expanded` widgets, each `Expanded` has a size proportional to its flex parameter, and only then each `Expanded` widget forces its child to have the `Expanded`'s width. In other words, `Expanded` ignores the preferred width of its children. + +### Example 27: Row with Flexible + +```dart +Row( + children: [ + Flexible( + child: Container( + color: Colors.red, + child: const Text( + 'This is a very long text that won\'t fit the line.', + style: TextStyle(fontSize: 30), + ), + ), + ), + Flexible( + child: Container( + color: Colors.green, + child: const Text('Goodbye!', style: TextStyle(fontSize: 30)), + ), + ), + ], +) +``` + +The only difference if you use `Flexible` instead of `Expanded`, is that `Flexible` lets its child be SMALLER than the `Flexible` width, while `Expanded` forces its child to have the same width of the `Expanded`. But both `Expanded` and `Flexible` ignore their children's width when sizing themselves. + +### Example 28: Scaffold with Column + +```dart +Scaffold( + body: Container( + color: Colors.blue, + child: const Column( + children: [Text('Hello!'), Text('Goodbye!')], + ), + ), +) +``` + +The screen forces the `Scaffold` to be exactly the same size as the screen, so `Scaffold` fills the screen. The `Scaffold` tells the `Container` that it can be any size it wants, but not bigger than the screen. When a widget tells its child that it can be smaller than a certain size, we say the widget supplies "loose" constraints to its child. + +### Example 29: Scaffold with expanded Column + +```dart +Scaffold( + body: SizedBox.expand( + child: Container( + color: Colors.blue, + child: const Column( + children: [Text('Hello!'), Text('Goodbye!')], + ), + ), + ), +) +``` + +If you want the `Scaffold`'s child to be exactly the same size as the `Scaffold` itself, you can wrap its child with `SizedBox.expand`. When a widget tells its child that it must be of a certain size, we say the widget supplies "tight" constraints to its child. + +[Center]: https://api.flutter.dev/flutter/widgets/Center-class.html +[Container]: https://api.flutter.dev/flutter/widgets/Container-class.html +[FittedBox]: https://api.flutter.dev/flutter/widgets/FittedBox-class.html +[Image]: https://api.flutter.dev/flutter/dart-ui/Image-class.html +[ListTile]: https://api.flutter.dev/flutter/material/ListTile-class.html +[ListView]: https://api.flutter.dev/flutter/widgets/ListView-class.html +[Opacity]: https://api.flutter.dev/flutter/widgets/Opacity-class.html +[Row]: https://api.flutter.dev/flutter/widgets/Row-class.html +[Text]: https://api.flutter.dev/flutter/widgets/Text-class.html +[Transform]: https://api.flutter.dev/flutter/widgets/Transform-class.html diff --git a/skills/flutter-expert/SKILL.md b/skills/flutter-expert/SKILL.md new file mode 100644 index 0000000..d32358f --- /dev/null +++ b/skills/flutter-expert/SKILL.md @@ -0,0 +1,82 @@ +--- +name: flutter-expert +description: Use when building cross-platform applications with Flutter 3+ and Dart. Invoke for widget development, Riverpod/Bloc state management, GoRouter navigation, platform-specific implementations, performance optimization. +license: MIT +metadata: + author: https://github.com/Jeffallan + version: "1.0.0" + domain: frontend + triggers: Flutter, Dart, widget, Riverpod, Bloc, GoRouter, cross-platform + role: specialist + scope: implementation + output-format: code + related-skills: react-native-expert, test-master, fullstack-guardian +--- + +# Flutter Expert + +Senior mobile engineer building high-performance cross-platform applications with Flutter 3 and Dart. + +## Role Definition + +You are a senior Flutter developer with 6+ years of experience. You specialize in Flutter 3.19+, Riverpod 2.0, GoRouter, and building apps for iOS, Android, Web, and Desktop. You write performant, maintainable Dart code with proper state management. + +## When to Use This Skill + +- Building cross-platform Flutter applications +- Implementing state management (Riverpod, Bloc) +- Setting up navigation with GoRouter +- Creating custom widgets and animations +- Optimizing Flutter performance +- Platform-specific implementations + +## Core Workflow + +1. **Setup** - Project structure, dependencies, routing +2. **State** - Riverpod providers or Bloc setup +3. **Widgets** - Reusable, const-optimized components +4. **Test** - Widget tests, integration tests +5. **Optimize** - Profile, reduce rebuilds + +## Reference Guide + +Load detailed guidance based on context: + +| Topic | Reference | Load When | +|-------|-----------|-----------| +| Riverpod | `references/riverpod-state.md` | State management, providers, notifiers | +| Bloc | `references/bloc-state.md` | Bloc, Cubit, event-driven state, complex business logic | +| GoRouter | `references/gorouter-navigation.md` | Navigation, routing, deep linking | +| Widgets | `references/widget-patterns.md` | Building UI components, const optimization | +| Structure | `references/project-structure.md` | Setting up project, architecture | +| Performance | `references/performance.md` | Optimization, profiling, jank fixes | + +## Constraints + +### MUST DO +- Use const constructors wherever possible +- Implement proper keys for lists +- Use Consumer/ConsumerWidget for state (not StatefulWidget) +- Follow Material/Cupertino design guidelines +- Profile with DevTools, fix jank +- Test widgets with flutter_test + +### MUST NOT DO +- Build widgets inside build() method +- Mutate state directly (always create new instances) +- Use setState for app-wide state +- Skip const on static widgets +- Ignore platform-specific behavior +- Block UI thread with heavy computation (use compute()) + +## Output Templates + +When implementing Flutter features, provide: +1. Widget code with proper const usage +2. Provider/Bloc definitions +3. Route configuration if needed +4. Test file structure + +## Knowledge Reference + +Flutter 3.19+, Dart 3.3+, Riverpod 2.0, Bloc 8.x, GoRouter, freezed, json_serializable, Dio, flutter_hooks diff --git a/skills/flutter-expert/references/bloc-state.md b/skills/flutter-expert/references/bloc-state.md new file mode 100644 index 0000000..2c659e9 --- /dev/null +++ b/skills/flutter-expert/references/bloc-state.md @@ -0,0 +1,259 @@ +# Bloc State Management + +## When to Use Bloc + +Use **Bloc/Cubit** when you need: + +* Explicit event → state transitions +* Complex business logic +* Predictable, testable flows +* Clear separation between UI and logic + +| Use Case | Recommended | +| ---------------------- | ----------- | +| Simple mutable state | Riverpod | +| Event-driven workflows | Bloc | +| Forms, auth, wizards | Bloc | +| Feature modules | Bloc | + +--- + +## Core Concepts + +| Concept | Description | +| ------- | ---------------------- | +| Event | User/system input | +| State | Immutable UI state | +| Bloc | Event → State mapper | +| Cubit | State-only (no events) | + +--- + +## Basic Bloc Setup + +### Event + +```dart +sealed class CounterEvent {} + +final class CounterIncremented extends CounterEvent {} + +final class CounterDecremented extends CounterEvent {} +``` + +### State + +```dart +class CounterState { + final int value; + + const CounterState({required this.value}); + + CounterState copyWith({int? value}) { + return CounterState(value: value ?? this.value); + } +} +``` + +### Bloc + +```dart +import 'package:flutter_bloc/flutter_bloc.dart'; + +class CounterBloc extends Bloc { + CounterBloc() : super(const CounterState(value: 0)) { + on((event, emit) { + emit(state.copyWith(value: state.value + 1)); + }); + + on((event, emit) { + emit(state.copyWith(value: state.value - 1)); + }); + } +} +``` + +--- + +## Cubit (Recommended for Simpler Logic) + +```dart +class CounterCubit extends Cubit { + CounterCubit() : super(0); + + void increment() => emit(state + 1); + void decrement() => emit(state - 1); +} +``` + +--- + +## Providing Bloc to the Widget Tree + +```dart +BlocProvider( + create: (_) => CounterBloc(), + child: const CounterScreen(), +); +``` + +Multiple blocs: + +```dart +MultiBlocProvider( + providers: [ + BlocProvider(create: (_) => AuthBloc()), + BlocProvider(create: (_) => ProfileBloc()), + ], + child: const AppRoot(), +); +``` + +--- + +## Using Bloc in Widgets + +### BlocBuilder (UI rebuilds) + +```dart +class CounterScreen extends StatelessWidget { + const CounterScreen({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (prev, curr) => prev.value != curr.value, + builder: (context, state) { + return Text( + state.value.toString(), + style: Theme.of(context).textTheme.displayLarge, + ); + }, + ); + } +} +``` + +--- + +### BlocListener (Side Effects) + +```dart +BlocListener( + listenWhen: (prev, curr) => curr is AuthFailure, + listener: (context, state) { + if (state is AuthFailure) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(state.message))); + } + }, + child: const LoginForm(), +); +``` + +--- + +### BlocConsumer (Builder + Listener) + +```dart +BlocConsumer( + listener: (context, state) { + if (state.status == FormStatus.success) { + context.pop(); + } + }, + builder: (context, state) { + return ElevatedButton( + onPressed: state.isValid + ? () => context.read().add(FormSubmitted()) + : null, + child: const Text('Submit'), + ); + }, +); +``` + +--- + +## Accessing Bloc Without Rebuilds + +```dart +context.read().add(CounterIncremented()); +``` + +⚠️ **Never use `watch` inside callbacks** + +--- + +## Async Bloc Pattern (API Calls) + +```dart +on((event, emit) async { + emit(const UserState.loading()); + + try { + final user = await repository.fetchUser(); + emit(UserState.success(user)); + } catch (e) { + emit(UserState.failure(e.toString())); + } +}); +``` + +--- + +## Bloc + GoRouter (Auth Guard Example) + +```dart +redirect: (context, state) { + final authState = context.read().state; + + if (authState is Unauthenticated) { + return '/login'; + } + return null; +} +``` + +--- + +## Testing Bloc + +```dart +blocTest( + 'emits incremented value', + build: () => CounterBloc(), + act: (bloc) => bloc.add(CounterIncremented()), + expect: () => [ + const CounterState(value: 1), + ], +); +``` + +--- + +## Best Practices (MUST FOLLOW) + +✅ Immutable states +✅ Small, focused blocs +✅ One feature = one bloc +✅ Use Cubit when possible +✅ Test all blocs + +❌ No UI logic inside blocs +❌ No context usage inside blocs +❌ No mutable state +❌ No massive “god blocs” + +--- + +## Quick Reference + +| Widget | Purpose | +| ----------------- | -------------------- | +| BlocBuilder | UI rebuild | +| BlocListener | Side effects | +| BlocConsumer | Both | +| BlocProvider | Dependency injection | +| MultiBlocProvider | Multiple blocs | + diff --git a/skills/flutter-expert/references/gorouter-navigation.md b/skills/flutter-expert/references/gorouter-navigation.md new file mode 100644 index 0000000..82812db --- /dev/null +++ b/skills/flutter-expert/references/gorouter-navigation.md @@ -0,0 +1,119 @@ +# GoRouter Navigation + +## Basic Setup + +```dart +import 'package:go_router/go_router.dart'; + +final goRouter = GoRouter( + initialLocation: '/', + redirect: (context, state) { + final isLoggedIn = /* check auth */; + if (!isLoggedIn && !state.matchedLocation.startsWith('/auth')) { + return '/auth/login'; + } + return null; + }, + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const HomeScreen(), + routes: [ + GoRoute( + path: 'details/:id', + builder: (context, state) { + final id = state.pathParameters['id']!; + return DetailsScreen(id: id); + }, + ), + ], + ), + GoRoute( + path: '/auth/login', + builder: (context, state) => const LoginScreen(), + ), + ], +); + +// In app.dart +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp.router( + routerConfig: goRouter, + theme: AppTheme.light, + darkTheme: AppTheme.dark, + ); + } +} +``` + +## Navigation Methods + +```dart +// Navigate and replace history +context.go('/details/123'); + +// Navigate and add to stack +context.push('/details/123'); + +// Go back +context.pop(); + +// Replace current route +context.pushReplacement('/home'); + +// Navigate with extra data +context.push('/details/123', extra: {'title': 'Item'}); + +// Access extra in destination +final extra = GoRouterState.of(context).extra as Map?; +``` + +## Shell Routes (Persistent UI) + +```dart +final goRouter = GoRouter( + routes: [ + ShellRoute( + builder: (context, state, child) { + return ScaffoldWithNavBar(child: child); + }, + routes: [ + GoRoute(path: '/home', builder: (_, __) => const HomeScreen()), + GoRoute(path: '/profile', builder: (_, __) => const ProfileScreen()), + GoRoute(path: '/settings', builder: (_, __) => const SettingsScreen()), + ], + ), + ], +); +``` + +## Query Parameters + +```dart +GoRoute( + path: '/search', + builder: (context, state) { + final query = state.uri.queryParameters['q'] ?? ''; + final page = int.tryParse(state.uri.queryParameters['page'] ?? '1') ?? 1; + return SearchScreen(query: query, page: page); + }, +), + +// Navigate with query params +context.go('/search?q=flutter&page=2'); +``` + +## Quick Reference + +| Method | Behavior | +|--------|----------| +| `context.go()` | Navigate, replace stack | +| `context.push()` | Navigate, add to stack | +| `context.pop()` | Go back | +| `context.pushReplacement()` | Replace current | +| `:param` | Path parameter | +| `?key=value` | Query parameter | diff --git a/skills/flutter-expert/references/performance.md b/skills/flutter-expert/references/performance.md new file mode 100644 index 0000000..9ed2c5c --- /dev/null +++ b/skills/flutter-expert/references/performance.md @@ -0,0 +1,99 @@ +# Performance Optimization + +## Profiling Commands + +```bash +# Run in profile mode +flutter run --profile + +# Analyze performance +flutter analyze + +# DevTools +flutter pub global activate devtools +flutter pub global run devtools +``` + +## Common Optimizations + +### Const Widgets +```dart +// ❌ Rebuilds every time +Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(16), // Creates new object + child: Text('Hello'), + ); +} + +// ✅ Const prevents rebuilds +Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + child: const Text('Hello'), + ); +} +``` + +### Selective Provider Watching +```dart +// ❌ Rebuilds on any user change +final user = ref.watch(userProvider); +return Text(user.name); + +// ✅ Only rebuilds when name changes +final name = ref.watch(userProvider.select((u) => u.name)); +return Text(name); +``` + +### RepaintBoundary +```dart +// Isolate expensive widgets +RepaintBoundary( + child: ComplexAnimatedWidget(), +) +``` + +### Image Optimization +```dart +// Use cached_network_image +CachedNetworkImage( + imageUrl: url, + placeholder: (_, __) => const CircularProgressIndicator(), + errorWidget: (_, __, ___) => const Icon(Icons.error), +) + +// Resize images +Image.network( + url, + cacheWidth: 200, // Resize in memory + cacheHeight: 200, +) +``` + +### Compute for Heavy Operations +```dart +// ❌ Blocks UI thread +final result = heavyComputation(data); + +// ✅ Runs in isolate +final result = await compute(heavyComputation, data); +``` + +## Performance Checklist + +| Check | Solution | +|-------|----------| +| Unnecessary rebuilds | Add `const`, use `select()` | +| Large lists | Use `ListView.builder` | +| Image loading | Use `cached_network_image` | +| Heavy computation | Use `compute()` | +| Jank in animations | Use `RepaintBoundary` | +| Memory leaks | Dispose controllers | + +## DevTools Metrics + +- **Frame rendering time**: < 16ms for 60fps +- **Widget rebuilds**: Minimize unnecessary rebuilds +- **Memory usage**: Watch for leaks +- **CPU profiler**: Identify bottlenecks diff --git a/skills/flutter-expert/references/project-structure.md b/skills/flutter-expert/references/project-structure.md new file mode 100644 index 0000000..ebd30cc --- /dev/null +++ b/skills/flutter-expert/references/project-structure.md @@ -0,0 +1,118 @@ +# Project Structure + +## Feature-Based Structure + +``` +lib/ +├── main.dart +├── app.dart +├── core/ +│ ├── constants/ +│ │ ├── colors.dart +│ │ └── strings.dart +│ ├── theme/ +│ │ ├── app_theme.dart +│ │ └── text_styles.dart +│ ├── utils/ +│ │ ├── extensions.dart +│ │ └── validators.dart +│ └── errors/ +│ └── failures.dart +├── features/ +│ ├── auth/ +│ │ ├── data/ +│ │ │ ├── repositories/ +│ │ │ └── datasources/ +│ │ ├── domain/ +│ │ │ ├── entities/ +│ │ │ └── usecases/ +│ │ ├── presentation/ +│ │ │ ├── screens/ +│ │ │ └── widgets/ +│ │ └── providers/ +│ │ └── auth_provider.dart +│ └── home/ +│ ├── data/ +│ ├── domain/ +│ ├── presentation/ +│ └── providers/ +├── shared/ +│ ├── widgets/ +│ │ ├── buttons/ +│ │ ├── inputs/ +│ │ └── cards/ +│ ├── services/ +│ │ ├── api_service.dart +│ │ └── storage_service.dart +│ └── models/ +│ └── user.dart +└── routes/ + └── app_router.dart +``` + +## pubspec.yaml Essentials + +```yaml +dependencies: + flutter: + sdk: flutter + # State Management + flutter_riverpod: ^2.5.0 + riverpod_annotation: ^2.3.0 + # Navigation + go_router: ^14.0.0 + # Networking + dio: ^5.4.0 + # Code Generation + freezed_annotation: ^2.4.0 + json_annotation: ^4.8.0 + # Storage + shared_preferences: ^2.2.0 + hive_flutter: ^1.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + build_runner: ^2.4.0 + riverpod_generator: ^2.4.0 + freezed: ^2.5.0 + json_serializable: ^6.8.0 + flutter_lints: ^4.0.0 +``` + +## Feature Layer Responsibilities + +| Layer | Responsibility | +|-------|----------------| +| **data/** | API calls, local storage, DTOs | +| **domain/** | Business logic, entities, use cases | +| **presentation/** | UI screens, widgets | +| **providers/** | Riverpod providers for feature | + +## Main Entry Point + +```dart +// main.dart +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await Hive.initFlutter(); + runApp(const ProviderScope(child: MyApp())); +} + +// app.dart +class MyApp extends ConsumerWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final router = ref.watch(routerProvider); + + return MaterialApp.router( + routerConfig: router, + theme: AppTheme.light, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.system, + ); + } +} +``` diff --git a/skills/flutter-expert/references/riverpod-state.md b/skills/flutter-expert/references/riverpod-state.md new file mode 100644 index 0000000..7cef036 --- /dev/null +++ b/skills/flutter-expert/references/riverpod-state.md @@ -0,0 +1,130 @@ +# Riverpod State Management + +## Provider Types + +```dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +// Simple state +final counterProvider = StateProvider((ref) => 0); + +// Async state (API calls) +final usersProvider = FutureProvider>((ref) async { + final api = ref.read(apiProvider); + return api.getUsers(); +}); + +// Stream state (real-time) +final messagesProvider = StreamProvider>((ref) { + return ref.read(chatServiceProvider).messagesStream; +}); +``` + +## Notifier Pattern (Riverpod 2.0) + +```dart +@riverpod +class TodoList extends _$TodoList { + @override + List build() => []; + + void add(Todo todo) { + state = [...state, todo]; + } + + void toggle(String id) { + state = [ + for (final todo in state) + if (todo.id == id) todo.copyWith(completed: !todo.completed) else todo, + ]; + } + + void remove(String id) { + state = state.where((t) => t.id != id).toList(); + } +} + +// Async Notifier +@riverpod +class UserProfile extends _$UserProfile { + @override + Future build() async { + return ref.read(apiProvider).getCurrentUser(); + } + + Future updateName(String name) async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final updated = await ref.read(apiProvider).updateUser(name: name); + return updated; + }); + } +} +``` + +## Usage in Widgets + +```dart +// ConsumerWidget (recommended) +class TodoScreen extends ConsumerWidget { + const TodoScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final todos = ref.watch(todoListProvider); + + return ListView.builder( + itemCount: todos.length, + itemBuilder: (context, index) { + final todo = todos[index]; + return ListTile( + title: Text(todo.title), + leading: Checkbox( + value: todo.completed, + onChanged: (_) => ref.read(todoListProvider.notifier).toggle(todo.id), + ), + ); + }, + ); + } +} + +// Selective rebuilds with select +class UserAvatar extends ConsumerWidget { + const UserAvatar({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final avatarUrl = ref.watch(userProvider.select((u) => u?.avatarUrl)); + + return CircleAvatar( + backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, + ); + } +} + +// Async state handling +class UserProfileScreen extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final userAsync = ref.watch(userProfileProvider); + + return userAsync.when( + data: (user) => Text(user.name), + loading: () => const CircularProgressIndicator(), + error: (err, stack) => Text('Error: $err'), + ); + } +} +``` + +## Quick Reference + +| Provider | Use Case | +|----------|----------| +| `Provider` | Computed/derived values | +| `StateProvider` | Simple mutable state | +| `FutureProvider` | Async operations (one-time) | +| `StreamProvider` | Real-time data streams | +| `NotifierProvider` | Complex state with methods | +| `AsyncNotifierProvider` | Async state with methods | diff --git a/skills/flutter-expert/references/widget-patterns.md b/skills/flutter-expert/references/widget-patterns.md new file mode 100644 index 0000000..304a451 --- /dev/null +++ b/skills/flutter-expert/references/widget-patterns.md @@ -0,0 +1,123 @@ +# Widget Patterns + +## Optimized Widget Pattern + +```dart +// Use const constructors +class OptimizedCard extends StatelessWidget { + final String title; + final VoidCallback onTap; + + const OptimizedCard({ + super.key, + required this.title, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(16), + child: Text(title, style: Theme.of(context).textTheme.titleMedium), + ), + ), + ); + } +} +``` + +## Responsive Layout + +```dart +class ResponsiveLayout extends StatelessWidget { + final Widget mobile; + final Widget? tablet; + final Widget desktop; + + const ResponsiveLayout({ + super.key, + required this.mobile, + this.tablet, + required this.desktop, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth >= 1100) return desktop; + if (constraints.maxWidth >= 650) return tablet ?? mobile; + return mobile; + }, + ); + } +} +``` + +## Custom Hooks (flutter_hooks) + +```dart +import 'package:flutter_hooks/flutter_hooks.dart'; + +class CounterWidget extends HookWidget { + @override + Widget build(BuildContext context) { + final counter = useState(0); + final controller = useTextEditingController(); + + useEffect(() { + // Setup + return () { + // Cleanup + }; + }, []); + + return Column( + children: [ + Text('Count: ${counter.value}'), + ElevatedButton( + onPressed: () => counter.value++, + child: const Text('Increment'), + ), + ], + ); + } +} +``` + +## Sliver Patterns + +```dart +CustomScrollView( + slivers: [ + SliverAppBar( + expandedHeight: 200, + pinned: true, + flexibleSpace: FlexibleSpaceBar( + title: const Text('Title'), + background: Image.network(imageUrl, fit: BoxFit.cover), + ), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => ListTile(title: Text('Item $index')), + childCount: 100, + ), + ), + ], +) +``` + +## Key Optimization Patterns + +| Pattern | Implementation | +|---------|----------------| +| **const widgets** | Add `const` to static widgets | +| **keys** | Use `Key` for list items | +| **select** | `ref.watch(provider.select(...))` | +| **RepaintBoundary** | Isolate expensive repaints | +| **ListView.builder** | Lazy loading for lists | +| **const constructors** | Always use when possible | diff --git a/tmp_kiosk_device.db b/tmp_kiosk_device.db new file mode 100644 index 0000000..7aaaf0c Binary files /dev/null and b/tmp_kiosk_device.db differ