feat(mobile): Flutter-App Grundgerüst (Login + Stempeln)

Employee-only App gegen bestehende Backend-API: Login inkl. TOTP-Schritt,
Ein-/Ausstempeln, Pause, Status "heute". Refresh-Token läuft nur als
HttpOnly-Cookie (Backend gibt ihn nie im Body zurück) → persistente
Cookie-Jar statt Secure-Storage-Refresh-Token. android/-Projektordner via
flutter create auf root@192.168.1.236 generiert, INTERNET-Permission
nachgetragen. Ungetestet auf echtem Gerät.

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-09 22:59:22 +02:00
commit 0b980d73bc
30 files changed
+1099

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# Widget Preview related
.widget_preview/
+38
View File
@@ -0,0 +1,38 @@
# TimeMaster Mobile (Flutter)
Employee-only App: Login + Ein-/Ausstempeln + Pause. Gegen bestehende Backend-API
(`backend/app/routers/{auth,time_entries}.py`).
## Setup
```bash
cd mobile
flutter create . --platforms=android,ios # generiert android/ios-Ordner (fehlen noch)
flutter pub get
flutter run
```
Kein `flutter` hier lokal verfügbar (kein SDK installiert) – `pubspec.yaml` +
`lib/` sind von Hand geschrieben, ungetestet. Vor erstem Start:
`flutter create .` im `mobile/`-Ordner ausführen, damit `android/`, `ios/` +
Gradle/Xcode-Projekte entstehen (überschreibt `lib/main.dart` NICHT, da
bereits vorhanden – bestehende Dateien bleiben).
## Auth-Flow
- `POST /auth/login` → bei `totp_required: true` zweiter Schritt `POST /auth/totp/login`
- Access-Token in `flutter_secure_storage`, Refresh-Token läuft NUR als
HttpOnly-Cookie (Backend gibt ihn nie im Body zurück) → persistente
`PersistCookieJar` (`cookie_jar`/`dio_cookie_manager`) nötig, siehe
`lib/services/api_client.dart`.
- 401 → einmaliger Auto-Refresh via Cookie, danach Logout + Redirect (Redirect
aktuell nicht verdrahtet – `onUnauthorized`-Callback ist vorbereitet, aber
in `main.dart` noch nicht an Navigator gebunden – TODO).
## Server-Adresse
Einstellbar im Login-Screen (Zahnrad oben rechts), landet in Secure Storage.
Default zeigt auf Platzhalter-URL – vor erstem Test auf 137 oder 164 zeigen
(`https://<host>/api/v1`).
## Umfang aktuell
Nur Login + stamp-in/out + break-start/end + Status "heute". Kein Antrags-
Workflow (Abwesenheit etc.), kein Offline-Modus, keine echte Android/iOS-
Build-Config (android/ios-Ordner fehlen noch, siehe oben).
+33
View File
@@ -0,0 +1,33 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- build/**
- android/**
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+49
View File
@@ -0,0 +1,49 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "de.timemaster.timemaster_mobile"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "de.timemaster.timemaster_mobile"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
// You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
// flag during build.
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+46
View File
@@ -0,0 +1,46 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="timemaster_mobile"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package de.timemaster.timemaster_mobile
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+6
View File
@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.1.0" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
}
include(":app")
+43
View File
@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'services/api_client.dart';
import 'screens/login_screen.dart';
import 'screens/home_screen.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await ApiClient.instance.init();
runApp(const TimeMasterApp());
}
class TimeMasterApp extends StatelessWidget {
const TimeMasterApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'TimeMaster',
theme: ThemeData(
colorSchemeSeed: Colors.indigo,
useMaterial3: true,
),
home: const _SessionGate(),
);
}
}
class _SessionGate extends StatelessWidget {
const _SessionGate();
@override
Widget build(BuildContext context) {
return FutureBuilder<bool>(
future: ApiClient.instance.hasSession(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return snapshot.data! ? const HomeScreen() : const LoginScreen();
},
);
}
}
+55
View File
@@ -0,0 +1,55 @@
/// Spiegelt backend/app/schemas/time_entry.py::TimeEntryOut.
class TimeEntry {
final String id;
final DateTime date;
final String startTime; // "HH:MM:SS"
final String? endTime;
final int breakMinutes;
final String? breakStart;
final String status; // EntryStatus: open/pending/approved/rejected
final String? note;
final double? workedHours;
TimeEntry({
required this.id,
required this.date,
required this.startTime,
this.endTime,
required this.breakMinutes,
this.breakStart,
required this.status,
this.note,
this.workedHours,
});
bool get isOpen => endTime == null;
bool get isOnBreak => breakStart != null;
factory TimeEntry.fromJson(Map<String, dynamic> json) {
return TimeEntry(
id: json['id'] as String,
date: DateTime.parse(json['date'] as String),
startTime: json['start_time'] as String,
endTime: json['end_time'] as String?,
breakMinutes: json['break_minutes'] as int? ?? 0,
breakStart: json['break_start'] as String?,
status: json['status'] as String,
note: json['note'] as String?,
workedHours: (json['worked_hours'] as num?)?.toDouble(),
);
}
}
class StampResult {
final TimeEntry entry;
final List<String> warnings;
StampResult({required this.entry, required this.warnings});
factory StampResult.fromJson(Map<String, dynamic> json) {
return StampResult(
entry: TimeEntry.fromJson(json['entry'] as Map<String, dynamic>),
warnings: (json['warnings'] as List<dynamic>? ?? []).cast<String>(),
);
}
}
+221
View File
@@ -0,0 +1,221 @@
import 'package:flutter/material.dart';
import 'package:dio/dio.dart';
import '../models/time_entry.dart';
import '../services/auth_service.dart';
import '../services/time_service.dart';
import 'login_screen.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final _timeService = TimeService();
final _auth = AuthService();
bool _loading = true;
bool _actionInProgress = false;
TimeEntry? _openEntry;
String? _error;
@override
void initState() {
super.initState();
_refresh();
}
Future<void> _refresh() async {
setState(() {
_loading = true;
_error = null;
});
try {
final entries = await _timeService.getToday();
TimeEntry? open;
for (final e in entries) {
if (e.isOpen) open = e;
}
setState(() {
_openEntry = open;
_loading = false;
});
} on DioException catch (e) {
setState(() {
_error = _dioMessage(e);
_loading = false;
});
}
}
String _dioMessage(DioException e) {
final detail = e.response?.data is Map ? e.response?.data['detail'] : null;
if (detail is String) return detail;
return 'Verbindung fehlgeschlagen.';
}
Future<void> _runAction(Future<void> Function() action) async {
setState(() {
_actionInProgress = true;
_error = null;
});
try {
await action();
await _refresh();
} on DioException catch (e) {
setState(() {
_error = _dioMessage(e);
_actionInProgress = false;
});
}
}
Future<void> _stampIn() => _runAction(() async {
final result = await _timeService.stampIn();
if (result.warnings.isNotEmpty) _showWarnings(result.warnings);
});
Future<void> _stampOut() => _runAction(() async {
final result = await _timeService.stampOut();
if (result.warnings.isNotEmpty) _showWarnings(result.warnings);
});
Future<void> _breakStart() => _runAction(() => _timeService.breakStart());
Future<void> _breakEnd() => _runAction(() => _timeService.breakEnd());
void _showWarnings(List<String> warnings) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(warnings.join(' · ')), backgroundColor: Colors.orange[800]),
);
}
Future<void> _logout() async {
await _auth.logout();
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const LoginScreen()),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Zeiterfassung'),
actions: [
IconButton(icon: const Icon(Icons.logout), onPressed: _logout),
],
),
body: RefreshIndicator(
onRefresh: _refresh,
child: _loading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.all(24),
children: [
if (_error != null)
Card(
color: Colors.red[50],
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(_error!, style: const TextStyle(color: Colors.red)),
),
),
const SizedBox(height: 16),
_statusCard(),
const SizedBox(height: 24),
..._actionButtons(),
],
),
),
);
}
Widget _statusCard() {
final entry = _openEntry;
String status;
Color color;
if (entry == null) {
status = 'Ausgestempelt';
color = Colors.grey;
} else if (entry.isOnBreak) {
status = 'Pause seit ${entry.breakStart}';
color = Colors.orange;
} else {
status = 'Eingestempelt seit ${entry.startTime}';
color = Colors.green;
}
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
Icon(Icons.circle, color: color, size: 16),
const SizedBox(width: 12),
Expanded(child: Text(status, style: Theme.of(context).textTheme.titleMedium)),
],
),
),
);
}
List<Widget> _actionButtons() {
final entry = _openEntry;
final disabled = _actionInProgress;
if (entry == null) {
return [
SizedBox(
width: double.infinity,
height: 56,
child: FilledButton.icon(
onPressed: disabled ? null : _stampIn,
icon: const Icon(Icons.login),
label: const Text('Einstempeln'),
),
),
];
}
if (entry.isOnBreak) {
return [
SizedBox(
width: double.infinity,
height: 56,
child: FilledButton.icon(
onPressed: disabled ? null : _breakEnd,
icon: const Icon(Icons.play_arrow),
label: const Text('Pause beenden'),
),
),
];
}
return [
SizedBox(
width: double.infinity,
height: 56,
child: OutlinedButton.icon(
onPressed: disabled ? null : _breakStart,
icon: const Icon(Icons.free_breakfast),
label: const Text('Pause beginnen'),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
height: 56,
child: FilledButton.icon(
style: FilledButton.styleFrom(backgroundColor: Colors.red[700]),
onPressed: disabled ? null : _stampOut,
icon: const Icon(Icons.logout),
label: const Text('Ausstempeln'),
),
),
];
}
}
+171
View File
@@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import '../services/api_client.dart';
import '../services/auth_service.dart';
import 'home_screen.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _emailCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
final _totpCtrl = TextEditingController();
final _auth = AuthService();
bool _loading = false;
bool _totpStep = false;
String? _partialToken;
String? _error;
Future<void> _submitLogin() async {
setState(() {
_loading = true;
_error = null;
});
final result = await _auth.login(_emailCtrl.text.trim(), _passwordCtrl.text);
setState(() => _loading = false);
if (result.totpRequired) {
setState(() {
_totpStep = true;
_partialToken = result.partialToken;
});
return;
}
if (!result.success) {
setState(() => _error = result.error);
return;
}
_goHome();
}
Future<void> _submitTotp() async {
setState(() {
_loading = true;
_error = null;
});
final result = await _auth.submitTotp(_partialToken!, _totpCtrl.text.trim());
setState(() => _loading = false);
if (!result.success) {
setState(() => _error = result.error);
return;
}
_goHome();
}
void _goHome() {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const HomeScreen()),
);
}
void _showServerSettings() {
final ctrl = TextEditingController(text: ApiClient.instance.baseUrl);
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Server-Adresse'),
content: TextField(
controller: ctrl,
decoration: const InputDecoration(hintText: 'https://timemaster.example.com/api/v1'),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Abbrechen')),
TextButton(
onPressed: () async {
await ApiClient.instance.setBaseUrl(ctrl.text.trim());
if (ctx.mounted) Navigator.pop(ctx);
},
child: const Text('Speichern'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('TimeMaster'),
actions: [
IconButton(icon: const Icon(Icons.settings), onPressed: _showServerSettings),
],
),
body: Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!_totpStep) ..._loginForm() else ..._totpForm(),
if (_error != null) ...[
const SizedBox(height: 16),
Text(_error!, style: const TextStyle(color: Colors.red)),
],
],
),
),
),
),
);
}
List<Widget> _loginForm() {
return [
TextField(
controller: _emailCtrl,
decoration: const InputDecoration(labelText: 'E-Mail / Kennung'),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 12),
TextField(
controller: _passwordCtrl,
decoration: const InputDecoration(labelText: 'Passwort'),
obscureText: true,
onSubmitted: (_) => _submitLogin(),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _loading ? null : _submitLogin,
child: _loading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Anmelden'),
),
),
];
}
List<Widget> _totpForm() {
return [
const Text('Bitte Code aus deiner Authenticator-App eingeben.'),
const SizedBox(height: 12),
TextField(
controller: _totpCtrl,
decoration: const InputDecoration(labelText: '6-stelliger Code'),
keyboardType: TextInputType.number,
maxLength: 6,
onSubmitted: (_) => _submitTotp(),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _loading ? null : _submitTotp,
child: _loading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Bestätigen'),
),
),
];
}
}
+102
View File
@@ -0,0 +1,102 @@
import 'package:dio/dio.dart';
import 'package:cookie_jar/cookie_jar.dart';
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:path_provider/path_provider.dart';
/// Zentraler HTTP-Client. Access-Token liegt in Secure Storage, Refresh-Token
/// kommt vom Backend AUSSCHLIESSLICH als HttpOnly-Cookie (siehe auth.py:
/// TokenResponse.refresh_token wird bei jedem Login/Refresh serverseitig auf
/// None gesetzt) → persistente Cookie-Jar nötig, kein Body-Refresh-Token.
class ApiClient {
ApiClient._internal();
static final ApiClient instance = ApiClient._internal();
static const _storage = FlutterSecureStorage();
static const _keyBaseUrl = 'api_base_url';
static const _keyAccessToken = 'access_token';
late final Dio dio;
late final PersistCookieJar _cookieJar;
void Function()? onUnauthorized;
String _baseUrl = 'https://timemaster.example.com/api/v1';
Future<void> init() async {
final saved = await _storage.read(key: _keyBaseUrl);
if (saved != null && saved.isNotEmpty) _baseUrl = saved;
final dir = await getApplicationSupportDirectory();
_cookieJar = PersistCookieJar(storage: FileStorage('${dir.path}/cookies'));
dio = Dio(BaseOptions(
baseUrl: _baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
));
dio.interceptors.add(CookieManager(_cookieJar));
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await _storage.read(key: _keyAccessToken);
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
onError: (error, handler) async {
if (error.response?.statusCode == 401 &&
error.requestOptions.path != '/auth/refresh') {
final refreshed = await _tryRefresh();
if (refreshed) {
final opts = error.requestOptions;
final token = await _storage.read(key: _keyAccessToken);
opts.headers['Authorization'] = 'Bearer $token';
try {
final response = await dio.fetch(opts);
return handler.resolve(response);
} catch (_) {
// fällt durch zu logout unten
}
}
await clearSession();
onUnauthorized?.call();
}
handler.next(error);
},
));
}
Future<bool> _tryRefresh() async {
try {
// Cookie wird von CookieManager automatisch mitgeschickt.
final response = await dio.post('/auth/refresh');
final data = response.data as Map<String, dynamic>;
await _storage.write(key: _keyAccessToken, value: data['access_token'] as String);
return true;
} catch (_) {
return false;
}
}
Future<void> setBaseUrl(String url) async {
_baseUrl = url;
dio.options.baseUrl = url;
await _storage.write(key: _keyBaseUrl, value: url);
}
String get baseUrl => _baseUrl;
Future<void> saveAccessToken(String accessToken) async {
await _storage.write(key: _keyAccessToken, value: accessToken);
}
Future<void> clearSession() async {
await _storage.delete(key: _keyAccessToken);
await _cookieJar.deleteAll();
}
Future<bool> hasSession() async {
return (await _storage.read(key: _keyAccessToken)) != null;
}
}
+74
View File
@@ -0,0 +1,74 @@
import 'package:dio/dio.dart';
import 'api_client.dart';
class LoginOutcome {
final bool success;
final bool totpRequired;
final String? partialToken;
final String? error;
LoginOutcome({
required this.success,
this.totpRequired = false,
this.partialToken,
this.error,
});
}
class AuthService {
final _api = ApiClient.instance;
Future<LoginOutcome> login(String email, String password) async {
try {
final response = await _api.dio.post('/auth/login', data: {
'email': email,
'password': password,
});
final data = response.data as Map<String, dynamic>;
if (data['totp_required'] == true) {
return LoginOutcome(
success: false,
totpRequired: true,
partialToken: data['partial_token'] as String,
);
}
await _api.saveAccessToken(data['access_token'] as String);
return LoginOutcome(success: true);
} on DioException catch (e) {
return LoginOutcome(success: false, error: _errorMessage(e));
}
}
Future<LoginOutcome> submitTotp(String partialToken, String code) async {
try {
final response = await _api.dio.post('/auth/totp/login', data: {
'partial_token': partialToken,
'code': code,
});
final data = response.data as Map<String, dynamic>;
await _api.saveAccessToken(data['access_token'] as String);
return LoginOutcome(success: true);
} on DioException catch (e) {
return LoginOutcome(success: false, error: _errorMessage(e));
}
}
Future<void> logout() async {
try {
await _api.dio.post('/auth/logout');
} catch (_) {
// Offline-Logout ist ok, lokale Session wird trotzdem geleert.
}
await _api.clearSession();
}
String _errorMessage(DioException e) {
final detail = e.response?.data is Map ? e.response?.data['detail'] : null;
if (detail is String) return detail;
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.connectionError) {
return 'Server nicht erreichbar. Server-Adresse prüfen.';
}
return 'Login fehlgeschlagen.';
}
}
+38
View File
@@ -0,0 +1,38 @@
import '../models/time_entry.dart';
import 'api_client.dart';
class TimeService {
final _dio = ApiClient.instance.dio;
Future<List<TimeEntry>> getToday() async {
final response = await _dio.get('/time/today');
return (response.data as List<dynamic>)
.map((e) => TimeEntry.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<StampResult> stampIn({String? note}) async {
final response = await _dio.post('/time/stamp-in', data: {
'source': 'api',
if (note != null && note.isNotEmpty) 'note': note,
});
return StampResult.fromJson(response.data as Map<String, dynamic>);
}
Future<StampResult> stampOut({String? note}) async {
final response = await _dio.post('/time/stamp-out', data: {
if (note != null && note.isNotEmpty) 'note': note,
});
return StampResult.fromJson(response.data as Map<String, dynamic>);
}
Future<TimeEntry> breakStart() async {
final response = await _dio.post('/time/break-start');
return TimeEntry.fromJson(response.data as Map<String, dynamic>);
}
Future<TimeEntry> breakEnd() async {
final response = await _dio.post('/time/break-end');
return TimeEntry.fromJson(response.data as Map<String, dynamic>);
}
}
+27
View File
@@ -0,0 +1,27 @@
name: timemaster_mobile
description: TimeMaster Zeiterfassung – Employee App
publish_to: 'none'
version: 0.1.0
environment:
sdk: '>=3.3.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
dio: ^5.7.0
cookie_jar: ^4.0.8
dio_cookie_manager: ^3.1.1
path_provider: ^2.1.4
flutter_secure_storage: ^9.2.2
provider: ^6.1.2
intl: ^0.19.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^4.0.0
flutter:
uses-material-design: true