feat: implement API client with JSON handling and error mapping; enhance routing and state management in app shell
This commit is contained in:
@@ -1,5 +1,9 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import 'api_exception.dart';
|
||||||
|
|
||||||
/// Platform-neutral HTTP client.
|
/// Platform-neutral HTTP client.
|
||||||
/// API base URL is injected at build time via --dart-define=API_BASE_URL.
|
/// 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.
|
/// Default is same-origin '/api' to avoid mixed-content on HTTPS sites.
|
||||||
@@ -19,18 +23,117 @@ class ApiClient {
|
|||||||
if (token != null) 'Authorization': 'Bearer $token',
|
if (token != null) 'Authorization': 'Bearer $token',
|
||||||
};
|
};
|
||||||
|
|
||||||
Future<http.Response> get(String path, {String? token}) =>
|
Future<dynamic> getJson(String path, {String? token}) async {
|
||||||
_client.get(Uri.parse('$baseUrl$path'), headers: _headers(token: token));
|
final response = await _client.get(
|
||||||
|
Uri.parse('$baseUrl$path'),
|
||||||
|
headers: _headers(token: token),
|
||||||
|
);
|
||||||
|
return _decodeOrNull(_guardResponse(response));
|
||||||
|
}
|
||||||
|
|
||||||
Future<http.Response> post(String path, String body, {String? token}) =>
|
Future<dynamic> postJson(
|
||||||
_client.post(Uri.parse('$baseUrl$path'),
|
String path, {
|
||||||
headers: _headers(token: token), body: body);
|
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<http.Response> put(String path, String body, {String? token}) =>
|
Future<dynamic> putJson(
|
||||||
_client.put(Uri.parse('$baseUrl$path'),
|
String path, {
|
||||||
headers: _headers(token: token), body: body);
|
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<http.Response> delete(String path, {String? token}) =>
|
Future<dynamic> deleteJson(String path, {String? token}) async {
|
||||||
_client.delete(Uri.parse('$baseUrl$path'),
|
final response = await _client.delete(
|
||||||
headers: _headers(token: token));
|
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<String, dynamic>) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.';
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,54 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.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/recipes/presentation/recipes_screen.dart';
|
||||||
import '../../features/auth/presentation/login_screen.dart';
|
import '../../features/auth/presentation/login_screen.dart';
|
||||||
import '../../features/profile/presentation/profile_screen.dart';
|
import '../../features/profile/presentation/profile_screen.dart';
|
||||||
|
|
||||||
final appRouterProvider = Provider<GoRouter>((ref) {
|
final appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
|
final authState = ref.watch(authStateProvider);
|
||||||
|
|
||||||
return GoRouter(
|
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: [
|
routes: [
|
||||||
|
GoRoute(path: '/', builder: (context, state) => const SizedBox.shrink()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/login',
|
path: '/login',
|
||||||
builder: (context, state) => const LoginScreen(),
|
builder: (context, state) => const LoginScreen(),
|
||||||
),
|
),
|
||||||
|
ShellRoute(
|
||||||
|
builder: (context, state, child) {
|
||||||
|
return AppShell(location: state.uri.path, child: child);
|
||||||
|
},
|
||||||
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/recipes',
|
path: '/recipes',
|
||||||
builder: (context, state) => const RecipesScreen(),
|
builder: (context, state) => const RecipesScreen(),
|
||||||
@@ -21,5 +58,7 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||||||
builder: (context, state) => const ProfileScreen(),
|
builder: (context, state) => const ProfileScreen(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<void> 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import '../../../core/api/api_client.dart';
|
import '../../../core/api/api_client.dart';
|
||||||
|
import '../../../core/api/api_exception.dart';
|
||||||
import '../../../core/platform/token_storage.dart';
|
import '../../../core/platform/token_storage.dart';
|
||||||
|
|
||||||
class AuthRepository {
|
class AuthRepository {
|
||||||
@@ -9,17 +9,37 @@ class AuthRepository {
|
|||||||
AuthRepository(this._api, this._storage);
|
AuthRepository(this._api, this._storage);
|
||||||
|
|
||||||
Future<String> login(String username, String password) async {
|
Future<String> login(String username, String password) async {
|
||||||
final response = await _api.post(
|
try {
|
||||||
|
final data = await _api.postJson(
|
||||||
'/auth/login',
|
'/auth/login',
|
||||||
jsonEncode({'username': username, 'password': password}),
|
body: {'username': username, 'password': password},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data is! Map<String, dynamic>) {
|
||||||
|
throw const ApiException(
|
||||||
|
type: ApiErrorType.unknown,
|
||||||
|
message: 'Ogiltigt svar fran servern.',
|
||||||
);
|
);
|
||||||
if (response.statusCode != 200 && response.statusCode != 201) {
|
|
||||||
throw Exception('Login failed: ${response.statusCode}');
|
|
||||||
}
|
}
|
||||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
|
||||||
final token = data['accessToken'] as String;
|
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);
|
await _storage.saveToken(token);
|
||||||
return token;
|
return token;
|
||||||
|
} on ApiException {
|
||||||
|
rethrow;
|
||||||
|
} catch (_) {
|
||||||
|
throw const ApiException(
|
||||||
|
type: ApiErrorType.network,
|
||||||
|
message: 'Kunde inte na servern.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> logout() => _storage.deleteToken();
|
Future<void> logout() => _storage.deleteToken();
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../../core/api/api_error_mapper.dart';
|
||||||
import '../data/auth_providers.dart';
|
import '../data/auth_providers.dart';
|
||||||
|
|
||||||
class LoginScreen extends ConsumerStatefulWidget {
|
class LoginScreen extends ConsumerStatefulWidget {
|
||||||
@@ -72,7 +74,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 12),
|
padding: const EdgeInsets.only(top: 12),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Inloggning misslyckades',
|
mapErrorToUserMessage(authState.error!),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,37 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
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});
|
const ProfileScreen({super.key});
|
||||||
|
|
||||||
Future<void> _logout(BuildContext context, WidgetRef ref) async {
|
|
||||||
await ref.read(authStateProvider.notifier).logout();
|
|
||||||
if (context.mounted) context.go('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return const Center(
|
||||||
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)'),
|
child: Text('Profilsida (grundversion)'),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import '../../../core/api/api_client.dart';
|
import '../../../core/api/api_client.dart';
|
||||||
|
import '../../../core/api/api_exception.dart';
|
||||||
import '../domain/recipe.dart';
|
import '../domain/recipe.dart';
|
||||||
|
|
||||||
class RecipeRepository {
|
class RecipeRepository {
|
||||||
@@ -8,13 +8,26 @@ class RecipeRepository {
|
|||||||
RecipeRepository(this._api);
|
RecipeRepository(this._api);
|
||||||
|
|
||||||
Future<List<Recipe>> fetchRecipes({String? token}) async {
|
Future<List<Recipe>> fetchRecipes({String? token}) async {
|
||||||
final response = await _api.get('/recipes', token: token);
|
try {
|
||||||
if (response.statusCode != 200) {
|
final data = await _api.getJson('/recipes', token: token);
|
||||||
throw Exception('Failed to load recipes: ${response.statusCode}');
|
|
||||||
|
if (data is! List) {
|
||||||
|
throw const ApiException(
|
||||||
|
type: ApiErrorType.unknown,
|
||||||
|
message: 'Ogiltigt svar fran servern.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
final List<dynamic> data = jsonDecode(response.body) as List<dynamic>;
|
|
||||||
return data
|
return data
|
||||||
.map((e) => Recipe.fromJson(e as Map<String, dynamic>))
|
.map((e) => Recipe.fromJson(e as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
|
} on ApiException {
|
||||||
|
rethrow;
|
||||||
|
} catch (_) {
|
||||||
|
throw const ApiException(
|
||||||
|
type: ApiErrorType.network,
|
||||||
|
message: 'Kunde inte hamta recept.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,31 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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';
|
import '../data/recipe_providers.dart';
|
||||||
|
|
||||||
class RecipesScreen extends ConsumerWidget {
|
class RecipesScreen extends ConsumerWidget {
|
||||||
const RecipesScreen({super.key});
|
const RecipesScreen({super.key});
|
||||||
|
|
||||||
Future<void> _logout(BuildContext context, WidgetRef ref) async {
|
|
||||||
await ref.read(authStateProvider.notifier).logout();
|
|
||||||
if (context.mounted) context.go('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final recipesAsync = ref.watch(recipesProvider);
|
final recipesAsync = ref.watch(recipesProvider);
|
||||||
return Scaffold(
|
return recipesAsync.when(
|
||||||
appBar: AppBar(
|
loading: () => const LoadingStateView(label: 'Laddar recept...'),
|
||||||
title: const Text('Recept'),
|
error: (error, _) => ErrorStateView(
|
||||||
actions: [
|
message: mapErrorToUserMessage(error),
|
||||||
IconButton(
|
onRetry: () => ref.invalidate(recipesProvider),
|
||||||
tooltip: 'Profil',
|
|
||||||
icon: const Icon(Icons.person),
|
|
||||||
onPressed: () => context.go('/profile'),
|
|
||||||
),
|
),
|
||||||
IconButton(
|
data: (recipes) {
|
||||||
tooltip: 'Logga ut',
|
if (recipes.isEmpty) {
|
||||||
icon: const Icon(Icons.logout),
|
return const EmptyStateView(
|
||||||
onPressed: () => _logout(context, ref),
|
title: 'Inga recept hittades',
|
||||||
),
|
description: 'Lagg till ett recept for att komma igang.',
|
||||||
],
|
);
|
||||||
),
|
}
|
||||||
body: recipesAsync.when(
|
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
return ListView.builder(
|
||||||
error: (e, _) => Center(child: Text('Fel: $e')),
|
|
||||||
data: (recipes) => ListView.builder(
|
|
||||||
itemCount: recipes.length,
|
itemCount: recipes.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final recipe = recipes[index];
|
final recipe = recipes[index];
|
||||||
@@ -52,8 +43,8 @@ class RecipesScreen extends ConsumerWidget {
|
|||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+85
-37
@@ -1,53 +1,101 @@
|
|||||||
# Next Steps: Flutter-migrering (Alternativ 3)
|
# Next Steps: Flutter-migrering
|
||||||
|
|
||||||
Relaterade dokument:
|
Relaterade dokument:
|
||||||
- [flutter/README.md](flutter/README.md)
|
- [flutter/README.md](flutter/README.md)
|
||||||
- [teknisk_beskrivning_flutter.md](teknisk_beskrivning_flutter.md)
|
- [teknisk_beskrivning_flutter.md](teknisk_beskrivning_flutter.md)
|
||||||
|
- [TEKNISK_BESKRIVNING.md](TEKNISK_BESKRIVNING.md)
|
||||||
|
|
||||||
## 1. Definiera malbild och scope forst
|
## Icke-forhandlingsbara ramar
|
||||||
- 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.
|
|
||||||
|
|
||||||
## 2. Bygg gemensam app-shell innan fler sidor
|
1. Inget ska tas bort eller andras i `recipe-api`.
|
||||||
- Stabil routingstruktur.
|
2. Inget ska tas bort eller andras i `recipe-frontend`.
|
||||||
- Gemensam navigation (top/bottom/nav drawer).
|
3. Migreringen sker i Flutter-sparet som separat klient mot befintliga API-kontrakt.
|
||||||
- Auth-gate och logout-flode.
|
4. Next-frontend kor parallellt tills Flutter har verifierad parity i karnfloden.
|
||||||
- Standardkomponenter for tomma lagen, felmeddelanden och laddning.
|
|
||||||
|
|
||||||
Det gor att varje ny sida gar snabbare och mer konsekvent.
|
## Malbild for v1 (funktionell parity)
|
||||||
|
|
||||||
## 3. Migrera i denna ordning (hogst affarsvarde forst)
|
For v1 ska dessa floden vara stabila i Flutter:
|
||||||
- Auth: login, session, logout.
|
- Auth: login, session, logout, auth-guard.
|
||||||
- Recept: lista -> detalj -> skapa/andra.
|
- Recept: lista, detalj, skapa, uppdatera, ta bort.
|
||||||
- Inventarie: lista -> skapa -> uppdatera -> forbrukning.
|
- Inventarie: lista, skapa, uppdatera, konsumera, historik.
|
||||||
- Import-funktionen
|
- Matplan: veckovy, val av recept per dag, portionsjustering, inkopslista, inventariejamforelse.
|
||||||
- Matsedel.
|
- Import: quick-import + parse-markdown-flode.
|
||||||
- Profil/admin.
|
- 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
|
## Prioriterad plan (ordning)
|
||||||
- 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.
|
|
||||||
|
|
||||||
## 5. Satt enhetliga kvalitetsgrindar per migrerad feature
|
## Fas 1 - Stabil app-shell (forst)
|
||||||
- Manuell testlista for kritiska scenarier.
|
- Bygg tydlig auth-gate i router.
|
||||||
- En liten smoke-test efter varje deploy.
|
- Centralisera API-fel (401/403/500) i ett gemensamt lager.
|
||||||
- Kontroll att web + mobilanpassning fungerar (utan web-specifika genvagar).
|
- Skapa gemensamma UI-komponenter for loading, empty, error.
|
||||||
|
- Satt en enhetlig navigationsstruktur (web forst, mobil-redo).
|
||||||
|
|
||||||
## 6. Leverera i korta iterationer
|
Motivering: minskar regressionsrisk och gor resten av migreringen snabbare.
|
||||||
- 1 feature at gangen till testmiljo.
|
|
||||||
- Demo + snabb feedback.
|
|
||||||
- Justera innan nasta feature.
|
|
||||||
|
|
||||||
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
|
## Fas 3 - Recept parity
|
||||||
- Kor dubbel drift under en period.
|
- Lista -> detalj -> skapa -> redigera -> ta bort.
|
||||||
- Peka en testdoman mot Flutter tills parity ar bekräftad.
|
- Knyt ihop med parse-markdown-proxy.
|
||||||
- Flytta trafik gradvis nar karnfloden ar stabila.
|
- 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
|
## 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user