mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-02 07:28:25 -05:00
230 lines
7 KiB
Dart
230 lines
7 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. 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();
|
|
}
|
|
}
|
|
}
|