This commit is contained in:
2025-10-17 10:22:44 +02:00
parent af9d2d58e8
commit 23ecbe140c
17 changed files with 1656 additions and 276 deletions
+87 -29
View File
@@ -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<ApiClient>((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<dynamic> call(String action, Map<String, dynamic> 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<ApiClient>((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;
}
}
}
+112 -27
View File
@@ -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<ApiClient>((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, AuthState>(() => AuthController());
const AuthState({
required this.loggedIn,
this.email,
this.loading = false,
this.error,
});
class AuthController extends Notifier<AuthState> {
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<void> 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<void> 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<AuthState> {
final ApiClient api;
AuthController(this.api) : super(const AuthState.loggedOut());
/// Beim App-Start/Hot Restart Session prüfen
Future<void> 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<void> 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<void> register(String email, String password) async {
await _api.call('register', {'email': email, 'password': password});
}
Future<void> 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<void> 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<AuthController, AuthState>((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<void>((ref) async {
await ref.read(authProvider.notifier).checkSession();
});
+192 -87
View File
@@ -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<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final emailCtrl = TextEditingController();
final passCtrl = TextEditingController();
bool loading = false;
String? error;
final _formKey = GlobalKey<FormState>();
final _emailCtrl = TextEditingController();
final _passCtrl = TextEditingController();
bool _obscure = true;
bool _registerMode = false;
@override
void dispose() {
_emailCtrl.dispose();
_passCtrl.dispose();
super.dispose();
}
Future<void> _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'),
),
],
),
),
),
),
+193 -12
View File
@@ -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<HedgehogService>((ref) {
final api = ref.read(apiClientProvider);
return HedgehogService(api);
});
/// Igel-Liste: lädt nur wenn eingeloggt (vermeidet 401 vor Login)
final hedgehogsFutureProvider = FutureProvider<List<Hedgehog>>((ref) async {
final auth = ref.watch(authProvider);
if (!auth.loggedIn) return const <Hedgehog>[];
final svc = ref.read(hedgehogServiceProvider);
final result = await svc.list();
return List<Hedgehog>.from(result);
});
/// Messungen & Fotos per Igel-ID (AutoDispose, damit Speicher frei wird)
final measurementsFutureProvider =
AutoDisposeFutureProvider.family<List<Measurement>, int>((ref, hedgehogId) async {
final svc = ref.read(hedgehogServiceProvider);
final list = await svc.listMeasurements(hedgehogId);
return List<Measurement>.from(list);
});
final photosFutureProvider =
AutoDisposeFutureProvider.family<List<Photo>, int>((ref, hedgehogId) async {
final svc = ref.read(hedgehogServiceProvider);
final list = await svc.listPhotos(hedgehogId);
return List<Photo>.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<Hedgehog>> list() async {
final data = await _api.call('hedgehog.list', {});
return (data as List)
.map((e) => Hedgehog.fromJson(Map<String, dynamic>.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<List<Hedgehog>>.value(<Hedgehog>[]);
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<String, dynamic>.from(e as Map);
final id = _asInt(m['id']);
if (id != null) m['id'] = id;
return Hedgehog.fromJson(m);
}).toList();
return Future<List<Hedgehog>>.value(list);
}
Future<Hedgehog> create(String name) async {
final data = await _api.call('hedgehog.create', {'name': name});
Future<Hedgehog> 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<String, dynamic>.from(data));
}
}
final hedgehogServiceProvider = Provider((ref) => HedgehogService(ref));
Future<void> delete(int id) async {
await api.call('hedgehog.delete', {'id': id});
}
/* ---------- Measurements ---------- */
Future<List<Measurement>> 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<List<Measurement>>.value(<Measurement>[]);
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<String, dynamic>.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<List<Measurement>>.value(list);
}
Future<Measurement> 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<String, dynamic>.from(data));
}
Future<void> deleteMeasurement(int id) async {
await api.call('meas.delete', {'id': id});
}
/* ---------- Photos ---------- */
Future<List<Photo>> 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<List<Photo>>.value(<Photo>[]);
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<String, dynamic>.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<List<Photo>>.value(list);
}
Future<Photo> uploadPhoto({
required int hedgehogId,
required String filename,
required List<int> 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<String, dynamic>.from(data));
}
Future<void> deletePhoto(int id) async {
await api.call('photo.delete', {'id': id});
}
}
+111 -77
View File
@@ -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<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends ConsumerState<DashboardScreen> {
late Future<List<Hedgehog>> _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<void> _reload() async {
setState(() {
_future = ref.read(hedgehogServiceProvider).list();
});
Uri _exportAllUrl({DateTime? from, DateTime? to}) {
final params = <String, String>{'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<void> _createHedgehogDialog() async {
Future<void> _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<void> _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<void> _createHedgehogDialog(BuildContext context, WidgetRef ref) async {
final service = ref.read(hedgehogServiceProvider);
final nameCtrl = TextEditingController();
final created = await showDialog<bool>(
final ok = await showDialog<bool>(
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<DashboardScreen> {
],
),
body: RefreshIndicator(
onRefresh: _reload,
child: FutureBuilder<List<Hedgehog>>(
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 <Hedgehog>[];
onRefresh: () async {
ref.invalidate(hedgehogsFutureProvider);
await Future<void>.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<DashboardScreen> {
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<DashboardScreen> {
),
),
floatingActionButton: FloatingActionButton(
onPressed: _createHedgehogDialog,
onPressed: () => _createHedgehogDialog(context, ref),
tooltip: 'Neuer Igel',
child: const Icon(Icons.add),
),
+557 -4
View File
@@ -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<void> _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<void> _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<void> _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<bool>(
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<void> _deleteMeasurement(BuildContext context, WidgetRef ref, Measurement m) async {
final df = DateFormat('dd.MM.yyyy HH:mm');
final ok = await showDialog<bool>(
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<void> _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<void> _deletePhoto(BuildContext context, WidgetRef ref, Photo p) async {
final ok = await showDialog<bool>(
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<Measurement> 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<void>.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),
],
);
},
),
],
),
),
);
}
}
@@ -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<String, dynamic> json) => _$MeasurementFromJson(json);
factory Measurement.fromJson(Map<String, dynamic> json) =>
_$MeasurementFromJson(json);
}
+25
View File
@@ -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<String, dynamic> json) => _$PhotoFromJson(json);
Map<String, dynamic> toJson() => _$PhotoToJson(this);
}
+25
View File
@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'photo.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
Photo _$PhotoFromJson(Map<String, dynamic> 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<String, dynamic> _$PhotoToJson(Photo instance) => <String, dynamic>{
'id': instance.id,
'filename': instance.filename,
'mime': instance.mime,
'size': instance.size,
'createdAt': instance.createdAt,
'url': instance.url,
};
+63 -36
View File
@@ -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<GoRouter>((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<MyApp> createState() => _MyAppState();
}
class _MyAppState extends ConsumerState<MyApp> {
@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,
);
}
}
}
@@ -7,9 +7,13 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
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);
}
+1
View File
@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
@@ -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"))
}
+266 -2
View File
@@ -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:
+8
View File
@@ -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
@@ -7,8 +7,11 @@
#include "generated_plugin_registrant.h"
#include <file_selector_windows/file_selector_windows.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}
+1
View File
@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST