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:
1 parent
8044a90d12
commit
be36786913
5 files changed
+109
-4
No files matched your search
@@ -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,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';
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user