744 lines
19 KiB
Markdown
744 lines
19 KiB
Markdown
# 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<String, dynamic> 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<bool> 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<ScanResult>? _scanStream;
|
|
|
|
Stream<ScanResult> 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<SettingsPage> createState() => _SettingsPageState();
|
|
}
|
|
|
|
class _SettingsPageState extends State<SettingsPage> {
|
|
final _controller = TextEditingController();
|
|
final _apiService = ApiService();
|
|
bool _saving = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadUrl();
|
|
}
|
|
|
|
Future<void> _loadUrl() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
_controller.text = prefs.getString('api_url') ?? '';
|
|
}
|
|
|
|
Future<void> _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<void> _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<ScanPage> createState() => _ScanPageState();
|
|
}
|
|
|
|
class _ScanPageState extends State<ScanPage> {
|
|
final _scannerService = ScannerService();
|
|
final _apiService = ApiService();
|
|
final _records = <ScanRecord>[];
|
|
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 `<manifest>` tag (before `<application>`):
|
|
|
|
```xml
|
|
<uses-permission android:name="android.permission.INTERNET"/>
|
|
```
|
|
|
|
Add inside `<application>` tag (before the `<activity>`):
|
|
|
|
```xml
|
|
<uses-library android:name="android.scanner.library"/>
|
|
```
|
|
|
|
**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 <device_id>`
|
|
|
|
**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"
|
|
```
|