feat: add Flutter web frontend with authentication and recipe management features

This commit is contained in:
Nils-Johan Gynther
2026-04-21 21:29:47 +02:00
parent 2acf66e4c4
commit 3996456f6f
19 changed files with 460 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
:{$PORT:5000} {
root * /usr/share/caddy
file_server
# SPA-routing returnera alltid index.html för okända paths
try_files {path} /index.html
encode gzip
}
+26
View File
@@ -0,0 +1,26 @@
# Stage 1 Build Flutter web
FROM ghcr.io/cirruslabs/flutter:stable AS builder
WORKDIR /app
COPY pubspec.yaml pubspec.lock* ./
RUN flutter pub get
COPY . .
# Inject the internal API URL at build time via --dart-define
ARG FLUTTER_API_URL_INTERNAL=http://recipe-api:8080
RUN flutter build web --release \
--dart-define=API_BASE_URL=${FLUTTER_API_URL_INTERNAL}
# Stage 2 Serve with Caddy
FROM caddy:alpine AS runner
ARG PORT=5000
ENV PORT=${PORT}
COPY --from=builder /app/build/web /usr/share/caddy
COPY Caddyfile /etc/caddy/Caddyfile
EXPOSE ${PORT}
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
+37
View File
@@ -0,0 +1,37 @@
import 'dart:io';
import 'package:http/http.dart' as http;
/// Platform-neutral HTTP client wrapping the internal API base URL.
/// Base URL is read from the FLUTTER_API_URL_INTERNAL environment variable
/// (set by Docker) or falls back to localhost for local development.
class ApiClient {
final String baseUrl;
final http.Client _client;
ApiClient({http.Client? client})
: baseUrl = const String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://localhost:8080',
),
_client = client ?? http.Client();
Map<String, String> _headers({String? token}) => {
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
};
Future<http.Response> get(String path, {String? token}) =>
_client.get(Uri.parse('$baseUrl$path'), headers: _headers(token: token));
Future<http.Response> post(String path, String body, {String? token}) =>
_client.post(Uri.parse('$baseUrl$path'),
headers: _headers(token: token), body: body);
Future<http.Response> put(String path, String body, {String? token}) =>
_client.put(Uri.parse('$baseUrl$path'),
headers: _headers(token: token), body: body);
Future<http.Response> delete(String path, {String? token}) =>
_client.delete(Uri.parse('$baseUrl$path'),
headers: _headers(token: token));
}
+4
View File
@@ -0,0 +1,4 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'api_client.dart';
final apiClientProvider = Provider<ApiClient>((ref) => ApiClient());
@@ -0,0 +1,9 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'token_storage.dart';
import 'web_token_storage.dart';
/// Bind ITokenStorage to the web adapter.
/// For mobile: swap WebTokenStorage for SecureTokenStorage here.
final tokenStorageProvider = Provider<ITokenStorage>((ref) {
return WebTokenStorage();
});
@@ -0,0 +1,7 @@
/// Platform-neutral contract for token storage.
/// Web implementation uses SharedPreferences; mobile uses flutter_secure_storage.
abstract interface class ITokenStorage {
Future<String?> getToken();
Future<void> saveToken(String token);
Future<void> deleteToken();
}
@@ -0,0 +1,26 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'token_storage.dart';
/// Web-adapter: stores JWT in SharedPreferences (localStorage on web).
/// Replace with flutter_secure_storage adapter for Android/iOS.
class WebTokenStorage implements ITokenStorage {
static const _key = 'auth_token';
@override
Future<String?> getToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_key);
}
@override
Future<void> saveToken(String token) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_key, token);
}
@override
Future<void> deleteToken() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_key);
}
}
+20
View File
@@ -0,0 +1,20 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../features/recipes/presentation/recipes_screen.dart';
import '../../features/auth/presentation/login_screen.dart';
final appRouterProvider = Provider<GoRouter>((ref) {
return GoRouter(
initialLocation: '/recipes',
routes: [
GoRoute(
path: '/login',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/recipes',
builder: (context, state) => const RecipesScreen(),
),
],
);
});
@@ -0,0 +1,34 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_providers.dart';
import '../../../core/platform/platform_providers.dart';
import 'auth_repository.dart';
final authRepositoryProvider = Provider<AuthRepository>((ref) {
return AuthRepository(
ref.watch(apiClientProvider),
ref.watch(tokenStorageProvider),
);
});
final authStateProvider = AsyncNotifierProvider<AuthNotifier, String?>(() {
return AuthNotifier();
});
class AuthNotifier extends AsyncNotifier<String?> {
@override
Future<String?> build() async {
return ref.watch(authRepositoryProvider).getToken();
}
Future<void> login(String email, String password) async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref.read(authRepositoryProvider).login(email, password),
);
}
Future<void> logout() async {
await ref.read(authRepositoryProvider).logout();
state = const AsyncData(null);
}
}
@@ -0,0 +1,28 @@
import 'dart:convert';
import '../../core/api/api_client.dart';
import '../../core/platform/token_storage.dart';
class AuthRepository {
final ApiClient _api;
final ITokenStorage _storage;
AuthRepository(this._api, this._storage);
Future<String> login(String email, String password) async {
final response = await _api.post(
'/api/auth/login',
jsonEncode({'email': email, 'password': password}),
);
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['access_token'] as String;
await _storage.saveToken(token);
return token;
}
Future<void> logout() => _storage.deleteToken();
Future<String?> getToken() => _storage.getToken();
}
@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/auth_providers.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _emailCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
@override
void dispose() {
_emailCtrl.dispose();
_passwordCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
await ref.read(authStateProvider.notifier).login(
_emailCtrl.text.trim(),
_passwordCtrl.text,
);
if (mounted) {
final state = ref.read(authStateProvider);
if (state is AsyncData && state.value != null) {
if (context.mounted) Navigator.of(context).pushReplacementNamed('/recipes');
}
}
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authStateProvider);
return Scaffold(
appBar: AppBar(title: const Text('Logga in')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextField(
controller: _emailCtrl,
decoration: const InputDecoration(labelText: 'E-post'),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 12),
TextField(
controller: _passwordCtrl,
decoration: const InputDecoration(labelText: 'Lösenord'),
obscureText: true,
),
const SizedBox(height: 24),
if (authState is AsyncLoading)
const CircularProgressIndicator()
else
ElevatedButton(
onPressed: _submit,
child: const Text('Logga in'),
),
if (authState is AsyncError)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
'Inloggning misslyckades',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
),
),
);
}
}
@@ -0,0 +1,14 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_providers.dart';
import '../../../features/auth/data/auth_providers.dart';
import 'recipe_repository.dart';
import '../domain/recipe.dart';
final recipeRepositoryProvider = Provider<RecipeRepository>((ref) {
return RecipeRepository(ref.watch(apiClientProvider));
});
final recipesProvider = FutureProvider<List<Recipe>>((ref) async {
final token = await ref.watch(authStateProvider.future);
return ref.watch(recipeRepositoryProvider).fetchRecipes(token: token);
});
@@ -0,0 +1,20 @@
import 'dart:convert';
import '../../../core/api/api_client.dart';
import '../domain/recipe.dart';
class RecipeRepository {
final ApiClient _api;
RecipeRepository(this._api);
Future<List<Recipe>> fetchRecipes({String? token}) async {
final response = await _api.get('/api/recipes', token: token);
if (response.statusCode != 200) {
throw Exception('Failed to load recipes: ${response.statusCode}');
}
final List<dynamic> data = jsonDecode(response.body) as List<dynamic>;
return data
.map((e) => Recipe.fromJson(e as Map<String, dynamic>))
.toList();
}
}
@@ -0,0 +1,23 @@
class Recipe {
final int id;
final String title;
final String? description;
final String? imageUrl;
final int? servings;
const Recipe({
required this.id,
required this.title,
this.description,
this.imageUrl,
this.servings,
});
factory Recipe.fromJson(Map<String, dynamic> json) => Recipe(
id: json['id'] as int,
title: json['title'] as String,
description: json['description'] as String?,
imageUrl: json['imageUrl'] as String?,
servings: json['servings'] as int?,
);
}
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/recipe_providers.dart';
class RecipesScreen extends ConsumerWidget {
const RecipesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final recipesAsync = ref.watch(recipesProvider);
return Scaffold(
appBar: AppBar(title: const Text('Recept')),
body: recipesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Fel: $e')),
data: (recipes) => ListView.builder(
itemCount: recipes.length,
itemBuilder: (context, index) {
final recipe = recipes[index];
return ListTile(
leading: recipe.imageUrl != null
? Image.network(recipe.imageUrl!, width: 56, fit: BoxFit.cover)
: const Icon(Icons.restaurant),
title: Text(recipe.title),
subtitle: recipe.description != null
? Text(
recipe.description!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: null,
);
},
),
),
);
}
}
+24
View File
@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/router/app_router.dart';
void main() {
runApp(const ProviderScope(child: RecipeApp()));
}
class RecipeApp extends ConsumerWidget {
const RecipeApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(appRouterProvider);
return MaterialApp.router(
title: 'Recipe App',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
useMaterial3: true,
),
routerConfig: router,
);
}
}
+28
View File
@@ -0,0 +1,28 @@
name: recipe_flutter
description: Recipe App Flutter frontend (web-first, mobile-ready)
publish_to: "none"
version: 1.0.0+1
environment:
sdk: ">=3.3.0 <4.0.0"
dependencies:
flutter:
sdk: flutter
flutter_web_plugins:
sdk: flutter
go_router: ^14.0.0
riverpod: ^2.5.1
flutter_riverpod: ^2.5.1
http: ^1.2.1
shared_preferences: ^2.2.3
flutter_secure_storage: ^9.2.2
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^4.0.0
build_runner: ^2.4.9
flutter:
uses-material-design: true
+11
View File
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<base href="/">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Recipe App</title>
<script src="flutter_bootstrap.js" async></script>
</head>
<body></body>
</html>