From 3996456f6f87fc257aadbc04a1a16d2fb071fa66 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Tue, 21 Apr 2026 21:29:47 +0200 Subject: [PATCH] feat: add Flutter web frontend with authentication and recipe management features --- compose.flutter.yml | 24 ++++++ flutter/Caddyfile | 9 +++ flutter/Dockerfile | 26 +++++++ flutter/lib/core/api/api_client.dart | 37 +++++++++ flutter/lib/core/api/api_providers.dart | 4 + .../lib/core/platform/platform_providers.dart | 9 +++ flutter/lib/core/platform/token_storage.dart | 7 ++ .../lib/core/platform/web_token_storage.dart | 26 +++++++ flutter/lib/core/router/app_router.dart | 20 +++++ .../features/auth/data/auth_providers.dart | 34 ++++++++ .../features/auth/data/auth_repository.dart | 28 +++++++ .../auth/presentation/login_screen.dart | 78 +++++++++++++++++++ .../recipes/data/recipe_providers.dart | 14 ++++ .../recipes/data/recipe_repository.dart | 20 +++++ .../lib/features/recipes/domain/recipe.dart | 23 ++++++ .../recipes/presentation/recipes_screen.dart | 38 +++++++++ flutter/lib/main.dart | 24 ++++++ flutter/pubspec.yaml | 28 +++++++ flutter/web/index.html | 11 +++ 19 files changed, 460 insertions(+) create mode 100644 compose.flutter.yml create mode 100644 flutter/Caddyfile create mode 100644 flutter/Dockerfile create mode 100644 flutter/lib/core/api/api_client.dart create mode 100644 flutter/lib/core/api/api_providers.dart create mode 100644 flutter/lib/core/platform/platform_providers.dart create mode 100644 flutter/lib/core/platform/token_storage.dart create mode 100644 flutter/lib/core/platform/web_token_storage.dart create mode 100644 flutter/lib/core/router/app_router.dart create mode 100644 flutter/lib/features/auth/data/auth_providers.dart create mode 100644 flutter/lib/features/auth/data/auth_repository.dart create mode 100644 flutter/lib/features/auth/presentation/login_screen.dart create mode 100644 flutter/lib/features/recipes/data/recipe_providers.dart create mode 100644 flutter/lib/features/recipes/data/recipe_repository.dart create mode 100644 flutter/lib/features/recipes/domain/recipe.dart create mode 100644 flutter/lib/features/recipes/presentation/recipes_screen.dart create mode 100644 flutter/lib/main.dart create mode 100644 flutter/pubspec.yaml create mode 100644 flutter/web/index.html diff --git a/compose.flutter.yml b/compose.flutter.yml new file mode 100644 index 00000000..f4dc8664 --- /dev/null +++ b/compose.flutter.yml @@ -0,0 +1,24 @@ +services: + recipe-flutter: + build: + context: ./flutter + dockerfile: Dockerfile + image: recipe-flutter:local + container_name: recipe-flutter + restart: unless-stopped + environment: + FLUTTER_API_URL_INTERNAL: "http://recipe-api:8080" + PORT: "5000" + ports: + - "5000:5000" + depends_on: + recipe-api: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5000 >/dev/null 2>&1 || exit 1"] + interval: 20s + timeout: 10s + retries: 5 + start_period: 60s + networks: + - recipe-internal diff --git a/flutter/Caddyfile b/flutter/Caddyfile new file mode 100644 index 00000000..385d3699 --- /dev/null +++ b/flutter/Caddyfile @@ -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 +} diff --git a/flutter/Dockerfile b/flutter/Dockerfile new file mode 100644 index 00000000..95c0a656 --- /dev/null +++ b/flutter/Dockerfile @@ -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"] diff --git a/flutter/lib/core/api/api_client.dart b/flutter/lib/core/api/api_client.dart new file mode 100644 index 00000000..a5e2745a --- /dev/null +++ b/flutter/lib/core/api/api_client.dart @@ -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 _headers({String? token}) => { + 'Content-Type': 'application/json', + if (token != null) 'Authorization': 'Bearer $token', + }; + + Future get(String path, {String? token}) => + _client.get(Uri.parse('$baseUrl$path'), headers: _headers(token: token)); + + Future post(String path, String body, {String? token}) => + _client.post(Uri.parse('$baseUrl$path'), + headers: _headers(token: token), body: body); + + Future put(String path, String body, {String? token}) => + _client.put(Uri.parse('$baseUrl$path'), + headers: _headers(token: token), body: body); + + Future delete(String path, {String? token}) => + _client.delete(Uri.parse('$baseUrl$path'), + headers: _headers(token: token)); +} diff --git a/flutter/lib/core/api/api_providers.dart b/flutter/lib/core/api/api_providers.dart new file mode 100644 index 00000000..2a079994 --- /dev/null +++ b/flutter/lib/core/api/api_providers.dart @@ -0,0 +1,4 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'api_client.dart'; + +final apiClientProvider = Provider((ref) => ApiClient()); diff --git a/flutter/lib/core/platform/platform_providers.dart b/flutter/lib/core/platform/platform_providers.dart new file mode 100644 index 00000000..e79fadea --- /dev/null +++ b/flutter/lib/core/platform/platform_providers.dart @@ -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((ref) { + return WebTokenStorage(); +}); diff --git a/flutter/lib/core/platform/token_storage.dart b/flutter/lib/core/platform/token_storage.dart new file mode 100644 index 00000000..d3111adb --- /dev/null +++ b/flutter/lib/core/platform/token_storage.dart @@ -0,0 +1,7 @@ +/// Platform-neutral contract for token storage. +/// Web implementation uses SharedPreferences; mobile uses flutter_secure_storage. +abstract interface class ITokenStorage { + Future getToken(); + Future saveToken(String token); + Future deleteToken(); +} diff --git a/flutter/lib/core/platform/web_token_storage.dart b/flutter/lib/core/platform/web_token_storage.dart new file mode 100644 index 00000000..fb343237 --- /dev/null +++ b/flutter/lib/core/platform/web_token_storage.dart @@ -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 getToken() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_key); + } + + @override + Future saveToken(String token) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_key, token); + } + + @override + Future deleteToken() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_key); + } +} diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart new file mode 100644 index 00000000..66eb2561 --- /dev/null +++ b/flutter/lib/core/router/app_router.dart @@ -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((ref) { + return GoRouter( + initialLocation: '/recipes', + routes: [ + GoRoute( + path: '/login', + builder: (context, state) => const LoginScreen(), + ), + GoRoute( + path: '/recipes', + builder: (context, state) => const RecipesScreen(), + ), + ], + ); +}); diff --git a/flutter/lib/features/auth/data/auth_providers.dart b/flutter/lib/features/auth/data/auth_providers.dart new file mode 100644 index 00000000..78d6c500 --- /dev/null +++ b/flutter/lib/features/auth/data/auth_providers.dart @@ -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((ref) { + return AuthRepository( + ref.watch(apiClientProvider), + ref.watch(tokenStorageProvider), + ); +}); + +final authStateProvider = AsyncNotifierProvider(() { + return AuthNotifier(); +}); + +class AuthNotifier extends AsyncNotifier { + @override + Future build() async { + return ref.watch(authRepositoryProvider).getToken(); + } + + Future login(String email, String password) async { + state = const AsyncLoading(); + state = await AsyncValue.guard( + () => ref.read(authRepositoryProvider).login(email, password), + ); + } + + Future logout() async { + await ref.read(authRepositoryProvider).logout(); + state = const AsyncData(null); + } +} diff --git a/flutter/lib/features/auth/data/auth_repository.dart b/flutter/lib/features/auth/data/auth_repository.dart new file mode 100644 index 00000000..377d2bde --- /dev/null +++ b/flutter/lib/features/auth/data/auth_repository.dart @@ -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 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; + final token = data['access_token'] as String; + await _storage.saveToken(token); + return token; + } + + Future logout() => _storage.deleteToken(); + + Future getToken() => _storage.getToken(); +} diff --git a/flutter/lib/features/auth/presentation/login_screen.dart b/flutter/lib/features/auth/presentation/login_screen.dart new file mode 100644 index 00000000..8550baf9 --- /dev/null +++ b/flutter/lib/features/auth/presentation/login_screen.dart @@ -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 createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState { + final _emailCtrl = TextEditingController(); + final _passwordCtrl = TextEditingController(); + + @override + void dispose() { + _emailCtrl.dispose(); + _passwordCtrl.dispose(); + super.dispose(); + } + + Future _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), + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter/lib/features/recipes/data/recipe_providers.dart b/flutter/lib/features/recipes/data/recipe_providers.dart new file mode 100644 index 00000000..78e6d4a3 --- /dev/null +++ b/flutter/lib/features/recipes/data/recipe_providers.dart @@ -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((ref) { + return RecipeRepository(ref.watch(apiClientProvider)); +}); + +final recipesProvider = FutureProvider>((ref) async { + final token = await ref.watch(authStateProvider.future); + return ref.watch(recipeRepositoryProvider).fetchRecipes(token: token); +}); diff --git a/flutter/lib/features/recipes/data/recipe_repository.dart b/flutter/lib/features/recipes/data/recipe_repository.dart new file mode 100644 index 00000000..a6886eec --- /dev/null +++ b/flutter/lib/features/recipes/data/recipe_repository.dart @@ -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> 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 data = jsonDecode(response.body) as List; + return data + .map((e) => Recipe.fromJson(e as Map)) + .toList(); + } +} diff --git a/flutter/lib/features/recipes/domain/recipe.dart b/flutter/lib/features/recipes/domain/recipe.dart new file mode 100644 index 00000000..65b314c8 --- /dev/null +++ b/flutter/lib/features/recipes/domain/recipe.dart @@ -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 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?, + ); +} diff --git a/flutter/lib/features/recipes/presentation/recipes_screen.dart b/flutter/lib/features/recipes/presentation/recipes_screen.dart new file mode 100644 index 00000000..b8a6f482 --- /dev/null +++ b/flutter/lib/features/recipes/presentation/recipes_screen.dart @@ -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, + ); + }, + ), + ), + ); + } +} diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart new file mode 100644 index 00000000..85314a89 --- /dev/null +++ b/flutter/lib/main.dart @@ -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, + ); + } +} diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml new file mode 100644 index 00000000..03f37aac --- /dev/null +++ b/flutter/pubspec.yaml @@ -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 diff --git a/flutter/web/index.html b/flutter/web/index.html new file mode 100644 index 00000000..f56339b4 --- /dev/null +++ b/flutter/web/index.html @@ -0,0 +1,11 @@ + + + + + + + Recipe App + + + +