diff --git a/mobile/.gitignore b/mobile/.gitignore new file mode 100644 index 0000000..79f7eca --- /dev/null +++ b/mobile/.gitignore @@ -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/ diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 0000000..598dcd6 --- /dev/null +++ b/mobile/README.md @@ -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:///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). diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml new file mode 100644 index 0000000..ae2e3ea --- /dev/null +++ b/mobile/analysis_options.yaml @@ -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 diff --git a/mobile/android/.gitignore b/mobile/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/mobile/android/.gitignore @@ -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 diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts new file mode 100644 index 0000000..a80f953 --- /dev/null +++ b/mobile/android/app/build.gradle.kts @@ -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 = "../.." +} diff --git a/mobile/android/app/src/debug/AndroidManifest.xml b/mobile/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/mobile/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..47d9adf --- /dev/null +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/android/app/src/main/kotlin/de/timemaster/timemaster_mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/de/timemaster/timemaster_mobile/MainActivity.kt new file mode 100644 index 0000000..c4c5a62 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/de/timemaster/timemaster_mobile/MainActivity.kt @@ -0,0 +1,5 @@ +package de.timemaster.timemaster_mobile + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/mobile/android/app/src/main/res/drawable-v21/launch_background.xml b/mobile/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/mobile/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/mobile/android/app/src/main/res/drawable/launch_background.xml b/mobile/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/values-night/styles.xml b/mobile/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/mobile/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/mobile/android/app/src/main/res/values/styles.xml b/mobile/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/mobile/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/mobile/android/app/src/profile/AndroidManifest.xml b/mobile/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/mobile/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/mobile/android/build.gradle.kts b/mobile/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/mobile/android/build.gradle.kts @@ -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("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/mobile/android/gradle.properties @@ -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 diff --git a/mobile/android/gradle/wrapper/gradle-wrapper.properties b/mobile/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a20f2c4 --- /dev/null +++ b/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/mobile/android/settings.gradle.kts b/mobile/android/settings.gradle.kts new file mode 100644 index 0000000..b28021a --- /dev/null +++ b/mobile/android/settings.gradle.kts @@ -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") diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart new file mode 100644 index 0000000..50218f9 --- /dev/null +++ b/mobile/lib/main.dart @@ -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 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( + future: ApiClient.instance.hasSession(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + return snapshot.data! ? const HomeScreen() : const LoginScreen(); + }, + ); + } +} diff --git a/mobile/lib/models/time_entry.dart b/mobile/lib/models/time_entry.dart new file mode 100644 index 0000000..7446cd2 --- /dev/null +++ b/mobile/lib/models/time_entry.dart @@ -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 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 warnings; + + StampResult({required this.entry, required this.warnings}); + + factory StampResult.fromJson(Map json) { + return StampResult( + entry: TimeEntry.fromJson(json['entry'] as Map), + warnings: (json['warnings'] as List? ?? []).cast(), + ); + } +} diff --git a/mobile/lib/screens/home_screen.dart b/mobile/lib/screens/home_screen.dart new file mode 100644 index 0000000..cb576dd --- /dev/null +++ b/mobile/lib/screens/home_screen.dart @@ -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 createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + final _timeService = TimeService(); + final _auth = AuthService(); + + bool _loading = true; + bool _actionInProgress = false; + TimeEntry? _openEntry; + String? _error; + + @override + void initState() { + super.initState(); + _refresh(); + } + + Future _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 _runAction(Future Function() action) async { + setState(() { + _actionInProgress = true; + _error = null; + }); + try { + await action(); + await _refresh(); + } on DioException catch (e) { + setState(() { + _error = _dioMessage(e); + _actionInProgress = false; + }); + } + } + + Future _stampIn() => _runAction(() async { + final result = await _timeService.stampIn(); + if (result.warnings.isNotEmpty) _showWarnings(result.warnings); + }); + + Future _stampOut() => _runAction(() async { + final result = await _timeService.stampOut(); + if (result.warnings.isNotEmpty) _showWarnings(result.warnings); + }); + + Future _breakStart() => _runAction(() => _timeService.breakStart()); + + Future _breakEnd() => _runAction(() => _timeService.breakEnd()); + + void _showWarnings(List warnings) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(warnings.join(' · ')), backgroundColor: Colors.orange[800]), + ); + } + + Future _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 _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'), + ), + ), + ]; + } +} diff --git a/mobile/lib/screens/login_screen.dart b/mobile/lib/screens/login_screen.dart new file mode 100644 index 0000000..1564418 --- /dev/null +++ b/mobile/lib/screens/login_screen.dart @@ -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 createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _emailCtrl = TextEditingController(); + final _passwordCtrl = TextEditingController(); + final _totpCtrl = TextEditingController(); + final _auth = AuthService(); + + bool _loading = false; + bool _totpStep = false; + String? _partialToken; + String? _error; + + Future _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 _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 _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 _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'), + ), + ), + ]; + } +} diff --git a/mobile/lib/services/api_client.dart b/mobile/lib/services/api_client.dart new file mode 100644 index 0000000..9a29016 --- /dev/null +++ b/mobile/lib/services/api_client.dart @@ -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 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 _tryRefresh() async { + try { + // Cookie wird von CookieManager automatisch mitgeschickt. + final response = await dio.post('/auth/refresh'); + final data = response.data as Map; + await _storage.write(key: _keyAccessToken, value: data['access_token'] as String); + return true; + } catch (_) { + return false; + } + } + + Future setBaseUrl(String url) async { + _baseUrl = url; + dio.options.baseUrl = url; + await _storage.write(key: _keyBaseUrl, value: url); + } + + String get baseUrl => _baseUrl; + + Future saveAccessToken(String accessToken) async { + await _storage.write(key: _keyAccessToken, value: accessToken); + } + + Future clearSession() async { + await _storage.delete(key: _keyAccessToken); + await _cookieJar.deleteAll(); + } + + Future hasSession() async { + return (await _storage.read(key: _keyAccessToken)) != null; + } +} diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart new file mode 100644 index 0000000..960f00b --- /dev/null +++ b/mobile/lib/services/auth_service.dart @@ -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 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; + 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 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; + await _api.saveAccessToken(data['access_token'] as String); + return LoginOutcome(success: true); + } on DioException catch (e) { + return LoginOutcome(success: false, error: _errorMessage(e)); + } + } + + Future 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.'; + } +} diff --git a/mobile/lib/services/time_service.dart b/mobile/lib/services/time_service.dart new file mode 100644 index 0000000..2ba8277 --- /dev/null +++ b/mobile/lib/services/time_service.dart @@ -0,0 +1,38 @@ +import '../models/time_entry.dart'; +import 'api_client.dart'; + +class TimeService { + final _dio = ApiClient.instance.dio; + + Future> getToday() async { + final response = await _dio.get('/time/today'); + return (response.data as List) + .map((e) => TimeEntry.fromJson(e as Map)) + .toList(); + } + + Future 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); + } + + Future 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); + } + + Future breakStart() async { + final response = await _dio.post('/time/break-start'); + return TimeEntry.fromJson(response.data as Map); + } + + Future breakEnd() async { + final response = await _dio.post('/time/break-end'); + return TimeEntry.fromJson(response.data as Map); + } +} diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml new file mode 100644 index 0000000..777a6ec --- /dev/null +++ b/mobile/pubspec.yaml @@ -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