feat: QR-Code-Einrichtung (Kamera-Scan statt Token abtippen)

Neuer QrEnrollmentScanScreen (mobile_scanner) liest das JSON {url, token}
aus dem Portal-QR-Code (KioskDevicesPage → Bearer-Gerät anlegen) und füllt
EnrollmentScreen automatisch aus. Manuelle Eingabe bleibt als Fallback.
CAMERA-Permission ergänzt. v0.1.4+5, weiterhin release-signiert (gleicher
Fingerprint wie v0.1.3+4).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dzdm3EWeNdYFUuXFRJryA1
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-10 13:27:40 +02:00
1 parent 8044a90d12
commit be36786913
5 files changed
+109 -4

No files matched your search

+3
View File
@@ -2,6 +2,9 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.NFC"/>
<uses-feature android:name="android.hardware.nfc" android:required="false"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-feature android:name="android.hardware.camera" android:required="false"/>
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false"/>
<application
android:label="timemaster_kiosk"
android:name="${applicationName}"
+1 -1
View File
@@ -1,3 +1,3 @@
/// Manuell mit pubspec.yaml synchron halten (siehe timemaster-mobile,
/// gleiche Konvention – kein package_info_plus während aktiver Feldtests).
const String kAppVersion = '0.1.3+4';
const String kAppVersion = '0.1.4+5';
+32 -2
View File
@@ -2,10 +2,13 @@ import 'package:flutter/material.dart';
import '../app_version.dart';
import '../services/api_client.dart';
import 'login_screen.dart';
import 'qr_enrollment_scan_screen.dart';
/// Einmalige Ersteinrichtung: Server-URL + Bearer-Token eintragen, die per
/// `timemaster kiosk add --auth-mode bearer` auf dem Server generiert wurden.
/// Kein Keypair, kein QR-Scan – bewusst simpel (VPN übernimmt Transport-
/// `timemaster kiosk add --auth-mode bearer` (CLI) oder im Portal
/// (KioskDevicesPage → Bearer-Gerät anlegen) erzeugt wurden. Portal zeigt
/// dafür zusätzlich einen QR-Code an, den diese Seite scannen kann – spart
/// das Abtippen des Tokens. Kein Keypair (VPN übernimmt Transport-
/// Sicherheit, siehe Plan Android-Tablet-Kiosk 2026-09-10).
class EnrollmentScreen extends StatefulWidget {
const EnrollmentScreen({super.key});
@@ -20,6 +23,18 @@ class _EnrollmentScreenState extends State<EnrollmentScreen> {
bool _saving = false;
String? _error;
Future<void> _scanQr() async {
final result = await Navigator.of(context).push<Map<String, String>>(
MaterialPageRoute(builder: (_) => const QrEnrollmentScanScreen()),
);
if (result == null || !mounted) return;
setState(() {
_urlCtrl.text = result['url'] ?? _urlCtrl.text;
_tokenCtrl.text = result['token'] ?? '';
_error = null;
});
}
Future<void> _save() async {
if (_urlCtrl.text.trim().isEmpty || _tokenCtrl.text.trim().isEmpty) {
setState(() => _error = 'Server-URL und Token sind Pflichtfelder.');
@@ -60,6 +75,21 @@ class _EnrollmentScreenState extends State<EnrollmentScreen> {
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _scanQr,
icon: const Icon(Icons.qr_code_scanner),
label: const Text('QR-Code scannen'),
),
),
const SizedBox(height: 16),
Row(children: const [
Expanded(child: Divider()),
Padding(padding: EdgeInsets.symmetric(horizontal: 8), child: Text('oder manuell')),
Expanded(child: Divider()),
]),
const SizedBox(height: 16),
TextField(
controller: _urlCtrl,
decoration: const InputDecoration(labelText: 'Server-URL', hintText: 'https://.../api/v1'),
@@ -0,0 +1,71 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
/// Scannt den QR-Code aus dem Portal (KioskDevicesPage → Bearer-Gerät
/// anlegen). Erwartetes Format: {"url": "<api base url>", "token": "<...>"}.
class QrEnrollmentScanScreen extends StatefulWidget {
const QrEnrollmentScanScreen({super.key});
@override
State<QrEnrollmentScanScreen> createState() => _QrEnrollmentScanScreenState();
}
class _QrEnrollmentScanScreenState extends State<QrEnrollmentScanScreen> {
final _controller = MobileScannerController();
bool _handled = false;
String? _error;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onDetect(BarcodeCapture capture) {
if (_handled) return;
final raw = capture.barcodes.firstOrNull?.rawValue;
if (raw == null) return;
try {
final data = jsonDecode(raw) as Map<String, dynamic>;
final url = data['url'] as String?;
final token = data['token'] as String?;
if (url == null || url.isEmpty || token == null || token.isEmpty) {
setState(() => _error = 'QR-Code enthält nicht die erwarteten Felder (url, token).');
return;
}
_handled = true;
Navigator.of(context).pop({'url': url, 'token': token});
} catch (_) {
setState(() => _error = 'QR-Code konnte nicht gelesen werden (kein gültiges Format).');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('QR-Code scannen')),
body: Stack(
children: [
MobileScanner(controller: _controller, onDetect: _onDetect),
if (_error != null)
Positioned(
left: 16,
right: 16,
bottom: 32,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Colors.red[900], borderRadius: BorderRadius.circular(8)),
child: Text(_error!, style: const TextStyle(color: Colors.white), textAlign: TextAlign.center),
),
),
],
),
);
}
}
extension _FirstOrNull<T> on List<T> {
T? get firstOrNull => isEmpty ? null : first;
}
+2 -1
View File
@@ -1,7 +1,7 @@
name: timemaster_kiosk
description: TimeMaster Kiosk – Stempeluhr für Android-Tablets hinter VPN
publish_to: 'none'
version: 0.1.3+4
version: 0.1.4+5
environment:
sdk: '>=3.3.0 <4.0.0'
@@ -14,6 +14,7 @@ dependencies:
flutter_secure_storage: ^9.2.2
nfc_manager: ^4.2.1
wakelock_plus: ^1.2.8
mobile_scanner: ^6.0.2
dev_dependencies:
flutter_test: