recipe-manager/mobile/lib/core/api/api_client.dart

360 lines
11 KiB
Dart

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<Map<String, dynamic>> 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<String, dynamic>;
final token = data['token'] as String;
final expiresAt = data['expiresAt'] as String;
final user = data['user'] as Map<String, dynamic>;
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<List<Recipe>> fetchRecipes() async {
try {
final response = await _dio.get('/api/recipes');
final list = response.data as List<dynamic>;
return list.map((item) => Recipe.fromJson(item as Map<String, dynamic>)).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<Map<String, dynamic>> 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<String, dynamic>;
} 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<Map<String, dynamic>> 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<String, dynamic>;
} on DioException catch (e) {
final err = e.error;
if (err is ApiException) throw err;
throw ApiException(e.message ?? 'Photo recipe extraction failed');
}
}
// 4b. AI Polish Recipe with Gemini
Future<Map<String, dynamic>> aiPolishRecipe({
String? title,
String? rawCaption,
List<String>? ingredients,
List<String>? instructions,
}) async {
try {
final response = await _dio.post(
'/api/recipes/ai-polish',
data: {
'title': title ?? '',
'rawCaption': rawCaption ?? '',
'ingredients': ingredients ?? [],
'instructions': instructions ?? [],
},
);
return response.data as Map<String, dynamic>;
} on DioException catch (e) {
final err = e.error;
if (err is ApiException) throw err;
throw ApiException(e.message ?? 'AI Polish failed');
}
}
// 5. Save or Update Recipe
Future<Recipe> 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<String, dynamic>);
} 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<void> 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. Public Recipes (Community Feed)
Future<List<Recipe>> getPublicRecipes() async {
try {
final response = await _dio.get('/api/recipes/public');
final list = response.data as List<dynamic>;
return list.map((json) => Recipe.fromJson(json as Map<String, dynamic>)).toList();
} on DioException catch (e) {
final err = e.error;
if (err is ApiException) throw err;
throw ApiException(e.message ?? 'Failed to load community recipes');
}
}
// 8. Clone Recipe to User's Library
Future<Recipe> cloneRecipe(String id) async {
try {
final response = await _dio.post('/api/recipes/clone/$id');
return Recipe.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
final err = e.error;
if (err is ApiException) throw err;
throw ApiException(e.message ?? 'Failed to clone recipe');
}
}
// 9. Shopping Lists
Future<List<dynamic>> getShoppingLists() async {
try {
final response = await _dio.get('/api/shopping-lists');
return response.data as List<dynamic>;
} on DioException catch (e) {
final err = e.error;
if (err is ApiException) throw err;
throw ApiException(e.message ?? 'Failed to load shopping lists');
}
}
Future<Map<String, dynamic>> createShoppingList(String name) async {
try {
final response = await _dio.post('/api/shopping-lists', data: {'name': name});
return response.data as Map<String, dynamic>;
} on DioException catch (e) {
final err = e.error;
if (err is ApiException) throw err;
throw ApiException(e.message ?? 'Failed to create shopping list');
}
}
Future<void> deleteShoppingList(String id) async {
try {
await _dio.delete('/api/shopping-lists/$id');
} on DioException catch (e) {
final err = e.error;
if (err is ApiException) throw err;
throw ApiException(e.message ?? 'Failed to delete shopping list');
}
}
Future<Map<String, dynamic>> addShoppingListItems(
String listId,
List<String> items, {
String? recipeId,
String? recipeTitle,
}) async {
try {
final response = await _dio.post(
'/api/shopping-lists/$listId/items',
data: {
'items': items,
if (recipeId != null) 'recipeId': recipeId,
if (recipeTitle != null) 'recipeTitle': recipeTitle,
},
);
return response.data as Map<String, dynamic>;
} on DioException catch (e) {
final err = e.error;
if (err is ApiException) throw err;
throw ApiException(e.message ?? 'Failed to add items to shopping list');
}
}
Future<Map<String, dynamic>> checkShoppingListItems(
String listId,
List<String> itemIds,
bool checked,
) async {
try {
final response = await _dio.put(
'/api/shopping-lists/$listId/items/check',
data: {
'itemIds': itemIds,
'checked': checked,
},
);
return response.data as Map<String, dynamic>;
} on DioException catch (e) {
final err = e.error;
if (err is ApiException) throw err;
throw ApiException(e.message ?? 'Failed to update item check status');
}
}
Future<Map<String, dynamic>> deleteShoppingListItems(String listId, List<String> itemIds) async {
try {
final response = await _dio.post(
'/api/shopping-lists/$listId/items/delete',
data: {'itemIds': itemIds},
);
return response.data as Map<String, dynamic>;
} on DioException catch (e) {
final err = e.error;
if (err is ApiException) throw err;
throw ApiException(e.message ?? 'Failed to delete items');
}
}
Future<Map<String, dynamic>> clearShoppingList(String listId, {bool onlyChecked = true}) async {
try {
final response = await _dio.post(
'/api/shopping-lists/$listId/clear',
data: {'onlyChecked': onlyChecked},
);
return response.data as Map<String, dynamic>;
} on DioException catch (e) {
final err = e.error;
if (err is ApiException) throw err;
throw ApiException(e.message ?? 'Failed to clear shopping list');
}
}
// 10. Logout
Future<void> logout() async {
try {
await _dio.post('/api/auth/logout');
} catch (_) {
// Continue clearing local storage even if network request fails
} finally {
await _storage.clearSession();
}
}
}