feat: In-App-Update (Download + Installer öffnen)

Portiert von timemaster-mobile v0.1.7+8: UpdateCheckService prüft
kiosk-version.txt, LoginScreen zeigt Update-Banner mit Fortschrittsbalken +
"Jetzt aktualisieren"-Button, öffnet Android-Paketinstaller direkt
(open_filex + FileProvider). Banner sitzt im LoginScreen statt StampScreen,
da das der Idle-/Ruhezustand des Kiosks ist – Stempel-Sessions sind nur
900s aktiv. REQUEST_INSTALL_PACKAGES-Permission ergänzt.

v0.1.5+6. Diese Version selbst muss noch einmal manuell installiert werden
(Feature existiert ja erst danach), ab hier sollte der Update-Button reichen.

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:39:16 +02:00
1 parent be36786913
commit 6f30e11e4e
6 files changed
+155 -2

No files matched your search

+13
View File
@@ -5,6 +5,7 @@
<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"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<application
android:label="timemaster_kiosk"
android:name="${applicationName}"
@@ -36,6 +37,18 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- FileProvider für open_filex – Android 7+ verlangt content://
statt file:// beim Öffnen heruntergeladener APKs. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Deckt getExternalFilesDir()/... ab, wo wir das APK zwischenspeichern -->
<external-files-path name="downloads" path="." />
<cache-path name="cache" path="." />
</paths>
+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.4+5';
const String kAppVersion = '0.1.5+6';
+76
View File
@@ -7,6 +7,7 @@ import '../app_version.dart';
import '../models/kiosk_session.dart';
import '../services/api_client.dart';
import '../services/kiosk_service.dart';
import '../services/update_check_service.dart';
import 'enrollment_screen.dart';
import 'stamp_screen.dart';
@@ -32,12 +33,49 @@ class _LoginScreenState extends State<LoginScreen> {
String? _scannedUid;
List<KioskEmployee>? _employees;
final _updateService = UpdateCheckService();
String? _newerVersion;
bool _updating = false;
double? _updateProgress;
@override
void initState() {
super.initState();
_updateService.checkForNewerVersion().then((v) {
if (mounted && v != null) setState(() => _newerVersion = v);
});
}
@override
void dispose() {
if (_nfcScanning) NfcManager.instance.stopSession();
super.dispose();
}
Future<void> _installUpdate() async {
setState(() {
_updating = true;
_updateProgress = null;
_error = null;
});
try {
await _updateService.downloadAndInstall(
onProgress: (p) {
if (mounted) setState(() => _updateProgress = p);
},
);
} catch (e) {
if (mounted) setState(() => _error = 'Update fehlgeschlagen: $e');
} finally {
if (mounted) {
setState(() {
_updating = false;
_updateProgress = null;
});
}
}
}
String _dioMessage(DioException e) {
final detail = e.response?.data is Map ? e.response?.data['detail'] : null;
if (detail is String) return detail;
@@ -223,6 +261,44 @@ class _LoginScreenState extends State<LoginScreen> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_newerVersion != null) ...[
Card(
color: Colors.blue[50],
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.system_update, color: Colors.blue),
const SizedBox(width: 12),
Expanded(
child: Text(
'Neue Version $_newerVersion verfügbar (installiert: $kAppVersion).',
style: const TextStyle(color: Colors.blue),
),
),
],
),
const SizedBox(height: 8),
if (_updating)
LinearProgressIndicator(value: _updateProgress)
else
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _installUpdate,
icon: const Icon(Icons.download),
label: const Text('Jetzt aktualisieren'),
),
),
],
),
),
),
const SizedBox(height: 16),
],
SegmentedButton<_LoginMode>(
segments: const [
ButtonSegment(value: _LoginMode.pin, label: Text('PIN'), icon: Icon(Icons.pin)),
+56
View File
@@ -0,0 +1,56 @@
import 'package:dio/dio.dart';
import 'package:open_filex/open_filex.dart';
import 'package:path_provider/path_provider.dart';
import '../app_version.dart';
import 'api_client.dart';
/// Prüft `/static/downloads/kiosk-version.txt` auf dem konfigurierten Server
/// gegen die eigene kAppVersion und kann das neue APK direkt herunterladen +
/// den System-Installer öffnen (REQUEST_INSTALL_PACKAGES + FileProvider,
/// siehe AndroidManifest.xml). Analog timemaster-mobile, eigene Dateinamen
/// (kiosk-version.txt / timemaster-kiosk.apk – geteiltes Downloads-
/// Verzeichnis mit der Employee-App auf dem Server, andere Dateien).
class UpdateCheckService {
String get _origin {
final apiBase = ApiClient.instance.baseUrl; // endet auf .../api/v1
return apiBase.replaceFirst(RegExp(r'/api/v1/?$'), '');
}
Future<String?> checkForNewerVersion() async {
try {
final response = await Dio(BaseOptions(
baseUrl: _origin,
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
)).get<String>('/static/downloads/kiosk-version.txt');
final remote = (response.data ?? '').trim();
if (remote.isEmpty || remote == kAppVersion) return null;
return remote;
} on DioException {
return null;
} catch (_) {
return null;
}
}
/// Lädt die APK herunter und öffnet den Android-Paketinstaller.
/// [onProgress] liefert 0.0–1.0, falls die Antwort ein Content-Length hat.
Future<void> downloadAndInstall({void Function(double)? onProgress}) async {
final dir = await getExternalStorageDirectory() ?? await getTemporaryDirectory();
final filePath = '${dir.path}/timemaster-kiosk-update.apk';
await Dio(BaseOptions(baseUrl: _origin)).download(
'/static/downloads/timemaster-kiosk.apk',
filePath,
onReceiveProgress: (received, total) {
if (total > 0 && onProgress != null) onProgress(received / total);
},
);
final result = await OpenFilex.open(filePath, type: 'application/vnd.android.package-archive');
if (result.type != ResultType.done) {
throw Exception('Installer konnte nicht geöffnet werden: ${result.message}');
}
}
}
+3 -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.4+5
version: 0.1.5+6
environment:
sdk: '>=3.3.0 <4.0.0'
@@ -15,6 +15,8 @@ dependencies:
nfc_manager: ^4.2.1
wakelock_plus: ^1.2.8
mobile_scanner: ^6.0.2
open_filex: ^4.5.0
path_provider: ^2.1.4
dev_dependencies:
flutter_test: