import 'dart:convert'; import 'package:dio/dio.dart'; import '../models/recipe.dart'; import '../storage/secure_storage_service.dart'; class ApiException implements Exception { final String message; final int? statusCode; ApiException(this.message, [this.statusCode]); @override String toString() => message; } class ApiClient { final SecureStorageService _storage; final Dio _dio; ApiClient(this._storage) : _dio = Dio( BaseOptions( connectTimeout: const Duration(seconds: 15), receiveTimeout: const Duration(seconds: 45), headers: {'Accept': 'application/json'}, ), ) { // Interceptor to auto-inject Bearer mobile token and base URL _dio.interceptors.add( InterceptorsWrapper( onRequest: (options, handler) async { final serverUrl = await _storage.getServerUrl(); final token = await _storage.getToken(); if (serverUrl != null && !options.path.startsWith('http')) { options.baseUrl = serverUrl; } if (token != null && token.isNotEmpty) { options.headers['Authorization'] = 'Bearer $token'; } return handler.next(options); }, onError: (DioException e, handler) { String message = 'Network communication error.'; if (e.response != null) { final data = e.response?.data; if (data is Map && data['error'] != null) { message = data['error'].toString(); } else if (e.response?.statusCode == 401) { message = 'Session expired or invalid. Please log in again.'; } else { message = 'Server returned HTTP ${e.response?.statusCode}.'; } } else if (e.type == DioExceptionType.connectionTimeout) { message = 'Connection timed out. Please check your internet or server URL.'; } else if (e.type == DioExceptionType.connectionError) { message = 'Could not connect to ForkFolio server. Check domain or VPN status.'; } return handler.reject( DioException( requestOptions: e.requestOptions, error: ApiException(message, e.response?.statusCode), response: e.response, type: e.type, ), ); }, ), ); } // 1. Mobile App Login Future> login({ required String serverUrl, required String username, required String password, required String deviceName, }) async { final cleanUrl = serverUrl.trim().replaceAll(RegExp(r'/+$'), ''); try { final response = await _dio.post( '$cleanUrl/api/auth/mobile-login', data: { 'username': username.trim(), 'password': password, 'deviceName': deviceName.trim(), }, ); final data = response.data as Map; final token = data['token'] as String; final expiresAt = data['expiresAt'] as String; final user = data['user'] as Map; await _storage.saveSession( serverUrl: cleanUrl, token: token, user: user, expiresAt: expiresAt, ); return data; } on DioException catch (e) { final err = e.error; if (err is ApiException) throw err; throw ApiException(e.message ?? 'Login failed'); } } // 2. Fetch User Recipes Future> fetchRecipes() async { try { final response = await _dio.get('/api/recipes'); final list = response.data as List; return list.map((item) => Recipe.fromJson(item as Map)).toList(); } on DioException catch (e) { final err = e.error; if (err is ApiException) throw err; throw ApiException(e.message ?? 'Failed to load recipes'); } } // 3. Scrape Recipe from Link or Social Media Caption Future> scrapeRecipe({ String? url, String? rawCaption, }) async { try { final response = await _dio.post( '/api/scrape', data: { if (url != null && url.isNotEmpty) 'url': url.trim(), if (rawCaption != null && rawCaption.isNotEmpty) 'rawCaption': rawCaption.trim(), }, ); return response.data as Map; } on DioException catch (e) { final err = e.error; if (err is ApiException) throw err; throw ApiException(e.message ?? 'Failed to scrape recipe details'); } } // 4. Scan Recipe Photo via Gemini Multimodal Vision Endpoint Future> scanRecipePhoto({ required String imageBase64, String mimeType = 'image/jpeg', }) async { try { final response = await _dio.post( '/api/recipes/scan-photo', data: { 'imageBase64': imageBase64, 'mimeType': mimeType, }, options: Options(receiveTimeout: const Duration(seconds: 90)), ); return response.data as Map; } on DioException catch (e) { final err = e.error; if (err is ApiException) throw err; throw ApiException(e.message ?? 'Photo recipe extraction failed'); } } // 5. Save or Update Recipe Future saveRecipe(Recipe recipe) async { try { Response response; if (recipe.id != null && recipe.id!.isNotEmpty) { response = await _dio.put('/api/recipes/${recipe.id}', data: recipe.toJson()); } else { response = await _dio.post('/api/recipes', data: recipe.toJson()); } return Recipe.fromJson(response.data as Map); } on DioException catch (e) { final err = e.error; if (err is ApiException) throw err; throw ApiException(e.message ?? 'Failed to save recipe'); } } // 6. Delete Recipe Future deleteRecipe(String id) async { try { await _dio.delete('/api/recipes/$id'); } on DioException catch (e) { final err = e.error; if (err is ApiException) throw err; throw ApiException(e.message ?? 'Failed to delete recipe'); } } // 7. Logout Future logout() async { try { await _dio.post('/api/auth/logout'); } catch (_) { // Continue clearing local storage even if network request fails } finally { await _storage.clearSession(); } } }