import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:s_gizi/app_state.dart'; import 'package:s_gizi/models/api_result_model.dart'; import 'package:s_gizi/models/mobile_child_model.dart'; import 'package:s_gizi/models/news_article_model.dart'; import 'package:s_gizi/models/recommendation_response_model.dart'; import 'package:s_gizi/models/riwayat_response_model.dart'; class ApiService { ApiService({http.Client? client, String? baseUrl}) : _client = client ?? http.Client(), baseUrl = baseUrl ?? _resolveDefaultBaseUrl(); final http.Client _client; final String baseUrl; static String _resolveDefaultBaseUrl() { const configured = String.fromEnvironment('SGIZI_API_BASE_URL'); if (configured.isNotEmpty) return configured; // Override for physical devices: // flutter run --dart-define=SGIZI_API_BASE_URL=http://:8000/api if (kIsWeb) { return 'http://192.168.1.69:8000/api'; } return 'http://192.168.1.69:8000/api'; } Future> hitungGizi(Map data) async { final response = await _client.post( Uri.parse('$baseUrl/gizi/hitung'), headers: _headers(), body: jsonEncode(data), ); if (response.statusCode == 200) { final decoded = jsonDecode(response.body); if (decoded is! Map) { throw Exception('Format response API tidak valid.'); } return decoded; } throw Exception('Gagal API (${response.statusCode}): ${response.body}'); } Future> login({ required String phone, required String password, }) async { final response = await _client.post( Uri.parse('$baseUrl/auth/login'), headers: const { 'Content-Type': 'application/json', 'Accept': 'application/json', }, body: jsonEncode({'phone': phone, 'password': password}), ); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception(_messageFromBody(response.body, 'Login gagal.')); } final decoded = jsonDecode(response.body); if (decoded is! Map) { throw Exception('Format response login tidak valid.'); } final token = decoded['token'] as String? ?? ''; if (token.isEmpty) throw Exception('Token login kosong.'); final userJson = decoded['user']; if (userJson is Map) { await SgiziAppState.instance.saveSession( token: token, role: (userJson['role'] as String? ?? 'orang_tua').trim().toLowerCase(), user: Map.from(userJson), ); } return decoded; } String _messageFromBody(String body, String fallback) { try { final decoded = jsonDecode(body); if (decoded is Map) { final message = decoded['message']; if (message is String && message.trim().isNotEmpty) return message; } } catch (_) {} return fallback; } Future registerSendOtp({ required String name, required String phone, required String parentGender, required String password, required String passwordConfirmation, }) async { final response = await _client.post( Uri.parse('$baseUrl/auth/register/send-otp'), headers: const { 'Content-Type': 'application/json', 'Accept': 'application/json', }, body: jsonEncode({ 'name': name, 'phone': phone, 'parent_gender': parentGender, 'password': password, 'password_confirmation': passwordConfirmation, }), ); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception(_messageFromBody(response.body, 'Gagal mengirim OTP.')); } } Future> registerVerifyOtp({ required String phone, required String otp, }) async { final response = await _client.post( Uri.parse('$baseUrl/auth/register/verify'), headers: const { 'Content-Type': 'application/json', 'Accept': 'application/json', }, body: jsonEncode({'phone': phone, 'otp': otp}), ); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception(_messageFromBody(response.body, 'OTP tidak valid.')); } final decoded = jsonDecode(response.body); if (decoded is! Map) { throw Exception('Format response verifikasi tidak valid.'); } final token = decoded['token'] as String? ?? ''; if (token.isEmpty) throw Exception('Token registrasi kosong.'); final userJson = decoded['user']; if (userJson is Map) { await SgiziAppState.instance.saveSession( token: token, role: (userJson['role'] as String? ?? 'orang_tua').trim().toLowerCase(), user: Map.from(userJson), ); } return decoded; } Future forgotPassword({required String phone}) async { final response = await _client.post( Uri.parse('$baseUrl/auth/forgot-password'), headers: const { 'Content-Type': 'application/json', 'Accept': 'application/json', }, body: jsonEncode({'phone': phone}), ); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception(_messageFromBody(response.body, 'Gagal mengirim OTP.')); } } Future> resetPassword({ required String phone, required String otp, required String password, required String passwordConfirmation, }) async { final response = await _client.post( Uri.parse('$baseUrl/auth/reset-password'), headers: const { 'Content-Type': 'application/json', 'Accept': 'application/json', }, body: jsonEncode({ 'phone': phone, 'otp': otp, 'password': password, 'password_confirmation': passwordConfirmation, }), ); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception(_messageFromBody(response.body, 'Gagal reset password.')); } final decoded = jsonDecode(response.body); if (decoded is! Map) { throw Exception('Format response reset password tidak valid.'); } final token = decoded['token'] as String? ?? ''; if (token.isNotEmpty) { final userJson = decoded['user']; if (userJson is Map) { await SgiziAppState.instance.saveSession( token: token, role: (userJson['role'] as String? ?? 'orang_tua') .trim() .toLowerCase(), user: Map.from(userJson), ); } } return decoded; } Future> verifyForgotPasswordOtp({ required String phone, required String otp, }) async { final response = await _client.post( Uri.parse('$baseUrl/auth/forgot-password/verify'), headers: const { 'Content-Type': 'application/json', 'Accept': 'application/json', }, body: jsonEncode({'phone': phone, 'otp': otp}), ); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception(_messageFromBody(response.body, 'Gagal verifikasi OTP.')); } final decoded = jsonDecode(response.body); if (decoded is! Map) { throw Exception('Format response verifikasi OTP tidak valid.'); } return decoded; } Future> getChildren() async { final response = await _client .get(Uri.parse('$baseUrl/children'), headers: _headers()) .timeout(const Duration(seconds: 12)); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception('Gagal mengambil data anak: ${response.body}'); } final decoded = jsonDecode(response.body); final list = decoded is Map ? decoded['data'] : null; if (list is! List) return const []; return list .whereType>() .map(MobileChildModel.fromJson) .toList(); } Future createChild(Map data) async { final response = await _client.post( Uri.parse('$baseUrl/children'), headers: _headers(), body: jsonEncode({ 'nama_anak': data['nama_anak'] ?? data['nama'], 'tanggal_lahir': data['tanggal_lahir'], 'jenis_kelamin': data['jenis_kelamin'], }), ); if (response.statusCode != 201 && response.statusCode != 200) { throw Exception('Gagal menyimpan data anak: ${response.body}'); } final decoded = jsonDecode(response.body); if (decoded is! Map || decoded['data'] is! Map) { throw Exception('Format data anak tidak valid.'); } return MobileChildModel.fromJson(decoded['data'] as Map); } Future updateChild({ required int childId, required Map data, }) async { final response = await _client.put( Uri.parse('$baseUrl/children/$childId'), headers: _headers(), body: jsonEncode({ if (data['nama'] != null) 'nama': data['nama'], if (data['nama_anak'] != null) 'nama_anak': data['nama_anak'], if (data['tanggal_lahir'] != null) 'tanggal_lahir': data['tanggal_lahir'], if (data['jenis_kelamin'] != null) 'jenis_kelamin': data['jenis_kelamin'], }), ); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception('Gagal memperbarui data anak: ${response.body}'); } final decoded = jsonDecode(response.body); if (decoded is! Map || decoded['data'] is! Map) { throw Exception('Format response update anak tidak valid.'); } return MobileChildModel.fromJson(decoded['data'] as Map); } Future deleteChild({required int childId}) async { final response = await _client.delete( Uri.parse('$baseUrl/children/$childId'), headers: _headers(), ); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception('Gagal menghapus data anak: ${response.body}'); } } Future postHasil(Map data) async { final json = await hitungGizi(data); return ApiResultModel.fromJson(json); } Future getRiwayat({required int childId}) async { final response = await _client .get(Uri.parse('$baseUrl/riwayat/$childId'), headers: _headers()) .timeout(const Duration(seconds: 12)); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception( 'Gagal mengambil riwayat. Status code: ${response.statusCode}', ); } final decoded = jsonDecode(response.body); if (decoded is! Map) { throw Exception('Format response API tidak valid.'); } return RiwayatResponseModel.fromJson(decoded); } Future getRecommendations({ required String status, int? childId, int? riwayatId, }) async { final encodedStatus = Uri.encodeComponent( status.isEmpty ? 'latest' : status, ); final uri = Uri.parse('$baseUrl/rekomendasi/$encodedStatus').replace( queryParameters: { if (childId != null) 'child_id': '$childId', if (riwayatId != null) 'riwayat_id': '$riwayatId', }, ); final response = await _client.get(uri, headers: _headers()); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception( 'Gagal mengambil rekomendasi. Status code: ${response.statusCode}', ); } final decoded = jsonDecode(response.body); if (decoded is! Map) { throw Exception('Format response rekomendasi tidak valid.'); } return RecommendationResponseModel.fromJson(decoded); } Future> getNewsArticles({ String query = 'kesehatan anak gizi balita stunting mpasi tumbuh kembang protein anak parenting kesehatan who nutrition imunisasi kesehatan ibu anak', }) async { // Online/Internet (Google News RSS) tetap lewat endpoint `/news` // agar tidak bentrok dengan `/articles` yang sekarang khusus DB admin. final uri = Uri.parse( '$baseUrl/news', ).replace(queryParameters: {'q': query}); final response = await _client.get(uri, headers: _headers()); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception( 'Gagal mengambil artikel berita. Status code: ${response.statusCode}', ); } final decoded = jsonDecode(response.body); if (decoded is! Map) { throw Exception('Format response artikel tidak valid.'); } final data = decoded['data']; if (data is! List) return const []; return data .whereType>() .map(NewsArticleModel.fromJson) .toList(); } Future> getProfile() async { final response = await _client .get(Uri.parse('$baseUrl/profile'), headers: _headers()) .timeout(const Duration(seconds: 12)); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception('Gagal mengambil profil: ${response.body}'); } final decoded = jsonDecode(response.body); if (decoded is! Map || decoded['data'] is! Map) { throw Exception('Format profil tidak valid.'); } return decoded['data'] as Map; } Future> updateProfile({ required String name, required String phone, String? email, String? gender, String? birthDate, String? specialization, String? experience, String? strSip, }) async { final response = await _client.put( Uri.parse('$baseUrl/profile'), headers: _headers(), body: jsonEncode({ 'name': name, 'phone': phone, 'email': (email ?? '').trim().isEmpty ? null : email?.trim(), if ((gender ?? '').trim().isNotEmpty) 'parent_gender': gender!.trim(), if ((birthDate ?? '').trim().isNotEmpty) 'tanggal_lahir': birthDate!.trim(), if ((specialization ?? '').trim().isNotEmpty) 'specialization': specialization!.trim(), if ((experience ?? '').trim().isNotEmpty) 'experience': experience!.trim(), if ((strSip ?? '').trim().isNotEmpty) 'str_sip': strSip!.trim(), }), ); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception('Gagal memperbarui profil: ${response.body}'); } final decoded = jsonDecode(response.body); if (decoded is! Map || decoded['data'] is! Map) { throw Exception('Format update profil tidak valid.'); } return decoded['data'] as Map; } Future updatePassword({ required String oldPassword, required String newPassword, required String newPasswordConfirmation, }) async { final response = await _client.put( Uri.parse('$baseUrl/profile/password'), headers: _headers(), body: jsonEncode({ 'old_password': oldPassword, 'password': newPassword, 'password_confirmation': newPasswordConfirmation, }), ); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception( _messageFromBody(response.body, 'Password lama tidak sesuai.'), ); } } Future logoutAllDevices() async { final response = await _client.post( Uri.parse('$baseUrl/profile/logout-all'), headers: _headers(), ); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception( _messageFromBody(response.body, 'Gagal logout semua perangkat.'), ); } } Future deleteAccount() async { final response = await _client.delete( Uri.parse('$baseUrl/profile'), headers: _headers(), ); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception(_messageFromBody(response.body, 'Gagal menghapus akun.')); } } /// Ambil artikel dari database `s_gizi` (tanpa query News API). Future> getArticlesDb() async { final uri = Uri.parse('$baseUrl/articles'); final response = await _client.get(uri, headers: _headers()); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception( 'Gagal mengambil artikel. Status code: ${response.statusCode}', ); } final decoded = jsonDecode(response.body); if (decoded is! Map) { throw Exception('Format response artikel tidak valid.'); } final data = decoded['data']; if (data is! List) return const []; return data .whereType>() .map(NewsArticleModel.fromJson) .toList(); } Future>> getConsultationRooms({ required int childId, }) async { final uri = Uri.parse( '$baseUrl/consultation/rooms', ).replace(queryParameters: {'child_id': '$childId'}); final response = await _client.get(uri, headers: _headers()); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception('Gagal mengambil room konsultasi: ${response.body}'); } final decoded = jsonDecode(response.body); final data = decoded is Map ? decoded['data'] : null; if (data is! List) return const []; return data.whereType>().toList(); } Future>> getNutritionists() async { final response = await _client .get(Uri.parse('$baseUrl/nutritionists'), headers: _headers()) .timeout(const Duration(seconds: 12)); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception( _messageFromBody(response.body, 'Gagal memuat ahli gizi.'), ); } final decoded = jsonDecode(response.body); final data = decoded is Map ? decoded['data'] : null; if (data is! List) return const []; return data.whereType>().toList(); } Future> openConsultationRoom({ required int childId, required String expertId, required String expertName, required String specialization, required String assetImage, required bool online, }) async { final response = await _client.post( Uri.parse('$baseUrl/consultation/rooms'), headers: _headers(), body: jsonEncode({ 'child_id': childId, 'expert_id': expertId, 'expert_name': expertName, 'specialization': specialization, 'asset_image': assetImage, 'online': online, }), ); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception('Gagal membuka room konsultasi: ${response.body}'); } final decoded = jsonDecode(response.body); if (decoded is! Map || decoded['data'] is! Map) { throw Exception('Format room konsultasi tidak valid.'); } return decoded['data'] as Map; } Future>> getConsultationMessages({ required int roomId, }) async { final response = await _client.get( Uri.parse('$baseUrl/consultation/rooms/$roomId/messages'), headers: _headers(), ); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception('Gagal mengambil messages konsultasi: ${response.body}'); } final decoded = jsonDecode(response.body); final data = decoded is Map ? decoded['data'] : null; if (data is! List) return const []; return data.whereType>().toList(); } Future> sendConsultationMessage({ required int roomId, required String message, int? measurementId, }) async { final body = {'message': message}; if (measurementId != null) { body['measurement_id'] = measurementId; } final response = await _client.post( Uri.parse('$baseUrl/consultation/rooms/$roomId/messages'), headers: _headers(), body: jsonEncode(body), ); debugPrint( '[sendConsultationMessage] status=${response.statusCode} body=${response.body}', ); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception('Gagal kirim pesan konsultasi: ${response.body}'); } final decoded = jsonDecode(response.body); if (decoded is! Map) { throw Exception('Format response kirim pesan tidak valid.'); } final data = decoded['data']; if (data is Map) return data; final status = decoded['status'] == true || decoded['success'] == true; if (status || decoded.containsKey('message')) return decoded; throw Exception('Format response kirim pesan tidak valid.'); } Future> sendConsultationExpertReply({ required int roomId, required String message, }) async { final response = await _client.post( Uri.parse('$baseUrl/consultation/rooms/$roomId/expert-reply'), headers: _headers(), body: jsonEncode({'message': message}), ); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception('Gagal kirim balasan ahli: ${response.body}'); } final decoded = jsonDecode(response.body); if (decoded is! Map || decoded['data'] is! Map) { throw Exception('Format response balasan ahli tidak valid.'); } return decoded['data'] as Map; } Future> getNutritionistDashboard() async { final response = await _client .get(Uri.parse('$baseUrl/nutritionist/dashboard'), headers: _headers()) .timeout(const Duration(seconds: 12)); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception( _messageFromBody(response.body, 'Gagal memuat dashboard ahli gizi.'), ); } final decoded = jsonDecode(response.body); if (decoded is! Map) { throw Exception('Format dashboard ahli gizi tidak valid.'); } return decoded; } Future> getNutritionistRoomMessages({ required int roomId, }) async { final response = await _client .get( Uri.parse('$baseUrl/nutritionist/rooms/$roomId/messages'), headers: _headers(), ) .timeout(const Duration(seconds: 12)); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception( _messageFromBody(response.body, 'Gagal memuat chat konsultasi.'), ); } final decoded = jsonDecode(response.body); if (decoded is! Map) { throw Exception('Format chat konsultasi tidak valid.'); } return decoded; } Future> sendNutritionistMessage({ required int roomId, required String message, }) async { final response = await _client.post( Uri.parse('$baseUrl/nutritionist/rooms/$roomId/messages'), headers: _headers(), body: jsonEncode({'message': message}), ); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception(_messageFromBody(response.body, 'Gagal mengirim pesan.')); } final decoded = jsonDecode(response.body); if (decoded is! Map) { throw Exception('Format kirim pesan tidak valid.'); } return decoded; } Future closeNutritionistConsultation({required int roomId}) async { final response = await _client.post( Uri.parse('$baseUrl/nutritionist/consultations/$roomId/close'), headers: _headers(), ); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception( _messageFromBody(response.body, 'Gagal menandai konsultasi selesai.'), ); } } Future updateConsultationRoomStatus({ required int roomId, required String status, }) async { final response = await _client.patch( Uri.parse('$baseUrl/consultation/rooms/$roomId/status'), headers: _headers(), body: jsonEncode({'status': status}), ); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception('Gagal update status konsultasi: ${response.body}'); } } Future markConsultationMeasurementShared({ required int roomId, required int measurementId, }) async { final response = await _client.patch( Uri.parse('$baseUrl/consultation/rooms/$roomId/shared-measurement'), headers: _headers(), body: jsonEncode({'measurement_id': measurementId}), ); if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception('Gagal menandai update perkembangan: ${response.body}'); } } Map _headers() { final token = SgiziAppState.instance.authToken; return { 'Content-Type': 'application/json', 'Accept': 'application/json', if (token != null) 'Authorization': 'Bearer $token', }; } }