From e8de1d3625dbdc7947264a2ac8086442ae571c04 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Wed, 22 Apr 2026 07:29:21 +0200 Subject: [PATCH] feat: implement API client with JSON handling and error mapping; enhance routing and state management in app shell --- flutter/lib/core/api/api_client.dart | 125 ++++++++++++++++-- flutter/lib/core/api/api_error_mapper.dart | 21 +++ flutter/lib/core/api/api_exception.dart | 25 ++++ flutter/lib/core/router/app_router.dart | 55 ++++++-- flutter/lib/core/ui/app_shell.dart | 121 +++++++++++++++++ flutter/lib/core/ui/async_state_views.dart | 94 +++++++++++++ .../features/auth/data/auth_repository.dart | 42 ++++-- .../auth/presentation/login_screen.dart | 5 +- .../profile/presentation/profile_screen.dart | 33 +---- .../recipes/data/recipe_repository.dart | 29 ++-- .../recipes/presentation/recipes_screen.dart | 47 +++---- next_steps_flutter.md | 122 +++++++++++------ 12 files changed, 586 insertions(+), 133 deletions(-) create mode 100644 flutter/lib/core/api/api_error_mapper.dart create mode 100644 flutter/lib/core/api/api_exception.dart create mode 100644 flutter/lib/core/ui/app_shell.dart create mode 100644 flutter/lib/core/ui/async_state_views.dart diff --git a/flutter/lib/core/api/api_client.dart b/flutter/lib/core/api/api_client.dart index b712d1bb..3eb5ccdc 100644 --- a/flutter/lib/core/api/api_client.dart +++ b/flutter/lib/core/api/api_client.dart @@ -1,5 +1,9 @@ +import 'dart:convert'; + import 'package:http/http.dart' as http; +import 'api_exception.dart'; + /// Platform-neutral HTTP client. /// API base URL is injected at build time via --dart-define=API_BASE_URL. /// Default is same-origin '/api' to avoid mixed-content on HTTPS sites. @@ -19,18 +23,117 @@ class ApiClient { if (token != null) 'Authorization': 'Bearer $token', }; - Future get(String path, {String? token}) => - _client.get(Uri.parse('$baseUrl$path'), headers: _headers(token: token)); + Future getJson(String path, {String? token}) async { + final response = await _client.get( + Uri.parse('$baseUrl$path'), + headers: _headers(token: token), + ); + return _decodeOrNull(_guardResponse(response)); + } - Future post(String path, String body, {String? token}) => - _client.post(Uri.parse('$baseUrl$path'), - headers: _headers(token: token), body: body); + Future postJson( + String path, { + Object? body, + String? token, + }) async { + final response = await _client.post( + Uri.parse('$baseUrl$path'), + headers: _headers(token: token), + body: body == null ? null : jsonEncode(body), + ); + return _decodeOrNull(_guardResponse(response)); + } - Future put(String path, String body, {String? token}) => - _client.put(Uri.parse('$baseUrl$path'), - headers: _headers(token: token), body: body); + Future putJson( + String path, { + Object? body, + String? token, + }) async { + final response = await _client.put( + Uri.parse('$baseUrl$path'), + headers: _headers(token: token), + body: body == null ? null : jsonEncode(body), + ); + return _decodeOrNull(_guardResponse(response)); + } - Future delete(String path, {String? token}) => - _client.delete(Uri.parse('$baseUrl$path'), - headers: _headers(token: token)); + Future deleteJson(String path, {String? token}) async { + final response = await _client.delete( + Uri.parse('$baseUrl$path'), + headers: _headers(token: token), + ); + return _decodeOrNull(_guardResponse(response)); + } + + http.Response _guardResponse(http.Response response) { + if (response.statusCode < 400) { + return response; + } + + final parsedBody = _decodeOrNull(response); + final serverMessage = _extractMessage(parsedBody); + + if (response.statusCode == 401) { + throw ApiException( + type: ApiErrorType.unauthorized, + statusCode: 401, + message: serverMessage ?? 'Unauthorized', + ); + } + + if (response.statusCode == 403) { + throw ApiException( + type: ApiErrorType.forbidden, + statusCode: 403, + message: serverMessage ?? 'Forbidden', + ); + } + + if (response.statusCode >= 500) { + throw ApiException( + type: ApiErrorType.server, + statusCode: response.statusCode, + message: serverMessage ?? 'Server error', + ); + } + + throw ApiException( + type: ApiErrorType.unknown, + statusCode: response.statusCode, + message: serverMessage ?? 'Request failed', + ); + } + + dynamic _decodeOrNull(http.Response response) { + final body = response.body.trim(); + if (body.isEmpty) { + return null; + } + + try { + return jsonDecode(body); + } catch (_) { + return body; + } + } + + String? _extractMessage(dynamic parsedBody) { + if (parsedBody is Map) { + final message = parsedBody['message']; + if (message is String && message.trim().isNotEmpty) { + return message; + } + if (message is List && message.isNotEmpty) { + return message.first.toString(); + } + final error = parsedBody['error']; + if (error is String && error.trim().isNotEmpty) { + return error; + } + } + if (parsedBody is String && parsedBody.trim().isNotEmpty) { + return parsedBody; + } + return null; + } } diff --git a/flutter/lib/core/api/api_error_mapper.dart b/flutter/lib/core/api/api_error_mapper.dart new file mode 100644 index 00000000..8f2eeea1 --- /dev/null +++ b/flutter/lib/core/api/api_error_mapper.dart @@ -0,0 +1,21 @@ +import 'api_exception.dart'; + +String mapErrorToUserMessage(Object error) { + if (error is ApiException) { + switch (error.type) { + case ApiErrorType.unauthorized: + return 'Din session har gatt ut. Logga in igen.'; + case ApiErrorType.forbidden: + return 'Du saknar behorighet for denna funktion.'; + case ApiErrorType.server: + return 'Serverfel uppstod. Forsok igen om en stund.'; + case ApiErrorType.network: + return 'Natverksfel. Kontrollera anslutningen och forsok igen.'; + case ApiErrorType.unknown: + return error.message.isNotEmpty + ? error.message + : 'Ett ovantat fel uppstod.'; + } + } + return 'Ett ovantat fel uppstod.'; +} diff --git a/flutter/lib/core/api/api_exception.dart b/flutter/lib/core/api/api_exception.dart new file mode 100644 index 00000000..0f51336d --- /dev/null +++ b/flutter/lib/core/api/api_exception.dart @@ -0,0 +1,25 @@ +enum ApiErrorType { + unauthorized, + forbidden, + server, + network, + unknown, +} + +class ApiException implements Exception { + final ApiErrorType type; + final int? statusCode; + final String message; + + const ApiException({ + required this.type, + required this.message, + this.statusCode, + }); + + @override + String toString() { + final status = statusCode == null ? '' : ' (HTTP $statusCode)'; + return 'ApiException$type$status: $message'; + } +} diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index c07d85f7..b25d0cd0 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -1,24 +1,63 @@ +import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; + +import '../../core/ui/app_shell.dart'; +import '../../features/auth/data/auth_providers.dart'; import '../../features/recipes/presentation/recipes_screen.dart'; import '../../features/auth/presentation/login_screen.dart'; import '../../features/profile/presentation/profile_screen.dart'; final appRouterProvider = Provider((ref) { + final authState = ref.watch(authStateProvider); + return GoRouter( - initialLocation: '/login', + initialLocation: '/', + redirect: (context, state) { + final isLoading = authState.isLoading; + final token = authState.valueOrNull; + final isLoggedIn = token != null && token.isNotEmpty; + final isLoginRoute = state.matchedLocation == '/login'; + final isRootRoute = state.matchedLocation == '/'; + + if (isLoading) { + return null; + } + + if (isRootRoute) { + return isLoggedIn ? '/recipes' : '/login'; + } + + if (!isLoggedIn && !isLoginRoute) { + return '/login'; + } + + if (isLoggedIn && isLoginRoute) { + return '/recipes'; + } + + return null; + }, routes: [ + GoRoute(path: '/', builder: (context, state) => const SizedBox.shrink()), GoRoute( path: '/login', builder: (context, state) => const LoginScreen(), ), - GoRoute( - path: '/recipes', - builder: (context, state) => const RecipesScreen(), - ), - GoRoute( - path: '/profile', - builder: (context, state) => const ProfileScreen(), + ShellRoute( + builder: (context, state, child) { + return AppShell(location: state.uri.path, child: child); + }, + routes: [ + GoRoute( + path: '/recipes', + builder: (context, state) => const RecipesScreen(), + ), + GoRoute( + path: '/profile', + builder: (context, state) => const ProfileScreen(), + ), + ], ), ], ); diff --git a/flutter/lib/core/ui/app_shell.dart b/flutter/lib/core/ui/app_shell.dart new file mode 100644 index 00000000..8c3c73d0 --- /dev/null +++ b/flutter/lib/core/ui/app_shell.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../features/auth/data/auth_providers.dart'; + +class AppShell extends ConsumerWidget { + final String location; + final Widget child; + + const AppShell({ + super.key, + required this.location, + required this.child, + }); + + static const _destinations = [ + _AppDestination( + path: '/recipes', + title: 'Recept', + icon: Icons.restaurant_menu, + label: 'Recept', + ), + _AppDestination( + path: '/profile', + title: 'Profil', + icon: Icons.person, + label: 'Profil', + ), + ]; + + int _selectedIndex() { + final index = _destinations.indexWhere( + (destination) => location.startsWith(destination.path), + ); + return index < 0 ? 0 : index; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedIndex = _selectedIndex(); + final selectedDestination = _destinations[selectedIndex]; + final isWide = MediaQuery.of(context).size.width >= 900; + + Future logout() async { + await ref.read(authStateProvider.notifier).logout(); + if (context.mounted) { + context.go('/login'); + } + } + + void navigateTo(int index) { + final target = _destinations[index].path; + if (target != location && context.mounted) { + context.go(target); + } + } + + return Scaffold( + appBar: AppBar( + title: Text(selectedDestination.title), + actions: [ + IconButton( + tooltip: 'Logga ut', + icon: const Icon(Icons.logout), + onPressed: logout, + ), + ], + ), + body: isWide + ? Row( + children: [ + NavigationRail( + selectedIndex: selectedIndex, + onDestinationSelected: navigateTo, + labelType: NavigationRailLabelType.all, + destinations: _destinations + .map( + (destination) => NavigationRailDestination( + icon: Icon(destination.icon), + label: Text(destination.label), + ), + ) + .toList(), + ), + const VerticalDivider(width: 1), + Expanded(child: child), + ], + ) + : child, + bottomNavigationBar: isWide + ? null + : NavigationBar( + selectedIndex: selectedIndex, + onDestinationSelected: navigateTo, + destinations: _destinations + .map( + (destination) => NavigationDestination( + icon: Icon(destination.icon), + label: destination.label, + ), + ) + .toList(), + ), + ); + } +} + +class _AppDestination { + final String path; + final String title; + final IconData icon; + final String label; + + const _AppDestination({ + required this.path, + required this.title, + required this.icon, + required this.label, + }); +} diff --git a/flutter/lib/core/ui/async_state_views.dart b/flutter/lib/core/ui/async_state_views.dart new file mode 100644 index 00000000..aecf634a --- /dev/null +++ b/flutter/lib/core/ui/async_state_views.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; + +class LoadingStateView extends StatelessWidget { + final String? label; + + const LoadingStateView({super.key, this.label}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + if (label != null) ...[ + const SizedBox(height: 12), + Text(label!), + ], + ], + ), + ); + } +} + +class EmptyStateView extends StatelessWidget { + final String title; + final String? description; + + const EmptyStateView({ + super.key, + required this.title, + this.description, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(title, style: Theme.of(context).textTheme.titleMedium), + if (description != null) ...[ + const SizedBox(height: 8), + Text( + description!, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ], + ), + ), + ); + } +} + +class ErrorStateView extends StatelessWidget { + final String message; + final VoidCallback? onRetry; + + const ErrorStateView({ + super.key, + required this.message, + this.onRetry, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + message, + textAlign: TextAlign.center, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + if (onRetry != null) ...[ + const SizedBox(height: 12), + OutlinedButton( + onPressed: onRetry, + child: const Text('Forsok igen'), + ), + ], + ], + ), + ), + ); + } +} diff --git a/flutter/lib/features/auth/data/auth_repository.dart b/flutter/lib/features/auth/data/auth_repository.dart index 7860e46e..b970b4ac 100644 --- a/flutter/lib/features/auth/data/auth_repository.dart +++ b/flutter/lib/features/auth/data/auth_repository.dart @@ -1,5 +1,5 @@ -import 'dart:convert'; import '../../../core/api/api_client.dart'; +import '../../../core/api/api_exception.dart'; import '../../../core/platform/token_storage.dart'; class AuthRepository { @@ -9,17 +9,37 @@ class AuthRepository { AuthRepository(this._api, this._storage); Future login(String username, String password) async { - final response = await _api.post( - '/auth/login', - jsonEncode({'username': username, 'password': password}), - ); - if (response.statusCode != 200 && response.statusCode != 201) { - throw Exception('Login failed: ${response.statusCode}'); + try { + final data = await _api.postJson( + '/auth/login', + body: {'username': username, 'password': password}, + ); + + if (data is! Map) { + throw const ApiException( + type: ApiErrorType.unknown, + message: 'Ogiltigt svar fran servern.', + ); + } + + final token = data['accessToken']; + if (token is! String || token.isEmpty) { + throw const ApiException( + type: ApiErrorType.unknown, + message: 'Svar saknar access token.', + ); + } + + await _storage.saveToken(token); + return token; + } on ApiException { + rethrow; + } catch (_) { + throw const ApiException( + type: ApiErrorType.network, + message: 'Kunde inte na servern.', + ); } - final data = jsonDecode(response.body) as Map; - final token = data['accessToken'] as String; - await _storage.saveToken(token); - return token; } Future logout() => _storage.deleteToken(); diff --git a/flutter/lib/features/auth/presentation/login_screen.dart b/flutter/lib/features/auth/presentation/login_screen.dart index 0a908de9..7c28b2bf 100644 --- a/flutter/lib/features/auth/presentation/login_screen.dart +++ b/flutter/lib/features/auth/presentation/login_screen.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; + +import '../../../core/api/api_error_mapper.dart'; import '../data/auth_providers.dart'; class LoginScreen extends ConsumerStatefulWidget { @@ -72,7 +74,8 @@ class _LoginScreenState extends ConsumerState { Padding( padding: const EdgeInsets.only(top: 12), child: Text( - 'Inloggning misslyckades', + mapErrorToUserMessage(authState.error!), + textAlign: TextAlign.center, style: TextStyle(color: Theme.of(context).colorScheme.error), ), ), diff --git a/flutter/lib/features/profile/presentation/profile_screen.dart b/flutter/lib/features/profile/presentation/profile_screen.dart index 7d27ecb5..eda4b38d 100644 --- a/flutter/lib/features/profile/presentation/profile_screen.dart +++ b/flutter/lib/features/profile/presentation/profile_screen.dart @@ -1,37 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import '../../auth/data/auth_providers.dart'; -class ProfileScreen extends ConsumerWidget { +class ProfileScreen extends StatelessWidget { const ProfileScreen({super.key}); - Future _logout(BuildContext context, WidgetRef ref) async { - await ref.read(authStateProvider.notifier).logout(); - if (context.mounted) context.go('/login'); - } - @override - Widget build(BuildContext context, WidgetRef ref) { - return Scaffold( - appBar: AppBar( - title: const Text('Profil'), - actions: [ - IconButton( - tooltip: 'Recept', - icon: const Icon(Icons.restaurant_menu), - onPressed: () => context.go('/recipes'), - ), - IconButton( - tooltip: 'Logga ut', - icon: const Icon(Icons.logout), - onPressed: () => _logout(context, ref), - ), - ], - ), - body: const Center( - child: Text('Profilsida (grundversion)'), - ), + Widget build(BuildContext context) { + return const Center( + child: Text('Profilsida (grundversion)'), ); } } diff --git a/flutter/lib/features/recipes/data/recipe_repository.dart b/flutter/lib/features/recipes/data/recipe_repository.dart index b6ef59b7..7e8d5885 100644 --- a/flutter/lib/features/recipes/data/recipe_repository.dart +++ b/flutter/lib/features/recipes/data/recipe_repository.dart @@ -1,5 +1,5 @@ -import 'dart:convert'; import '../../../core/api/api_client.dart'; +import '../../../core/api/api_exception.dart'; import '../domain/recipe.dart'; class RecipeRepository { @@ -8,13 +8,26 @@ class RecipeRepository { RecipeRepository(this._api); Future> fetchRecipes({String? token}) async { - final response = await _api.get('/recipes', token: token); - if (response.statusCode != 200) { - throw Exception('Failed to load recipes: ${response.statusCode}'); + try { + final data = await _api.getJson('/recipes', token: token); + + if (data is! List) { + throw const ApiException( + type: ApiErrorType.unknown, + message: 'Ogiltigt svar fran servern.', + ); + } + + return data + .map((e) => Recipe.fromJson(e as Map)) + .toList(); + } on ApiException { + rethrow; + } catch (_) { + throw const ApiException( + type: ApiErrorType.network, + message: 'Kunde inte hamta recept.', + ); } - final List data = jsonDecode(response.body) as List; - return data - .map((e) => Recipe.fromJson(e as Map)) - .toList(); } } diff --git a/flutter/lib/features/recipes/presentation/recipes_screen.dart b/flutter/lib/features/recipes/presentation/recipes_screen.dart index e10562de..dbcf4f0b 100644 --- a/flutter/lib/features/recipes/presentation/recipes_screen.dart +++ b/flutter/lib/features/recipes/presentation/recipes_screen.dart @@ -1,40 +1,31 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import '../../auth/data/auth_providers.dart'; + +import '../../../core/api/api_error_mapper.dart'; +import '../../../core/ui/async_state_views.dart'; import '../data/recipe_providers.dart'; class RecipesScreen extends ConsumerWidget { const RecipesScreen({super.key}); - Future _logout(BuildContext context, WidgetRef ref) async { - await ref.read(authStateProvider.notifier).logout(); - if (context.mounted) context.go('/login'); - } - @override Widget build(BuildContext context, WidgetRef ref) { final recipesAsync = ref.watch(recipesProvider); - return Scaffold( - appBar: AppBar( - title: const Text('Recept'), - actions: [ - IconButton( - tooltip: 'Profil', - icon: const Icon(Icons.person), - onPressed: () => context.go('/profile'), - ), - IconButton( - tooltip: 'Logga ut', - icon: const Icon(Icons.logout), - onPressed: () => _logout(context, ref), - ), - ], + return recipesAsync.when( + loading: () => const LoadingStateView(label: 'Laddar recept...'), + error: (error, _) => ErrorStateView( + message: mapErrorToUserMessage(error), + onRetry: () => ref.invalidate(recipesProvider), ), - body: recipesAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center(child: Text('Fel: $e')), - data: (recipes) => ListView.builder( + data: (recipes) { + if (recipes.isEmpty) { + return const EmptyStateView( + title: 'Inga recept hittades', + description: 'Lagg till ett recept for att komma igang.', + ); + } + + return ListView.builder( itemCount: recipes.length, itemBuilder: (context, index) { final recipe = recipes[index]; @@ -52,8 +43,8 @@ class RecipesScreen extends ConsumerWidget { : null, ); }, - ), - ), + ); + }, ); } } diff --git a/next_steps_flutter.md b/next_steps_flutter.md index ba33b148..2f449ff4 100644 --- a/next_steps_flutter.md +++ b/next_steps_flutter.md @@ -1,53 +1,101 @@ -# Next Steps: Flutter-migrering (Alternativ 3) +# Next Steps: Flutter-migrering Relaterade dokument: - [flutter/README.md](flutter/README.md) - [teknisk_beskrivning_flutter.md](teknisk_beskrivning_flutter.md) +- [TEKNISK_BESKRIVNING.md](TEKNISK_BESKRIVNING.md) -## 1. Definiera malbild och scope forst -- Bestam vilka floden som maste vara parity i v1: login, receptlista, receptdetalj, inventarie, matsedel, profil. -- Satt tydlig definition of done per feature: UI, navigation, API, felhantering, loading states, auth-skydd. +## Icke-forhandlingsbara ramar -## 2. Bygg gemensam app-shell innan fler sidor -- Stabil routingstruktur. -- Gemensam navigation (top/bottom/nav drawer). -- Auth-gate och logout-flode. -- Standardkomponenter for tomma lagen, felmeddelanden och laddning. +1. Inget ska tas bort eller andras i `recipe-api`. +2. Inget ska tas bort eller andras i `recipe-frontend`. +3. Migreringen sker i Flutter-sparet som separat klient mot befintliga API-kontrakt. +4. Next-frontend kor parallellt tills Flutter har verifierad parity i karnfloden. -Det gor att varje ny sida gar snabbare och mer konsekvent. +## Malbild for v1 (funktionell parity) -## 3. Migrera i denna ordning (hogst affarsvarde forst) -- Auth: login, session, logout. -- Recept: lista -> detalj -> skapa/andra. -- Inventarie: lista -> skapa -> uppdatera -> forbrukning. -- Import-funktionen -- Matsedel. -- Profil/admin. +For v1 ska dessa floden vara stabila i Flutter: +- Auth: login, session, logout, auth-guard. +- Recept: lista, detalj, skapa, uppdatera, ta bort. +- Inventarie: lista, skapa, uppdatera, konsumera, historik. +- Matplan: veckovy, val av recept per dag, portionsjustering, inkopslista, inventariejamforelse. +- Import: quick-import + parse-markdown-flode. +- Profil: basfunktioner for anvandarprofil. -Ordningen minimerar blockerare eftersom recept + auth ofta anvands av allt annat. +Adminfloden migreras efter att ovanstaende ar verifierat. -## 4. Kor API-contract first per feature -- Verifiera exakt request/response mot backend innan UI putsas. -- Mappa datamodeller robust (null, typskillnader, fallback-falt). -- Lagg in central felhantering for 401/403/500 tidigt. +## Prioriterad plan (ordning) -## 5. Satt enhetliga kvalitetsgrindar per migrerad feature -- Manuell testlista for kritiska scenarier. -- En liten smoke-test efter varje deploy. -- Kontroll att web + mobilanpassning fungerar (utan web-specifika genvagar). +## Fas 1 - Stabil app-shell (forst) +- Bygg tydlig auth-gate i router. +- Centralisera API-fel (401/403/500) i ett gemensamt lager. +- Skapa gemensamma UI-komponenter for loading, empty, error. +- Satt en enhetlig navigationsstruktur (web forst, mobil-redo). -## 6. Leverera i korta iterationer -- 1 feature at gangen till testmiljo. -- Demo + snabb feedback. -- Justera innan nasta feature. +Motivering: minskar regressionsrisk och gor resten av migreringen snabbare. -Det minskar risken att du bygger fel saker for langt. +## Fas 2 - Auth parity +- Hardna loginflodet (tydliga felmeddelanden, retries dar relevant). +- Verifiera token-livscykel (reload/hard refresh/logout). +- Implementera automatisk hantering av utgangen token (401 -> logout -> login). -## 7. Avveckla gamla frontend stegvis -- Kor dubbel drift under en period. -- Peka en testdoman mot Flutter tills parity ar bekräftad. -- Flytta trafik gradvis nar karnfloden ar stabila. +## Fas 3 - Recept parity +- Lista -> detalj -> skapa -> redigera -> ta bort. +- Knyt ihop med parse-markdown-proxy. +- Behall backend som enda plats for matchning, validering och affarslogik. + +## Fas 4 - Inventarie parity +- Lista med filter/sortering. +- Skapa och uppdatera inventariepost. +- Konsumtion och konsumtionshistorik. + +## Fas 5 - Matplan parity +- Veckovy med receptval per dag. +- Portionsjustering per dag. +- Inkoplista och inventariejamforelse. + +## Fas 6 - Import parity +- URL/PDF/bild via befintliga endpoints. +- Tydlig hantering av langkorande anrop och fel. + +## Fas 7 - Profil/admin parity +- Profil for alla anvandare. +- Role-aware navigation och skydd for adminytor. +- Adminfunktioner migreras sist for att minimera risk i karnfloden. + +## Contract-first per feature + +For varje feature: +1. Verifiera request/response mot befintligt backendkontrakt. +2. Mappa modeller robust (null-safe, fallback-falt, typskillnader). +3. Kontrollera felbanor innan UI-polish. + +Ingen backendforandring goras for att "fa Flutter att funka". + +## Kvalitetsgrind (Definition of Done) + +En feature ar klar nar allt nedan ar uppfyllt: +1. API-floden fungerar for bade success och fel. +2. Auth/rollskydd fungerar (inklusive 401/403). +3. Loading/empty/error ar konsekvent hanterat. +4. Navigation in/ut ur feature fungerar utan specialfall. +5. Smoke-test i testmiljo ar godkant. + +## Leveransmodell + +- Leverera 1 feature i taget till testdoman. +- Demo och snabb feedback innan nasta feature. +- Hall dubbel drift (Next + Flutter) tills karnfloden ar stabila. +- Flytta trafik gradvis nar parity ar verifierad. + +## Nasta konkreta sprint (rekommenderad) + +1. Fas 1: app-shell hardening. +2. Fas 2: auth parity helt klar. +3. Fas 3 (del 1): receptdetalj + skapa recept. +4. Smoke-test pa testdomanen och avstamning. ## Tumregel -- Sikta pa funktionell parity forst, pixel-perfect parity senare. -- Det ger snabbare nytta och farre regressionsproblem. + +- Sikta pa funktionell parity forst. +- Pixel-perfect parity tas efter stabil funktion.