diff --git a/lib/core/api_client.dart b/lib/core/api_client.dart index ea78e24..da0e4ca 100644 --- a/lib/core/api_client.dart +++ b/lib/core/api_client.dart @@ -1,42 +1,100 @@ -import 'package:dio/dio.dart'; +import 'dart:convert'; import 'package:flutter/foundation.dart' show kIsWeb; -import 'package:dio/browser.dart' as dio_browser; -import 'package:dio_cookie_manager/dio_cookie_manager.dart'; -import 'package:cookie_jar/cookie_jar.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:dio/dio.dart'; +import 'package:dio/browser.dart' as dio_web; + +final apiClientProvider = Provider((ref) => ApiClient()); class ApiClient { final Dio dio; - static const baseUrl = 'https://api.windesign.at/hedgehog.php'; - - ApiClient._(this.dio); - - factory ApiClient() { - final dio = Dio(BaseOptions(baseUrl: baseUrl)); + String? _sid; // Session-ID aus dem Server (Header-Token) + ApiClient() + : dio = Dio( + BaseOptions( + baseUrl: 'https://api.windesign.at/hedgehog.php', + connectTimeout: const Duration(seconds: 20), + receiveTimeout: const Duration(seconds: 30), + headers: const { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + validateStatus: (_) => true, // wir werten selbst aus + ), + ) { if (kIsWeb) { - // 🔑 Ohne das hier sendet der Browser keine Cookies! - dio.httpClientAdapter = dio_browser.BrowserHttpClientAdapter() - ..withCredentials = true; - dio.options.extra['withCredentials'] = true; - } else { - final jar = PersistCookieJar(); - dio.interceptors.add(CookieManager(jar)); + final adapter = dio_web.BrowserHttpClientAdapter(); + adapter.withCredentials = true; // falls Cookies doch erlaubt sind + dio.httpClientAdapter = adapter; } - // Interceptor für {ok:false,error:'...'} beibehalten … - return ApiClient._(dio); + dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) { + // Header-SID immer mitsenden (unabhängig von Cookies) + if (_sid != null && _sid!.isNotEmpty) { + options.headers['X-Session'] = _sid!; + options.headers['Authorization'] = 'Bearer $_sid'; + } + options.extra['withCredentials'] = true; // Web: fetch credentials + handler.next(options); + }, + ), + ); + } + + void setSessionId(String? sid) { + _sid = sid; } Future call(String action, Map payload) async { - final res = await dio.post( - '', - data: {'action': action, ...payload}, - options: Options(contentType: Headers.jsonContentType), // <— wichtig - ); - return res.data['data']; - } -} + try { + final req = {'action': action, ...payload}; + final resp = await dio.post('', data: req); -/// 👉 Zentraler Provider für den ApiClient (hier definiert!) -final apiClientProvider = Provider((ref) => ApiClient()); + // Debug + // ignore: avoid_print + print('[API] $action status=${resp.statusCode} type=${resp.data.runtimeType}'); + + dynamic data = resp.data; + if (data is String) { + try { data = jsonDecode(data); } catch (_) { + throw StateError('API "$action": Antwort ist kein JSON.'); + } + } + + // Statuscodes selbst prüfen + final code = resp.statusCode ?? 0; + if (code >= 400) { + final msg = (data is Map && data['error'] != null) + ? data['error'].toString() + : 'HTTP $code'; + throw StateError(msg); + } + + // Wrapper {ok,data} respektieren + if (data is Map && data.containsKey('ok')) { + // 👉 SID aus login/session mitnehmen + if (action == 'login' || action == 'session') { + final sid = (data['data']?['sid'] ?? data['sid'])?.toString(); + if (sid != null && sid.isNotEmpty) setSessionId(sid); + } + if (data['ok'] == true) return data; + throw StateError(data['error']?.toString() ?? 'Unbekannter API-Fehler'); + } + + return data; + } on DioException catch (e) { + final msg = e.response?.data?.toString() ?? e.message ?? 'Netzwerkfehler'; + // ignore: avoid_print + print('[API] $action dioError: $msg'); + throw StateError(msg); + } catch (e) { + // ignore: avoid_print + print('[API] $action error: $e'); + rethrow; + } + } + +} diff --git a/lib/features/auth/auth_controller.dart b/lib/features/auth/auth_controller.dart index 907cd5b..0c541af 100644 --- a/lib/features/auth/auth_controller.dart +++ b/lib/features/auth/auth_controller.dart @@ -1,45 +1,130 @@ +// lib/features/auth/auth_controller.dart import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/api_client.dart'; -final apiClientProvider = Provider((ref) => ApiClient()); - class AuthState { - final bool isAuthenticated; + final bool loggedIn; final String? email; - const AuthState({required this.isAuthenticated, this.email}); -} + final bool loading; + final String? error; -final authProvider = - NotifierProvider(() => AuthController()); + const AuthState({ + required this.loggedIn, + this.email, + this.loading = false, + this.error, + }); -class AuthController extends Notifier { - late final ApiClient _api = ref.read(apiClientProvider); + const AuthState.loggedOut() + : this(loggedIn: false, email: null, loading: false, error: null); - @override - AuthState build() => const AuthState(isAuthenticated: false); - - Future login(String email, String password) async { - await _api.call('login', {'email': email, 'password': password}); - state = AuthState(isAuthenticated: true, email: email); + AuthState copyWith({ + bool? loggedIn, + String? email, + bool? loading, + String? error, + }) { + return AuthState( + loggedIn: loggedIn ?? this.loggedIn, + email: email ?? this.email, + loading: loading ?? this.loading, + error: error, + ); } - Future logout() async { - await _api.call('logout', {}); - state = const AuthState(isAuthenticated: false); + @override + String toString() => + 'AuthState(loggedIn=$loggedIn, email=$email, loading=$loading, error=$error)'; +} + +class AuthController extends StateNotifier { + final ApiClient api; + + AuthController(this.api) : super(const AuthState.loggedOut()); + + /// Beim App-Start/Hot Restart Session prüfen + Future checkSession() async { + try { + state = state.copyWith(loading: true, error: null); + final res = await api.call('session', {}); + final data = (res is Map && res['data'] != null) + ? res['data'] as Map + : (res as Map); + final logged = data['loggedIn'] == true; + final email = data['email']?.toString(); + final sid = data['sid']?.toString(); + if (sid != null && sid.isNotEmpty) { + api.setSessionId(sid); // 👉 wichtig + } + state = AuthState(loggedIn: logged, email: email, loading: false); + } catch (e) { + state = const AuthState.loggedOut(); + } + } + + Future login(String email, String password) async { + state = state.copyWith(loading: true, error: null); + try { + final res = + await api.call('login', {'email': email, 'password': password}); + final data = (res is Map && res['data'] != null) + ? res['data'] as Map + : (res as Map); + if (data['loggedIn'] == true) { + final sid = data['sid']?.toString(); + if (sid != null && sid.isNotEmpty) { + api.setSessionId(sid); // 👉 wichtig + } + state = AuthState( + loggedIn: true, email: data['email']?.toString(), loading: false); + } else { + state = state.copyWith(loading: false, error: 'Login fehlgeschlagen'); + } + } catch (e) { + state = state.copyWith(loading: false, error: e.toString()); + } } Future register(String email, String password) async { - await _api.call('register', {'email': email, 'password': password}); - } - - Future checkSession() async { + state = state.copyWith(loading: true, error: null); try { - final data = await _api.call('session', {}); - if (data['loggedIn'] == true) { - state = AuthState(isAuthenticated: true, email: data['email']); + final res = + await api.call('register', {'email': email, 'password': password}); + final data = (res is Map && res['data'] != null) + ? res['data'] as Map + : (res as Map); + if (data['registered'] == true) { + // Optional: Direkt einloggen oder Erfolg nur anzeigen + await login(email, password); + } else { + state = state.copyWith( + loading: false, error: 'Registrierung fehlgeschlagen'); } - } catch (_) { - state = const AuthState(isAuthenticated: false); + } catch (e) { + state = state.copyWith(loading: false, error: e.toString()); } } + + Future logout() async { + try { + await api.call('logout', {}); + } catch (_) { + // ignore: Fehlermeldung egal – wir setzen lokalen Zustand zurück + } + state = const AuthState.loggedOut(); + } } + +/// StateNotifierProvider: globaler Auth-Zustand +final authProvider = StateNotifierProvider((ref) { + final api = ref.read(apiClientProvider); + final controller = AuthController(api); + // Beim ersten Read direkt Session prüfen + controller.checkSession(); + return controller; +}); + +/// Praktischer „Trigger“-Provider, falls du irgendwo explizit neu prüfen willst +final authSessionCheckProvider = FutureProvider((ref) async { + await ref.read(authProvider.notifier).checkSession(); +}); diff --git a/lib/features/auth/ui/login_screen.dart b/lib/features/auth/ui/login_screen.dart index a082602..19e4ee3 100644 --- a/lib/features/auth/ui/login_screen.dart +++ b/lib/features/auth/ui/login_screen.dart @@ -1,112 +1,217 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:dio/dio.dart'; // <— wichtig für DioException + import '../auth_controller.dart'; -import 'package:go_router/go_router.dart'; class LoginScreen extends ConsumerStatefulWidget { const LoginScreen({super.key}); + @override ConsumerState createState() => _LoginScreenState(); } class _LoginScreenState extends ConsumerState { - final emailCtrl = TextEditingController(); - final passCtrl = TextEditingController(); - bool loading = false; - String? error; + final _formKey = GlobalKey(); + final _emailCtrl = TextEditingController(); + final _passCtrl = TextEditingController(); + bool _obscure = true; + bool _registerMode = false; + + @override + void dispose() { + _emailCtrl.dispose(); + _passCtrl.dispose(); + super.dispose(); + } Future _submit() async { - setState(() => loading = true); - try { - await ref - .read(authProvider.notifier) - .login(emailCtrl.text, passCtrl.text); - if (mounted) context.go('/'); - } catch (e) { - setState(() => error = e.toString()); - } finally { - setState(() => loading = false); + if (!_formKey.currentState!.validate()) return; + + final email = _emailCtrl.text.trim(); + final pass = _passCtrl.text; + + // Referenz VOR await holen + final authNotifier = ref.read(authProvider.notifier); + + if (_registerMode) { + await authNotifier.register(email, pass); + } else { + await authNotifier.login(email, pass); } + + // Nach await: zuerst prüfen, ob der Screen noch lebt + if (!mounted) return; + + final authState = ref.read(authProvider); + if (authState.error != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(authState.error!)), + ); + } + // Navigation macht das GoRouter-redirect automatisch, nichts weiter nötig. } @override Widget build(BuildContext context) { + final auth = ref.watch(authProvider); + final loading = auth.loading; + return Scaffold( body: Center( child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: Card( - margin: const EdgeInsets.all(16), - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text('Igel Login', - style: - TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), - const SizedBox(height: 16), - TextField( - controller: emailCtrl, - decoration: const InputDecoration(labelText: 'E-Mail')), - TextField( - controller: passCtrl, - decoration: const InputDecoration(labelText: 'Passwort'), - obscureText: true), - const SizedBox(height: 16), - if (error != null) - Text(error!, style: const TextStyle(color: Colors.red)), - const SizedBox(height: 8), - ElevatedButton( - onPressed: loading ? null : _submit, - child: loading - ? const CircularProgressIndicator() - : const Text('Login'), + constraints: const BoxConstraints(maxWidth: 460), + child: Padding( + padding: const EdgeInsets.all(24), + child: Material( + elevation: 2, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: FocusTraversalGroup( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Icon(Icons.pets, + size: 48, + color: Theme.of(context).colorScheme.primary), + const SizedBox(height: 12), + Text( + _registerMode ? 'Neu registrieren' : 'Anmelden', + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 16), + + // Fehlermeldung aus dem State (optional) + if (auth.error != null && auth.error!.isNotEmpty) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.red.withOpacity(0.25)), + ), + child: Text(auth.error!, + style: const TextStyle(color: Colors.red)), + ), + const SizedBox(height: 12), + ], + + TextFormField( + controller: _emailCtrl, + autofocus: true, + enabled: !loading, + decoration: const InputDecoration( + labelText: 'E-Mail', + hintText: 'name@beispiel.at', + prefixIcon: Icon(Icons.mail), + ), + keyboardType: TextInputType.emailAddress, + autofillHints: const [ + AutofillHints.username, + AutofillHints.email + ], + validator: (v) { + final t = (v ?? '').trim(); + if (t.isEmpty) return 'Bitte E-Mail eingeben'; + if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(t)) + return 'Ungültige E-Mail'; + return null; + }, + onFieldSubmitted: (_) => _submit(), + ), + const SizedBox(height: 12), + + TextFormField( + controller: _passCtrl, + enabled: !loading, + obscureText: _obscure, + decoration: InputDecoration( + labelText: 'Passwort', + prefixIcon: const Icon(Icons.lock), + suffixIcon: IconButton( + tooltip: _obscure + ? 'Passwort anzeigen' + : 'Passwort verbergen', + icon: Icon(_obscure + ? Icons.visibility + : Icons.visibility_off), + onPressed: () => + setState(() => _obscure = !_obscure), + ), + ), + autofillHints: const [AutofillHints.password], + inputFormatters: [ + FilteringTextInputFormatter.singleLineFormatter + ], + onFieldSubmitted: (_) => _submit(), + validator: (v) { + if ((v ?? '').isEmpty) + return 'Bitte Passwort eingeben'; + if (_registerMode && (v!.length < 6)) + return 'Mindestens 6 Zeichen'; + return null; + }, + ), + const SizedBox(height: 20), + + SizedBox( + width: double.infinity, + height: 48, + child: FilledButton( + onPressed: loading ? null : _submit, + child: loading + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2)) + : Text(_registerMode + ? 'Registrieren' + : 'Anmelden'), + ), + ), + const SizedBox(height: 10), + + TextButton.icon( + onPressed: loading + ? null + : () { + setState( + () => _registerMode = !_registerMode); + }, + icon: const Icon(Icons.person_add_alt), + label: Text(_registerMode + ? 'Schon ein Konto? Jetzt anmelden' + : 'Neu registrieren'), + ), + + const SizedBox(height: 6), + if (!loading) + TextButton( + onPressed: () { + // Demo/Testzugang automatisch füllen (optional) + _emailCtrl.text = 'test@example.com'; + _passCtrl.text = 'test1234'; + _submit(); + }, + child: const Text('Mit Testkonto fortfahren'), + ), + + const SizedBox(height: 8), + ], + ), ), - TextButton( - onPressed: () async { - final email = emailCtrl.text.trim(); - final pw = passCtrl.text; - if (email.isEmpty || - !email.contains('@') || - pw.length < 6) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Bitte gültige E-Mail und Passwort (min. 6 Zeichen) eingeben.')), - ); - return; - } - try { - await ref - .read(authProvider.notifier) - .register(email, pw); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Registrierung erfolgreich – bitte einloggen.')), - ); - } on DioException catch (e) { - final msg = e.response?.data is Map && - (e.response!.data['error'] != null) - ? e.response!.data['error'].toString() - : e.message ?? 'Unbekannter Fehler'; - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Fehler: $msg')), - ); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Fehler: $e')), - ); - } - }, - child: const Text('Neu registrieren'), - ), - ], + ), ), ), ), diff --git a/lib/features/hedgehogs/data/hedgehog_service.dart b/lib/features/hedgehogs/data/hedgehog_service.dart index 0b0660f..4386d09 100644 --- a/lib/features/hedgehogs/data/hedgehog_service.dart +++ b/lib/features/hedgehogs/data/hedgehog_service.dart @@ -1,24 +1,205 @@ +// lib/features/hedgehogs/data/hedgehog_service.dart import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:dio/dio.dart'; +import 'package:http_parser/http_parser.dart'; + import '../../../core/api_client.dart'; -import '../models/hedgehog.dart'; +import '../../auth/auth_controller.dart'; +import '../../hedgehogs/models/hedgehog.dart'; +import '../../measurements/models/measurement.dart'; +import '../../photos/models/photo.dart'; + +/// Low-level Service +final hedgehogServiceProvider = Provider((ref) { + final api = ref.read(apiClientProvider); + return HedgehogService(api); +}); + +/// Igel-Liste: lädt nur wenn eingeloggt (vermeidet 401 vor Login) +final hedgehogsFutureProvider = FutureProvider>((ref) async { + final auth = ref.watch(authProvider); + if (!auth.loggedIn) return const []; + + final svc = ref.read(hedgehogServiceProvider); + final result = await svc.list(); + return List.from(result); +}); + +/// Messungen & Fotos per Igel-ID (AutoDispose, damit Speicher frei wird) +final measurementsFutureProvider = + AutoDisposeFutureProvider.family, int>((ref, hedgehogId) async { + final svc = ref.read(hedgehogServiceProvider); + final list = await svc.listMeasurements(hedgehogId); + return List.from(list); +}); + +final photosFutureProvider = + AutoDisposeFutureProvider.family, int>((ref, hedgehogId) async { + final svc = ref.read(hedgehogServiceProvider); + final list = await svc.listPhotos(hedgehogId); + return List.from(list); +}); class HedgehogService { - final Ref ref; - HedgehogService(this.ref); + final ApiClient api; + HedgehogService(this.api); - ApiClient get _api => ref.read(apiClientProvider); + /* ---------- Hedgehogs ---------- */ Future> list() async { - final data = await _api.call('hedgehog.list', {}); - return (data as List) - .map((e) => Hedgehog.fromJson(Map.from(e))) - .toList(); + final res = await api.call('hedgehog.list', {}); + final raw = (res is Map && res['data'] is List) ? res['data'] : (res as List?); + if (raw == null) return Future>.value([]); + + int? _asInt(dynamic v) { + if (v == null) return null; + if (v is int) return v; + if (v is num) return v.toInt(); + if (v is String) return int.tryParse(v); + return null; + } + + final iterable = List.from(raw); + final list = iterable.map((e) { + final m = Map.from(e as Map); + final id = _asInt(m['id']); + if (id != null) m['id'] = id; + return Hedgehog.fromJson(m); + }).toList(); + + return Future>.value(list); } - Future create(String name) async { - final data = await _api.call('hedgehog.create', {'name': name}); + Future create(String name, {String? species, String? notes}) async { + final res = await api.call('hedgehog.create', { + 'name': name, + if (species != null) 'species': species, + if (notes != null) 'notes': notes, + }); + final data = res is Map && res['data'] != null ? res['data'] : res; return Hedgehog.fromJson(Map.from(data)); } -} -final hedgehogServiceProvider = Provider((ref) => HedgehogService(ref)); + Future delete(int id) async { + await api.call('hedgehog.delete', {'id': id}); + } + + /* ---------- Measurements ---------- */ + + Future> listMeasurements(int hedgehogId) async { + final res = await api.call('meas.list', {'hedgehogId': hedgehogId}); + final raw = (res is Map && res['data'] is List) ? res['data'] : (res as List?); + if (raw == null) return Future>.value([]); + + int? _asInt(dynamic v) { + if (v == null) return null; + if (v is int) return v; + if (v is num) return v.toInt(); + if (v is String) return int.tryParse(v); + return null; + } + + double? _asDouble(dynamic v) { + if (v == null) return null; + if (v is double) return v; + if (v is int) return v.toDouble(); + if (v is num) return v.toDouble(); + if (v is String) return double.tryParse(v); + return null; + } + + final iterable = List.from(raw); + final list = iterable.map((e) { + final m = Map.from(e as Map); + final id = _asInt(m['id']); + if (id != null) m['id'] = id; + final hid = _asInt(m['hedgehogId']); + if (hid != null) m['hedgehogId'] = hid; + final wg = _asInt(m['weightGrams']); + if (wg != null) m['weightGrams'] = wg; + final lm = _asInt(m['lengthMm']); + if (lm != null) m['lengthMm'] = lm; + final tc = _asDouble(m['temperatureC']); + if (tc != null) m['temperatureC'] = tc; + return Measurement.fromJson(m); + }).toList(); + + return Future>.value(list); + } + + Future addMeasurement({ + required int hedgehogId, + required DateTime measuredAt, + int? weightGrams, + int? lengthMm, + double? temperatureC, + String? note, + }) async { + final res = await api.call('meas.create', { + 'hedgehogId': hedgehogId, + 'measuredAt': measuredAt.toIso8601String(), + if (weightGrams != null) 'weightGrams': weightGrams, + if (lengthMm != null) 'lengthMm': lengthMm, + if (temperatureC != null) 'temperatureC': temperatureC, + if (note != null) 'note': note, + }); + final data = res is Map && res['data'] != null ? res['data'] : res; + return Measurement.fromJson(Map.from(data)); + } + + Future deleteMeasurement(int id) async { + await api.call('meas.delete', {'id': id}); + } + + /* ---------- Photos ---------- */ + + Future> listPhotos(int hedgehogId) async { + final res = await api.call('photo.list', {'hedgehogId': hedgehogId}); + final raw = (res is Map && res['data'] is List) ? res['data'] : (res as List?); + if (raw == null) return Future>.value([]); + + int _asInt(dynamic v) { + if (v is int) return v; + if (v is num) return v.toInt(); + if (v is String) return int.tryParse(v) ?? 0; + return 0; + } + + final iterable = List.from(raw); + final list = iterable.map((e) { + final m = Map.from(e as Map); + m['id'] = _asInt(m['id']); + m['size'] = _asInt(m['size']); + if (m.containsKey('created_at') && !m.containsKey('createdAt')) { + m['createdAt'] = m['created_at']; + } + return Photo.fromJson(m); + }).toList(); + + return Future>.value(list); + } + + Future uploadPhoto({ + required int hedgehogId, + required String filename, + required List bytes, + String? contentType, // z. B. image/jpeg + }) async { + final form = FormData.fromMap({ + 'action': 'photo.upload', + 'hedgehogId': hedgehogId, + 'file': MultipartFile.fromBytes( + bytes, + filename: filename, + contentType: contentType != null ? MediaType.parse(contentType) : null, + ), + }); + final resp = await api.dio.post('', data: form); + final data = resp.data['data'] ?? resp.data; + return Photo.fromJson(Map.from(data)); + } + + Future deletePhoto(int id) async { + await api.call('photo.delete', {'id': id}); + } +} diff --git a/lib/features/hedgehogs/ui/dashboard_screen.dart b/lib/features/hedgehogs/ui/dashboard_screen.dart index aec38c0..193f101 100644 --- a/lib/features/hedgehogs/ui/dashboard_screen.dart +++ b/lib/features/hedgehogs/ui/dashboard_screen.dart @@ -2,93 +2,134 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../../auth/auth_controller.dart'; import '../data/hedgehog_service.dart'; import '../models/hedgehog.dart'; -class DashboardScreen extends ConsumerStatefulWidget { +class DashboardScreen extends ConsumerWidget { const DashboardScreen({super.key}); - @override - ConsumerState createState() => _DashboardScreenState(); -} - -class _DashboardScreenState extends ConsumerState { - late Future> _future; - - @override - void initState() { - super.initState(); - _future = ref.read(hedgehogServiceProvider).list(); + String _fmtMySql(DateTime dt, {bool endOfDay = false}) { + final d = DateTime( + dt.year, + dt.month, + dt.day, + endOfDay ? 23 : 0, + endOfDay ? 59 : 0, + endOfDay ? 59 : 0, + ); + String two(int v) => v.toString().padLeft(2, '0'); + return '${d.year}-${two(d.month)}-${two(d.day)} ${two(d.hour)}:${two(d.minute)}:${two(d.second)}'; } - Future _reload() async { - setState(() { - _future = ref.read(hedgehogServiceProvider).list(); - }); + Uri _exportAllUrl({DateTime? from, DateTime? to}) { + final params = {'action': 'meas.export_all_csv'}; + if (from != null) params['from'] = _fmtMySql(from, endOfDay: false); + if (to != null) params['to'] = _fmtMySql(to, endOfDay: true); + return Uri.https('api.windesign.at', '/hedgehog.php', params); } - Future _createHedgehogDialog() async { + Future _exportAll(BuildContext context) async { + final ok = await launchUrl(_exportAllUrl(), mode: LaunchMode.externalApplication); + if (!ok && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Konnte Export-URL nicht öffnen')), + ); + } + } + + Future _exportAllFiltered(BuildContext context) async { + DateTime? from; + DateTime? to; + + from = await showDatePicker( + context: context, + firstDate: DateTime(2000), + lastDate: DateTime.now().add(const Duration(days: 365)), + initialDate: DateTime.now().subtract(const Duration(days: 7)), + helpText: 'Von-Datum wählen', + ); + if (!context.mounted) return; + + to = await showDatePicker( + context: context, + firstDate: DateTime(2000), + lastDate: DateTime.now().add(const Duration(days: 365)), + initialDate: DateTime.now(), + helpText: 'Bis-Datum wählen', + ); + if (!context.mounted) return; + + if (from == null && to == null) return; + + final ok = await launchUrl(_exportAllUrl(from: from, to: to), mode: LaunchMode.externalApplication); + if (!ok && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Konnte Export-URL nicht öffnen')), + ); + } + } + + Future _createHedgehogDialog(BuildContext context, WidgetRef ref) async { final service = ref.read(hedgehogServiceProvider); final nameCtrl = TextEditingController(); - final created = await showDialog( + final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Neuer Igel'), content: TextField( controller: nameCtrl, - decoration: const InputDecoration( - labelText: 'Name', - hintText: 'z. B. Frida', - ), + decoration: const InputDecoration(labelText: 'Name', hintText: 'z. B. Frida'), textInputAction: TextInputAction.done, onSubmitted: (_) => Navigator.of(ctx).pop(true), ), actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(false), - child: const Text('Abbrechen'), - ), - FilledButton( - onPressed: () => Navigator.of(ctx).pop(true), - child: const Text('Speichern'), - ), + TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Abbrechen')), + FilledButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Speichern')), ], ), ); - if (created == true && nameCtrl.text.trim().isNotEmpty) { + if (ok == true && nameCtrl.text.trim().isNotEmpty) { try { await service.create(nameCtrl.text.trim()); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Igel angelegt.')), - ); - await _reload(); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Igel angelegt.'))); + ref.invalidate(hedgehogsFutureProvider); // Liste neu laden } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Fehler beim Anlegen: $e')), - ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Fehler beim Anlegen: $e'))); } } } @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final auth = ref.watch(authProvider); + final asyncHogs = ref.watch(hedgehogsFutureProvider); return Scaffold( appBar: AppBar( title: Text('Meine Igel${auth.email != null ? " (${auth.email})" : ""}'), actions: [ + IconButton( + tooltip: 'Alle CSV exportieren', + onPressed: () => _exportAll(context), + icon: const Icon(Icons.file_download), + ), + IconButton( + tooltip: 'Export (Datum filtern)', + onPressed: () => _exportAllFiltered(context), + icon: const Icon(Icons.calendar_today), + ), IconButton( tooltip: 'Abmelden', onPressed: () async { await ref.read(authProvider.notifier).logout(); - if (!mounted) return; + if (!context.mounted) return; context.go('/login'); }, icon: const Icon(Icons.logout), @@ -96,39 +137,32 @@ class _DashboardScreenState extends ConsumerState { ], ), body: RefreshIndicator( - onRefresh: _reload, - child: FutureBuilder>( - future: _future, - builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done) { - return const Center(child: CircularProgressIndicator()); - } - if (snapshot.hasError) { - return ListView( - children: [ - const SizedBox(height: 48), - Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'Fehler beim Laden:\n${snapshot.error}', - textAlign: TextAlign.center, - ), - ), - ), - const SizedBox(height: 12), - Center( - child: FilledButton.icon( - onPressed: _reload, - icon: const Icon(Icons.refresh), - label: const Text('Erneut versuchen'), - ), - ), - ], - ); - } - - final list = snapshot.data ?? const []; + onRefresh: () async { + ref.invalidate(hedgehogsFutureProvider); + await Future.delayed(const Duration(milliseconds: 150)); + }, + child: asyncHogs.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, _) => ListView( + children: [ + const SizedBox(height: 48), + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text('Fehler beim Laden:\n$err', textAlign: TextAlign.center), + ), + ), + const SizedBox(height: 12), + Center( + child: FilledButton.icon( + onPressed: () => ref.invalidate(hedgehogsFutureProvider), + icon: const Icon(Icons.refresh), + label: const Text('Erneut versuchen'), + ), + ), + ], + ), + data: (list) { if (list.isEmpty) { return ListView( children: [ @@ -144,7 +178,7 @@ class _DashboardScreenState extends ConsumerState { const SizedBox(height: 8), Center( child: TextButton.icon( - onPressed: _createHedgehogDialog, + onPressed: () => _createHedgehogDialog(context, ref), icon: const Icon(Icons.add), label: const Text('Jetzt ersten Igel anlegen'), ), @@ -172,7 +206,7 @@ class _DashboardScreenState extends ConsumerState { ), ), floatingActionButton: FloatingActionButton( - onPressed: _createHedgehogDialog, + onPressed: () => _createHedgehogDialog(context, ref), tooltip: 'Neuer Igel', child: const Icon(Icons.add), ), diff --git a/lib/features/hedgehogs/ui/detail_screen.dart b/lib/features/hedgehogs/ui/detail_screen.dart index 2678c93..fee2e18 100644 --- a/lib/features/hedgehogs/ui/detail_screen.dart +++ b/lib/features/hedgehogs/ui/detail_screen.dart @@ -1,14 +1,567 @@ +// lib/features/hedgehogs/ui/detail_screen.dart import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:dio/dio.dart'; -class HedgehogDetailScreen extends StatelessWidget { +import '../data/hedgehog_service.dart'; +import '../../../core/api_client.dart'; +import '../../measurements/models/measurement.dart'; +import '../../photos/models/photo.dart'; + +class HedgehogDetailScreen extends ConsumerWidget { final int id; const HedgehogDetailScreen({super.key, required this.id}); + /* ---------------- CSV Export/Import ---------------- */ + + Uri _exportCsvUrl(int hedgehogId) => + Uri.parse('https://api.windesign.at/hedgehog.php?action=meas.export_csv&hedgehogId=$hedgehogId'); + + Future _doExport(BuildContext context) async { + final ok = await launchUrl(_exportCsvUrl(id), mode: LaunchMode.externalApplication); + if (!ok && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Konnte Export-URL nicht öffnen')), + ); + } + } + + Future _doImport(BuildContext context, WidgetRef ref) async { + final picked = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['csv'], + withData: true, + ); + if (picked == null || picked.files.isEmpty) return; + + final file = picked.files.single; + if (file.bytes == null) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Datei konnte nicht gelesen werden')), + ); + return; + } + + try { + final dio = ref.read(apiClientProvider).dio; + final form = FormData.fromMap({ + 'action': 'meas.import_csv', + 'hedgehogId': id, + 'file': MultipartFile.fromBytes(file.bytes!, filename: file.name), + }); + + final resp = await dio.post('', data: form); + if (!context.mounted) return; + final data = resp.data['data'] ?? {}; + final imported = data['imported'] ?? 0; + final updated = data['updated'] ?? 0; + final skipped = data['skipped'] ?? 0; + final errors = data['errors'] ?? 0; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Import: $imported importiert, $updated aktualisiert, $skipped überspr., $errors Fehler')), + ); + + // Nach Import: Messungen neu laden + ref.invalidate(measurementsFutureProvider(id)); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Import-Fehler: $e')), + ); + } + } + + /* ---------------- Messungen anlegen/löschen ---------------- */ + + Future _addMeasurementDialog(BuildContext context, WidgetRef ref) async { + final svc = ref.read(hedgehogServiceProvider); + final df = DateFormat('dd.MM.yyyy HH:mm'); + + DateTime when = DateTime.now(); + final weightCtrl = TextEditingController(); + final lengthCtrl = TextEditingController(); + final tempCtrl = TextEditingController(); + final noteCtrl = TextEditingController(); + + final ok = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setDialog) => AlertDialog( + title: const Text('Messung hinzufügen'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.event), + title: Text(df.format(when)), + trailing: TextButton( + onPressed: () async { + final d = await showDatePicker( + context: context, + firstDate: DateTime(2000), + lastDate: DateTime.now().add(const Duration(days: 1)), + initialDate: when, + ); + if (d == null) return; + final t = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(when), + ); + if (t == null) return; + when = DateTime(d.year, d.month, d.day, t.hour, t.minute); + setDialog(() {}); + }, + child: const Text('ändern'), + ), + ), + const SizedBox(height: 8), + TextField( + controller: weightCtrl, + decoration: const InputDecoration(labelText: 'Gewicht (g)', hintText: 'z. B. 450'), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 8), + TextField( + controller: lengthCtrl, + decoration: const InputDecoration(labelText: 'Länge (mm)', hintText: 'z. B. 180'), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 8), + TextField( + controller: tempCtrl, + decoration: const InputDecoration(labelText: 'Temperatur (°C)', hintText: 'z. B. 36.5'), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + ), + const SizedBox(height: 8), + TextField( + controller: noteCtrl, + decoration: const InputDecoration(labelText: 'Notiz'), + maxLines: 3, + ), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Abbrechen')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Speichern')), + ], + ), + ), + ); + + if (ok == true) { + try { + int? w = int.tryParse(weightCtrl.text.trim()); + int? l = int.tryParse(lengthCtrl.text.trim()); + double? t = double.tryParse(tempCtrl.text.trim()); + await svc.addMeasurement( + hedgehogId: id, + measuredAt: when, + weightGrams: w, + lengthMm: l, + temperatureC: t, + note: noteCtrl.text.trim().isEmpty ? null : noteCtrl.text.trim(), + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Messung gespeichert.'))); + ref.invalidate(measurementsFutureProvider(id)); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Fehler: $e'))); + } + } + } + + Future _deleteMeasurement(BuildContext context, WidgetRef ref, Measurement m) async { + final df = DateFormat('dd.MM.yyyy HH:mm'); + final ok = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Messung löschen?'), + content: Text('${df.format(m.measuredAt)}\nGewicht: ${m.weightGrams ?? '-'} g'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Abbrechen')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Löschen')), + ], + ), + ); + if (ok == true) { + try { + await ref.read(hedgehogServiceProvider).deleteMeasurement(m.id); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Gelöscht.'))); + ref.invalidate(measurementsFutureProvider(id)); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Fehler: $e'))); + } + } + } + + /* ---------------- Fotos Upload/Löschen ---------------- */ + + Future _uploadPhoto(BuildContext context, WidgetRef ref) async { + final picked = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['jpg', 'jpeg', 'png', 'webp'], + withData: true, + ); + if (picked == null || picked.files.isEmpty) return; + + final f = picked.files.single; + final bytes = f.bytes; + if (bytes == null) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Datei konnte nicht gelesen werden'))); + return; + } + + try { + await ref.read(hedgehogServiceProvider).uploadPhoto( + hedgehogId: id, + filename: f.name, + bytes: bytes, + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Foto hochgeladen.'))); + ref.invalidate(photosFutureProvider(id)); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Upload-Fehler: $e'))); + } + } + + Future _deletePhoto(BuildContext context, WidgetRef ref, Photo p) async { + final ok = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Foto löschen?'), + content: Text('Datei: ${p.filename}'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Abbrechen')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Löschen')), + ], + ), + ); + if (ok == true) { + try { + await ref.read(hedgehogServiceProvider).deletePhoto(p.id); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Foto gelöscht.'))); + ref.invalidate(photosFutureProvider(id)); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Fehler: $e'))); + } + } + } + + /* ---------------- Chart ---------------- */ + + Widget _buildChart(List data) { + final df = DateFormat('dd.MM.'); + final raw = data.where((m) => m.weightGrams != null).toList(); + if (raw.isEmpty) return const Center(child: Text('Keine Gewichts-Daten für den Chart.')); + + final points = raw + .map((m) => FlSpot( + m.measuredAt.millisecondsSinceEpoch.toDouble(), + m.weightGrams!.toDouble(), + )) + .toList() + ..sort((a, b) => a.x.compareTo(b.x)); + + final minX = points.first.x; + final maxX = points.last.x; + final rangeX = (maxX - minX).abs(); + final desiredXTicks = 5; + final intervalX = rangeX == 0 ? 1.0 : rangeX / desiredXTicks; + + double minY = points.map((e) => e.y).reduce((a, b) => a < b ? a : b); + double maxY = points.map((e) => e.y).reduce((a, b) => a > b ? a : b); + if (minY == maxY) { + minY -= 10; + maxY += 10; + } else { + final pad = (maxY - minY) * 0.1; + minY -= pad; + maxY += pad; + } + + String _fmtDate(double x) => df.format(DateTime.fromMillisecondsSinceEpoch(x.toInt())); + + return InteractiveViewer( + minScale: 0.9, + maxScale: 4, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: LineChart( + LineChartData( + minX: minX, maxX: maxX, minY: minY, maxY: maxY, + clipData: const FlClipData.all(), + lineTouchData: const LineTouchData(enabled: false), + titlesData: FlTitlesData( + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, reservedSize: 36, interval: intervalX, + getTitlesWidget: (value, meta) => Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(_fmtDate(value), style: const TextStyle(fontSize: 10)), + ), + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, reservedSize: 44, + interval: ((maxY - minY) / 4).clamp(1, 1000).toDouble(), + getTitlesWidget: (value, meta) => Text('${value.toInt()} g', style: const TextStyle(fontSize: 10)), + ), + ), + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + ), + gridData: const FlGridData(show: true, drawVerticalLine: true, drawHorizontalLine: true), + borderData: FlBorderData(show: true), + lineBarsData: [ + LineChartBarData(isCurved: true, spots: points, dotData: const FlDotData(show: false), barWidth: 3), + ], + ), + ), + ), + ); + } + @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final df = DateFormat('dd.MM.yyyy HH:mm'); + final measAsync = ref.watch(measurementsFutureProvider(id)); + final photosAsync = ref.watch(photosFutureProvider(id)); + return Scaffold( - appBar: AppBar(title: Text('Igel #$id')), - body: const Center(child: Text('Detailansicht (Chart, Messungen etc.) kommt später')), + appBar: AppBar( + title: Text('Igel #$id – Messungen & Fotos'), + actions: [ + IconButton( + tooltip: 'CSV exportieren', + onPressed: () => _doExport(context), + icon: const Icon(Icons.download), + ), + IconButton( + tooltip: 'CSV importieren', + onPressed: () => _doImport(context, ref), + icon: const Icon(Icons.upload), + ), + IconButton( + tooltip: 'Foto hochladen', + onPressed: () => _uploadPhoto(context, ref), + icon: const Icon(Icons.photo_camera), + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _addMeasurementDialog(context, ref), + child: const Icon(Icons.add), + ), + body: RefreshIndicator( + onRefresh: () async { + ref.invalidate(measurementsFutureProvider(id)); + ref.invalidate(photosFutureProvider(id)); + await Future.delayed(const Duration(milliseconds: 200)); + }, + child: ListView( + children: [ + // Messungen + measAsync.when( + loading: () => const SizedBox( + height: 260, + child: Center(child: CircularProgressIndicator()), + ), + error: (e, _) => Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Text('Fehler (Messungen): $e'), + const SizedBox(height: 8), + FilledButton.icon( + onPressed: () => ref.invalidate(measurementsFutureProvider(id)), + icon: const Icon(Icons.refresh), + label: const Text('Erneut laden'), + ), + ], + ), + ), + data: (meas) { + final sorted = meas.toList()..sort((a, b) => a.measuredAt.compareTo(b.measuredAt)); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(height: 260, child: _buildChart(sorted)), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text('Messungen (${sorted.length})', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + ), + ...sorted.reversed.map((m) => Dismissible( + key: ValueKey(m.id), + background: Container( + color: Colors.red.withOpacity(0.1), + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 16), + child: const Icon(Icons.delete, color: Colors.red), + ), + direction: DismissDirection.endToStart, + confirmDismiss: (_) async { + await _deleteMeasurement(context, ref, m); + return false; // wir invalidieren selbst + }, + child: ListTile( + leading: const Icon(Icons.stacked_line_chart), + title: Text(df.format(m.measuredAt)), + subtitle: Text([ + if (m.weightGrams != null) 'Gewicht: ${m.weightGrams} g', + if (m.lengthMm != null) 'Länge: ${m.lengthMm} mm', + if (m.temperatureC != null) 'Temp: ${m.temperatureC!.toStringAsFixed(1)} °C', + if (m.note != null && m.note!.isNotEmpty) 'Notiz: ${m.note}', + ].join(' ')), + ), + )), + const SizedBox(height: 16), + ], + ); + }, + ), + + const Divider(height: 1), + + // Fotos + photosAsync.when( + loading: () => const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ), + error: (e, _) => Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Text('Fehler (Fotos): $e'), + const SizedBox(height: 8), + FilledButton.icon( + onPressed: () => ref.invalidate(photosFutureProvider(id)), + icon: const Icon(Icons.refresh), + label: const Text('Erneut laden'), + ), + ], + ), + ), + data: (photos) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Row( + children: [ + const Icon(Icons.photo_library), + const SizedBox(width: 8), + Text('Fotos (${photos.length})', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + const Spacer(), + TextButton.icon( + onPressed: () => _uploadPhoto(context, ref), + icon: const Icon(Icons.add_a_photo), + label: const Text('Foto hinzufügen'), + ), + ], + ), + ), + if (photos.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text('Noch keine Fotos hochgeladen.'), + ) + else + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: LayoutBuilder( + builder: (ctx, c) { + final w = c.maxWidth; + final cross = w > 900 ? 5 : w > 700 ? 4 : w > 500 ? 3 : 2; + return GridView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: photos.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: cross, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + ), + itemBuilder: (ctx, i) { + final p = photos[i]; + return InkWell( + onTap: () async { + final uri = Uri.parse(p.url); + await launchUrl(uri, mode: LaunchMode.externalApplication); + }, + onLongPress: () => _deletePhoto(context, ref, p), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Stack( + fit: StackFit.expand, + children: [ + Image.network( + p.url, + fit: BoxFit.cover, + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return const Center(child: CircularProgressIndicator()); + }, + errorBuilder: (_, __, ___) => Container( + color: Colors.grey.shade200, + alignment: Alignment.center, + child: const Icon(Icons.broken_image), + ), + ), + Positioned( + right: 4, + top: 4, + child: Container( + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(6), + ), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + child: Text( + (p.mime.split('/').last).toUpperCase(), + style: const TextStyle(color: Colors.white, fontSize: 10), + ), + ), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ), + const SizedBox(height: 24), + ], + ); + }, + ), + ], + ), + ), ); } } diff --git a/lib/features/measurements/models/measurement.dart b/lib/features/measurements/models/measurement.dart index 8dcd7e8..c74c68b 100644 --- a/lib/features/measurements/models/measurement.dart +++ b/lib/features/measurements/models/measurement.dart @@ -1,4 +1,3 @@ -// lib/features/measurements/models/measurement.dart import 'package:freezed_annotation/freezed_annotation.dart'; part 'measurement.freezed.dart'; part 'measurement.g.dart'; @@ -15,5 +14,6 @@ class Measurement with _$Measurement { String? note, }) = _Measurement; - factory Measurement.fromJson(Map json) => _$MeasurementFromJson(json); + factory Measurement.fromJson(Map json) => + _$MeasurementFromJson(json); } diff --git a/lib/features/photos/models/photo.dart b/lib/features/photos/models/photo.dart new file mode 100644 index 0000000..5db4a9d --- /dev/null +++ b/lib/features/photos/models/photo.dart @@ -0,0 +1,25 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'photo.g.dart'; + +@JsonSerializable() +class Photo { + final int id; + final String filename; + final String mime; + final int size; + final String? createdAt; + final String url; + + Photo({ + required this.id, + required this.filename, + required this.mime, + required this.size, + required this.url, + this.createdAt, + }); + + factory Photo.fromJson(Map json) => _$PhotoFromJson(json); + Map toJson() => _$PhotoToJson(this); +} diff --git a/lib/features/photos/models/photo.g.dart b/lib/features/photos/models/photo.g.dart new file mode 100644 index 0000000..7c633f6 --- /dev/null +++ b/lib/features/photos/models/photo.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'photo.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Photo _$PhotoFromJson(Map json) => Photo( + id: (json['id'] as num).toInt(), + filename: json['filename'] as String, + mime: json['mime'] as String, + size: (json['size'] as num).toInt(), + url: json['url'] as String, + createdAt: json['createdAt'] as String?, + ); + +Map _$PhotoToJson(Photo instance) => { + 'id': instance.id, + 'filename': instance.filename, + 'mime': instance.mime, + 'size': instance.size, + 'createdAt': instance.createdAt, + 'url': instance.url, + }; diff --git a/lib/main.dart b/lib/main.dart index 9d2befb..0331c99 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,55 +1,82 @@ +// lib/main.dart import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; + +import 'features/auth/auth_controller.dart'; import 'features/auth/ui/login_screen.dart'; import 'features/hedgehogs/ui/dashboard_screen.dart'; import 'features/hedgehogs/ui/detail_screen.dart'; -import 'features/auth/auth_controller.dart'; void main() { + WidgetsFlutterBinding.ensureInitialized(); runApp(const ProviderScope(child: MyApp())); } -class MyApp extends ConsumerStatefulWidget { +final routerProvider = Provider((ref) { + final auth = ref.watch(authProvider); + + return GoRouter( + // Wenn du Deep Links möchtest, nimm hier einfach eine feste initialLocation + // initialLocation: '/', + routes: [ + GoRoute( + path: '/login', + name: 'login', + builder: (context, state) => const LoginScreen(), + ), + GoRoute( + path: '/', + name: 'dashboard', + builder: (context, state) => const DashboardScreen(), + routes: [ + GoRoute( + path: 'hedgehogs/:id', + name: 'hedgehogDetail', + builder: (context, state) { + final idStr = state.pathParameters['id'] ?? '0'; + final id = int.tryParse(idStr) ?? 0; + return HedgehogDetailScreen(id: id); + }, + ), + ], + ), + ], + redirect: (context, state) { + final loggingIn = state.matchedLocation == '/login'; + final loggedIn = auth.loggedIn; + + // Während Session-Check: nicht springen + if (auth.loading) return null; + + if (!loggedIn) { + // Nicht eingeloggt → außer Login immer auf /login + return loggingIn ? null : '/login'; + } + + // Eingeloggt → weg von /login auf Dashboard + if (loggingIn) return '/'; + + // sonst keine Umleitung + return null; + }, + ); +}); + +class MyApp extends ConsumerWidget { const MyApp({super.key}); - @override - ConsumerState createState() => _MyAppState(); -} - -class _MyAppState extends ConsumerState { - @override - void initState() { - super.initState(); - // nach dem ersten Frame, damit ref verfügbar ist - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(authProvider.notifier).checkSession(); - }); - } @override - Widget build(BuildContext context) { - final router = GoRouter( - initialLocation: '/login', - routes: [ - GoRoute(path: '/login', builder: (c, s) => const LoginScreen()), - GoRoute(path: '/', builder: (c, s) => const DashboardScreen()), - GoRoute( - path: '/hedgehogs/:id', - builder: (c, s) => HedgehogDetailScreen(id: int.parse(s.pathParameters['id']!)), - ), - ], - redirect: (ctx, state) { - final loggedIn = ref.read(authProvider).isAuthenticated; - if (!loggedIn && state.matchedLocation != '/login') return '/login'; - if (loggedIn && state.matchedLocation == '/login') return '/'; - return null; - }, - ); + Widget build(BuildContext context, WidgetRef ref) { + final router = ref.watch(routerProvider); return MaterialApp.router( - title: 'Igel App', - theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.teal), + title: 'Hedgehog', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal), + useMaterial3: true, + ), routerConfig: router, ); } -} \ No newline at end of file +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 64a0ece..7299b5c 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,9 +7,13 @@ #include "generated_plugin_registrant.h" #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2db3c22..786ff5c 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index c0be2ee..4397c25 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,10 +7,16 @@ import Foundation import file_picker import file_selector_macos +import path_provider_foundation import shared_preferences_foundation +import sqflite_darwin +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 5de7fb2..9da0e8d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -22,6 +22,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.7.0" + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" args: dependency: transitive description: @@ -110,6 +118,30 @@ packages: url: "https://pub.dev" source: hosted version: "8.12.0" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" characters: dependency: transitive description: @@ -126,6 +158,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" code_builder: dependency: transitive description: @@ -291,6 +331,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" flutter_lints: dependency: "direct main" description: @@ -369,7 +417,7 @@ packages: source: hosted version: "2.3.2" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 @@ -385,13 +433,21 @@ packages: source: hosted version: "3.2.2" http_parser: - dependency: transitive + dependency: "direct main" description: name: http_parser sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" url: "https://pub.dev" source: hosted version: "4.0.2" + image: + dependency: "direct main" + description: + name: image + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + url: "https://pub.dev" + source: hosted + version: "4.5.4" image_picker: dependency: "direct main" description: @@ -456,6 +512,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.1+1" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" io: dependency: transitive description: @@ -544,6 +608,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" package_config: dependency: transitive description: @@ -560,6 +632,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "4adf4fd5423ec60a29506c76581bc05854c55e3a0b72d35bb28d661c9686edf2" + url: "https://pub.dev" + source: hosted + version: "2.2.15" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" path_provider_linux: dependency: transitive description: @@ -584,6 +680,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + photo_view: + dependency: "direct main" + description: + name: photo_view + sha256: "1fc3d970a91295fbd1364296575f854c9863f225505c28c46e0a03e48960c75e" + url: "https://pub.dev" + source: hosted + version: "0.15.0" platform: dependency: transitive description: @@ -608,6 +720,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" pub_semver: dependency: transitive description: @@ -632,6 +752,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" shared_preferences: dependency: "direct main" description: @@ -733,6 +861,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709" + url: "https://pub.dev" + source: hosted + version: "2.5.4+6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "22adfd9a2c7d634041e96d6241e6e1c8138ca6817018afc5d443fef91dcefa9c" + url: "https://pub.dev" + source: hosted + version: "2.4.1+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" stack_trace: dependency: transitive description: @@ -773,6 +949,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" + url: "https://pub.dev" + source: hosted + version: "3.3.0+3" term_glyph: dependency: transitive description: @@ -813,6 +997,78 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193" + url: "https://pub.dev" + source: hosted + version: "6.3.14" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" + url: "https://pub.dev" + source: hosted + version: "2.3.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + uuid: + dependency: transitive + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" vector_math: dependency: transitive description: @@ -869,6 +1125,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8b7b59e..4c1299d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,14 @@ dependencies: file_picker: ^8.0.3 shared_preferences: ^2.3.2 flutter_lints: ^5.0.0 + intl: ^0.19.0 + url_launcher: ^6.3.0 + http_parser: ^4.0.2 + image: ^4.2.0 + http: ^1.2.2 + cached_network_image: ^3.4.0 + photo_view: ^0.15.0 + dev_dependencies: build_runner: ^2.4.11 freezed: ^2.5.7 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 77ab7a0..043a96f 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,8 +7,11 @@ #include "generated_plugin_registrant.h" #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index a423a02..a95e267 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST