Images
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user