Files
pad_scanner/docs/plans/2026-05-07-scanner-to-api-plan.md
Misaka 70d22f16d1 Add scanner-to-api implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 19:45:10 +08:00

19 KiB

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):

  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

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:

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:

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

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:

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:

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

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:

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

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:

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

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:

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

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:

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

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:

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

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>):

<uses-permission android:name="android.permission.INTERNET"/>

Add inside <application> tag (before the <activity>):

<uses-library android:name="android.scanner.library"/>

Step 2: Commit

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)

git add -A
git commit -m "fix: adjustments from device testing"