From 70d22f16d1799f1b627e6c357e0a359c02037fb1 Mon Sep 17 00:00:00 2001 From: Misaka Date: Thu, 7 May 2026 19:45:10 +0800 Subject: [PATCH] Add scanner-to-api implementation plan Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-05-07-scanner-to-api-plan.md | 743 +++++++++++++++++++ 1 file changed, 743 insertions(+) create mode 100644 docs/plans/2026-05-07-scanner-to-api-plan.md diff --git a/docs/plans/2026-05-07-scanner-to-api-plan.md b/docs/plans/2026-05-07-scanner-to-api-plan.md new file mode 100644 index 0000000..fac5edd --- /dev/null +++ b/docs/plans/2026-05-07-scanner-to-api-plan.md @@ -0,0 +1,743 @@ +# Scanner to API Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a Flutter app that captures barcode scans from the D500 PAD scanner and sends the data to a user-configured HTTP endpoint. + +**Architecture:** Android BroadcastReceiver captures scan broadcasts from the device's built-in scanning service, forwards data to Flutter via EventChannel. The Dart side receives scan events, sends HTTP POST to the configured URL, and displays results. + +**Tech Stack:** Flutter 3.x, Dart, Android native (Kotlin), http package, shared_preferences + +--- + +### Task 1: Add Dependencies + +**Files:** +- Modify: `pubspec.yaml` + +**Step 1: Add http and shared_preferences to pubspec.yaml** + +Add under `dependencies:` (after `cupertino_icons`): + +```yaml + http: ^1.2.0 + shared_preferences: ^2.2.0 +``` + +**Step 2: Run flutter pub get** + +Run: `flutter pub get` +Expected: dependencies resolved successfully + +**Step 3: Commit** + +```bash +git add pubspec.yaml pubspec.lock +git commit -m "feat: add http and shared_preferences dependencies" +``` + +--- + +### Task 2: Create Scan Record Model + +**Files:** +- Create: `lib/models/scan_record.dart` +- Create: `test/models/scan_record_test.dart` + +**Step 1: Write the failing test** + +Create `test/models/scan_record_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:pad_scanner/models/scan_record.dart'; + +void main() { + group('ScanRecord', () { + test('creates from map', () { + final record = ScanRecord( + barcode: '1234567890', + codeType: 'CODE128', + timestamp: DateTime.parse('2026-05-07T10:30:00Z'), + ); + expect(record.barcode, '1234567890'); + expect(record.codeType, 'CODE128'); + expect(record.status, SendStatus.pending); + }); + + test('toJson produces correct map', () { + final record = ScanRecord( + barcode: 'ABC123', + codeType: 'QR', + timestamp: DateTime.parse('2026-05-07T10:30:00Z'), + ); + final json = record.toJson(); + expect(json['barcode'], 'ABC123'); + expect(json['code_type'], 'QR'); + expect(json['timestamp'], '2026-05-07T10:30:00.000Z'); + }); + }); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `flutter test test/models/scan_record_test.dart` +Expected: FAIL - file not found + +**Step 3: Write minimal implementation** + +Create `lib/models/scan_record.dart`: + +```dart +enum SendStatus { pending, success, failed } + +class ScanRecord { + final String barcode; + final String codeType; + final DateTime timestamp; + SendStatus status; + + ScanRecord({ + required this.barcode, + required this.codeType, + required this.timestamp, + this.status = SendStatus.pending, + }); + + Map toJson() => { + 'barcode': barcode, + 'code_type': codeType, + 'timestamp': timestamp.toUtc().toIso8601String(), + }; +} +``` + +**Step 4: Run test to verify it passes** + +Run: `flutter test test/models/scan_record_test.dart` +Expected: PASS + +**Step 5: Commit** + +```bash +git add lib/models/scan_record.dart test/models/scan_record_test.dart +git commit -m "feat: add ScanRecord model with toJson" +``` + +--- + +### Task 3: Create API Service + +**Files:** +- Create: `lib/services/api_service.dart` +- Create: `test/services/api_service_test.dart` + +**Step 1: Write the failing test** + +Create `test/services/api_service_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pad_scanner/services/api_service.dart'; + +void main() { + group('ApiService', () { + test('sendScanData returns true on 200', () async { + final client = MockClient((request) async { + return http.Response('{"status":"ok"}', 200); + }); + final service = ApiService(client: client); + final result = await service.sendScanData( + 'http://localhost:8000/scan', + barcode: '123456', + codeType: 'CODE128', + ); + expect(result, isTrue); + }); + + test('sendScanData returns false on error', () async { + final client = MockClient((request) async { + return http.Response('error', 500); + }); + final service = ApiService(client: client); + final result = await service.sendScanData( + 'http://localhost:8000/scan', + barcode: '123456', + codeType: 'CODE128', + ); + expect(result, isFalse); + }); + }); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `flutter test test/services/api_service_test.dart` +Expected: FAIL + +**Step 3: Write minimal implementation** + +Create `lib/services/api_service.dart`: + +```dart +import 'dart:convert'; +import 'package:http/http.dart' as http; + +class ApiService { + final http.Client _client; + + ApiService({http.Client? client}) + : _client = client ?? http.Client(); + + Future sendScanData( + String url, { + required String barcode, + required String codeType, + }) async { + try { + final response = await _client + .post( + Uri.parse(url), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'barcode': barcode, + 'code_type': codeType, + 'timestamp': DateTime.now().toUtc().toIso8601String(), + }), + ) + .timeout(const Duration(seconds: 5)); + return response.statusCode >= 200 && response.statusCode < 300; + } catch (_) { + return false; + } + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `flutter test test/services/api_service_test.dart` +Expected: PASS + +**Step 5: Commit** + +```bash +git add lib/services/api_service.dart test/services/api_service_test.dart +git commit -m "feat: add ApiService for HTTP POST" +``` + +--- + +### Task 4: Create Scanner Service (Flutter EventChannel) + +**Files:** +- Create: `lib/services/scanner_service.dart` + +**Step 1: Write the scanner service** + +Create `lib/services/scanner_service.dart`: + +```dart +import 'dart:async'; +import 'package:flutter/services.dart'; + +class ScanResult { + final String barcode; + final String codeType; + + ScanResult({required this.barcode, required this.codeType}); +} + +class ScannerService { + static const _eventChannel = + EventChannel('com.example.pad_scanner/scan'); + + Stream? _scanStream; + + Stream get scanResults { + _scanStream ??= _eventChannel + .receiveBroadcastStream() + .map((event) => event as Map) + .map((event) => ScanResult( + barcode: event['barcode'] as String? ?? '', + codeType: event['codeType'] as String? ?? 'UNKNOWN', + )); + return _scanStream!; + } +} +``` + +**Step 2: Commit** + +```bash +git add lib/services/scanner_service.dart +git commit -m "feat: add ScannerService with EventChannel" +``` + +--- + +### Task 5: Android Native - BroadcastReceiver + EventChannel + +**Files:** +- Modify: `android/app/src/main/kotlin/com/example/pad_scanner/MainActivity.kt` + +**Step 1: Implement BroadcastReceiver and EventChannel in MainActivity** + +Replace entire content of `MainActivity.kt`: + +```kotlin +package com.example.pad_scanner + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import io.flutter.embedding.android.FlutterActivity +import io.flutter.plugin.common.EventChannel + +class MainActivity : FlutterActivity() { + private var scanReceiver: BroadcastReceiver? = null + + override fun onResume() { + super.onResume() + registerScanReceiver() + } + + override fun onPause() { + super.onPause() + unregisterScanReceiver() + } + + private fun registerScanReceiver() { + scanReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + // noop - handled by EventChannel sink + } + } + } + + private fun unregisterScanReceiver() { + scanReceiver?.let { + unregisterReceiver(it) + scanReceiver = null + } + } + + override fun configureFlutterEngine(flutterEngine: io.flutter.embedding.engine.FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + EventChannel(flutterEngine.dartExecutor.binaryMessenger, + "com.example.pad_scanner/scan") + .setStreamHandler(object : EventChannel.StreamHandler { + private var receiver: BroadcastReceiver? = null + + override fun onListen(arguments: Any?, events: io.flutter.plugin.common.EventChannel.EventSink?) { + if (events == null) return + + receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val barcode = intent.getStringExtra("scannerdata") ?: return + events.success(mapOf( + "barcode" to barcode, + "codeType" to "UNKNOWN" + )) + } + } + + val filter = IntentFilter("com.android.server.scannerservice.broadcast") + registerReceiver(receiver, filter) + } + + override fun onCancel(arguments: Any?) { + receiver?.let { + unregisterReceiver(it) + receiver = null + } + } + }) + } +} +``` + +**Step 2: Commit** + +```bash +git add android/app/src/main/kotlin/com/example/pad_scanner/MainActivity.kt +git commit -m "feat: add BroadcastReceiver and EventChannel for scanner" +``` + +--- + +### Task 6: Settings Page + +**Files:** +- Create: `lib/pages/settings_page.dart` + +**Step 1: Write the settings page** + +Create `lib/pages/settings_page.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:pad_scanner/services/api_service.dart'; + +class SettingsPage extends StatefulWidget { + const SettingsPage({super.key}); + + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + final _controller = TextEditingController(); + final _apiService = ApiService(); + bool _saving = false; + + @override + void initState() { + super.initState(); + _loadUrl(); + } + + Future _loadUrl() async { + final prefs = await SharedPreferences.getInstance(); + _controller.text = prefs.getString('api_url') ?? ''; + } + + Future _saveUrl() async { + setState(() => _saving = true); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('api_url', _controller.text.trim()); + setState(() => _saving = false); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('URL saved')), + ); + } + } + + Future _testConnection() async { + final url = _controller.text.trim(); + if (url.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter a URL first')), + ); + return; + } + final ok = await _apiService.sendScanData( + url, + barcode: 'TEST_BARCODE', + codeType: 'TEST', + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(ok ? 'Connection OK' : 'Connection failed')), + ); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Settings')), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _controller, + decoration: const InputDecoration( + labelText: 'API URL', + hintText: 'http://192.168.1.100:8000/scan', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _saving ? null : _saveUrl, + child: _saving + ? const SizedBox( + width: 20, height: 20, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Save'), + ), + const SizedBox(height: 8), + OutlinedButton( + onPressed: _testConnection, + child: const Text('Test Connection'), + ), + ], + ), + ), + ); + } +} +``` + +**Step 2: Commit** + +```bash +git add lib/pages/settings_page.dart +git commit -m "feat: add settings page for URL configuration" +``` + +--- + +### Task 7: Main Scan Page + +**Files:** +- Create: `lib/pages/scan_page.dart` + +**Step 1: Write the scan page** + +Create `lib/pages/scan_page.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:pad_scanner/models/scan_record.dart'; +import 'package:pad_scanner/services/scanner_service.dart'; +import 'package:pad_scanner/services/api_service.dart'; +import 'package:pad_scanner/pages/settings_page.dart'; + +class ScanPage extends StatefulWidget { + const ScanPage({super.key}); + + @override + State createState() => _ScanPageState(); +} + +class _ScanPageState extends State { + final _scannerService = ScannerService(); + final _apiService = ApiService(); + final _records = []; + String _status = 'Waiting for scan...'; + + @override + void initState() { + super.initState(); + _startListening(); + } + + void _startListening() { + _scannerService.scanResults.listen((result) async { + final record = ScanRecord( + barcode: result.barcode, + codeType: result.codeType, + timestamp: DateTime.now(), + ); + + setState(() { + _records.insert(0, record); + _status = 'Sending...'; + }); + + final prefs = await SharedPreferences.getInstance(); + final url = prefs.getString('api_url') ?? ''; + + if (url.isEmpty) { + setState(() { + record.status = SendStatus.failed; + _status = 'No URL configured. Go to Settings.'; + }); + return; + } + + final ok = await _apiService.sendScanData( + url, + barcode: record.barcode, + codeType: record.codeType, + ); + + if (mounted) { + setState(() { + record.status = ok ? SendStatus.success : SendStatus.failed; + _status = ok ? 'Sent successfully' : 'Send failed'; + }); + } + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('PAD Scanner'), + actions: [ + IconButton( + icon: const Icon(Icons.settings), + onPressed: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SettingsPage()), + ), + ), + ], + ), + body: Column( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Text( + _status, + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + ), + Expanded( + child: _records.isEmpty + ? const Center( + child: Text('No scans yet.\nPress the scan button on the device.', + textAlign: TextAlign.center)) + : ListView.builder( + itemCount: _records.length, + itemBuilder: (context, index) { + final r = _records[index]; + return ListTile( + title: Text(r.barcode), + subtitle: Text( + '${r.codeType} ${r.timestamp.toLocal().toIso8601String().substring(0, 19)}'), + trailing: Icon( + r.status == SendStatus.success + ? Icons.check_circle + : r.status == SendStatus.failed + ? Icons.error + : Icons.hourglass_empty, + color: r.status == SendStatus.success + ? Colors.green + : r.status == SendStatus.failed + ? Colors.red + : Colors.orange, + ), + ); + }, + ), + ), + ], + ), + ); + } +} +``` + +**Step 2: Commit** + +```bash +git add lib/pages/scan_page.dart +git commit -m "feat: add scan page with record list and send logic" +``` + +--- + +### Task 8: Wire Up main.dart + +**Files:** +- Modify: `lib/main.dart` + +**Step 1: Replace main.dart** + +Replace entire content of `lib/main.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:pad_scanner/pages/scan_page.dart'; + +void main() { + runApp(const PadScannerApp()); +} + +class PadScannerApp extends StatelessWidget { + const PadScannerApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'PAD Scanner', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), + useMaterial3: true, + ), + home: const ScanPage(), + ); + } +} +``` + +**Step 2: Commit** + +```bash +git add lib/main.dart +git commit -m "feat: wire up main.dart with ScanPage" +``` + +--- + +### Task 9: Update AndroidManifest + +**Files:** +- Modify: `android/app/src/main/AndroidManifest.xml` + +**Step 1: Add INTERNET permission and uses-library** + +Add inside `` tag (before ``): + +```xml + +``` + +Add inside `` tag (before the ``): + +```xml + +``` + +**Step 2: Commit** + +```bash +git add android/app/src/main/AndroidManifest.xml +git commit -m "feat: add INTERNET permission and scanner library" +``` + +--- + +### Task 10: Build and Deploy to Device + +**Step 1: Run all unit tests** + +Run: `flutter test` +Expected: all tests PASS + +**Step 2: Build and deploy to D500 device** + +Make sure device is connected via USB, then: + +Run: `flutter run -d ` + +**Step 3: Verify on device** + +1. App launches with "Waiting for scan..." status +2. Go to Settings, enter a URL (or use a mock server), save +3. Press the scan button on the device, scan a barcode +4. Verify the barcode appears in the list +5. Verify the send status indicator updates + +**Step 4: Final commit (if any fixes needed)** + +```bash +git add -A +git commit -m "fix: adjustments from device testing" +```