part of '../app/app.dart'; class ApiClient { ApiClient({required this.baseUrl, this.token}); final String baseUrl; String? token; Uri _uri(String path, [Map? query]) { final normalizedBase = normalizeBaseUrl(baseUrl); final base = Uri.parse(normalizedBase); return base.replace( path: '${base.path.replaceAll(RegExp(r'/$'), '')}/$path', queryParameters: query, ); } Map get _headers => { 'Accept': 'application/json', 'Content-Type': 'application/json', if (token != null) 'Authorization': 'Bearer $token', }; Future> post( String path, Map body, ) async { return _sendWithRenew( path, (client, uri) => client .post(uri, headers: _headers, body: jsonEncode(body)) .timeout(const Duration(seconds: 15)), ); } Future> get( String path, [ Map? query, ]) async { return _sendWithRenew( path, (client, uri) => client .get(uri, headers: _headers) .timeout(const Duration(seconds: 15)), query, ); } Future> _sendWithRenew( String path, Future Function(http.Client client, Uri uri) send, [ Map? query, ]) async { final uri = _uri(path, query); try { return await _sendOnce(uri, send); } on ApiException catch (error) { if (!_canRenew(path, error)) { rethrow; } final renewed = await _renewToken(uri); if (!renewed) { rethrow; } debugPrint('MyLIS session diperbarui, mengulang request: $uri'); return _sendOnce(uri, send); } } Future> _sendOnce( Uri uri, Future Function(http.Client client, Uri uri) send, ) async { final client = _clientFor(uri); try { final response = await send(client, uri); return _decode(response, uri); } catch (error, stackTrace) { throw _connectionException(uri, error, stackTrace); } finally { client.close(); } } bool _canRenew(String path, ApiException error) { return token != null && error.statusCode == 401 && path != 'api/mobile/login' && !path.endsWith('/login'); } Future _renewToken(Uri failedUri) async { final credentials = await SessionStore.credentials(); if (credentials == null) { debugPrint( 'MyLIS session expired untuk $failedUri; kredensial tersimpan tidak ada.', ); return false; } final loginUri = _uri('api/mobile/login'); final client = _clientFor(loginUri); try { debugPrint( 'MyLIS session expired untuk $failedUri; mencoba renew token.', ); final response = await client .post( loginUri, headers: const { 'Accept': 'application/json', 'Content-Type': 'application/json', }, body: jsonEncode({ 'username': credentials.username, 'password': credentials.password, }), ) .timeout(const Duration(seconds: 15)); final data = _decode(response, loginUri); final token = data['token']?.toString() ?? ''; if (token.isEmpty) { debugPrint( 'MyLIS renew token gagal: respons login tidak berisi token.', ); return false; } this.token = token; await SessionStore.saveLogin( baseUrl: baseUrl, token: token, username: credentials.username, password: credentials.password, ); return true; } on ApiException catch (error) { debugPrint('MyLIS renew token gagal: ${error.message}'); return false; } catch (error, stackTrace) { debugPrint('MyLIS renew token gagal tidak terduga: $error'); debugPrintStack(stackTrace: stackTrace); return false; } finally { client.close(); } } http.Client _clientFor(Uri uri) { final client = HttpClient() ..badCertificateCallback = (certificate, host, port) { debugPrint( 'MyLIS menerima sertifikat internal untuk $host:$port ' 'issuer=${certificate.issuer}', ); return true; }; return IOClient(client); } Map _decode(http.Response response, Uri uri) { final bodyPreview = response.body.length > 240 ? '${response.body.substring(0, 240)}...' : response.body; if (response.statusCode < 200 || response.statusCode >= 300) { Object? decoded; if (_looksLikeJson(response)) { try { decoded = response.body.isEmpty ? {} : jsonDecode(response.body); } catch (_) { decoded = null; } } final message = decoded is Map ? decoded['message'] : null; debugPrint( 'MyLIS API gagal: ${response.request?.method ?? '-'} $uri ' 'HTTP ${response.statusCode}; body=$bodyPreview', ); throw ApiException( message?.toString() ?? 'Server mengembalikan HTTP ${response.statusCode}. ' 'URL: $uri. Kemungkinan request masuk ke virtual host/proxy yang salah. ' 'Detail awal respons: $bodyPreview', statusCode: response.statusCode, ); } final Object decoded; try { decoded = response.body.isEmpty ? {} : jsonDecode(response.body); } catch (error, stackTrace) { debugPrint( 'MyLIS API decode gagal: ${response.request?.method ?? '-'} $uri ' 'HTTP ${response.statusCode}; error=$error', ); debugPrintStack(stackTrace: stackTrace); throw ApiException( 'Respons server tidak bisa dibaca. ' 'URL: $uri. Status HTTP: ${response.statusCode}.', statusCode: response.statusCode, ); } if (response.statusCode >= 200 && response.statusCode < 300) { final cleanDecoded = _decodeHtmlEntitiesIn(decoded); return cleanDecoded is Map ? cleanDecoded : {'data': cleanDecoded}; } return {'data': _decodeHtmlEntitiesIn(decoded)}; } bool _looksLikeJson(http.Response response) { final contentType = response.headers['content-type']?.toLowerCase() ?? ''; final body = response.body.trimLeft(); return contentType.contains('json') || body.startsWith('{') || body.startsWith('['); } ApiException _connectionException( Uri uri, Object error, StackTrace stackTrace, ) { if (error is ApiException) { return error; } final reason = _connectionReason(error); debugPrint('MyLIS koneksi gagal: $uri; $reason; error=$error'); debugPrintStack(stackTrace: stackTrace); return ApiException('Koneksi server gagal. URL: $uri. Penyebab: $reason'); } String _connectionReason(Object error) { if (error is TimeoutException) { return 'timeout lebih dari 15 detik. Periksa jaringan, DNS lokal, atau server Laravel belum aktif.'; } if (error is http.ClientException) { final message = error.message.toLowerCase(); if (message.contains('certificate') || message.contains('handshake') || message.contains('tls')) { return 'sertifikat HTTPS lokal belum dipercaya oleh device/simulator.'; } if (message.contains('failed host lookup') || message.contains('nodename') || message.contains('name or service')) { return 'host tidak ditemukan. Periksa URL atau DNS/hosts lokal.'; } if (message.contains('connection refused')) { return 'server menolak koneksi. Periksa apakah Laravel/web server aktif.'; } if (message.contains('connection closed')) { return 'koneksi ditutup oleh server.'; } return 'client HTTP gagal: ${error.message}'; } if (error is FormatException) { return 'format URL atau respons tidak valid: ${error.message}'; } return error.toString(); } Object? _decodeHtmlEntitiesIn(Object? value) { if (value is String) { return decodeHtmlEntities(value); } if (value is List) { return value.map(_decodeHtmlEntitiesIn).toList(); } if (value is Map) { return value.map( (key, item) => MapEntry(key.toString(), _decodeHtmlEntitiesIn(item)), ); } return value; } } class ApiException implements Exception { ApiException(this.message, {this.statusCode}); final String message; final int? statusCode; }