This commit is contained in:
Dwi Swandhana
2026-07-22 16:33:42 +07:00
parent e04d43dfdf
commit 213270ede1
11 changed files with 889 additions and 37 deletions

No files matched your search

+24 -5
View File
@@ -1,11 +1,21 @@
import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
} }
val keystoreProperties =
Properties().apply {
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystorePropertiesFile.inputStream().use { load(it) }
}
}
android { android {
namespace = "com.example.mylis" namespace = "com.duidev.mylis"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
@@ -16,7 +26,7 @@ android {
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.mylis" applicationId = "com.duidev.mylis"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion
@@ -25,11 +35,20 @@ android {
versionName = flutter.versionName versionName = flutter.versionName
} }
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String?
keyPassword = keystoreProperties["keyPassword"] as String?
storeFile = (keystoreProperties["storeFile"] as String?)?.let {
file(it)
}
storePassword = keystoreProperties["storePassword"] as String?
}
}
buildTypes { buildTypes {
release { release {
// TODO: Add your own signing config for the release build. signingConfig = signingConfigs.getByName("release")
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
} }
} }
} }
+29
View File
@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "732589737888",
"project_id": "mylis-a4983",
"storage_bucket": "mylis-a4983.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:732589737888:android:3f782e07e62ace55a446c5",
"android_client_info": {
"package_name": "com.duidev.mylis"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyAibwJgxVnkVOOIGsjbf2SpW_isJOFTtAg"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
@@ -1,4 +1,4 @@
package com.example.mylis package com.duidev.mylis
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
File diff suppressed because one or more lines are too long
+6 -6
View File
@@ -383,7 +383,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.example.mylis; PRODUCT_BUNDLE_IDENTIFIER = com.duidev.mylis;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -399,7 +399,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.mylis.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.duidev.mylis.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -416,7 +416,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.mylis.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.duidev.mylis.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -431,7 +431,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.mylis.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.duidev.mylis.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -563,7 +563,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.example.mylis; PRODUCT_BUNDLE_IDENTIFIER = com.duidev.mylis;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -586,7 +586,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.example.mylis; PRODUCT_BUNDLE_IDENTIFIER = com.duidev.mylis;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
+10 -4
View File
@@ -20,11 +20,13 @@ class _LoginScreenState extends State<LoginScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
SessionStore.baseUrl().then((value) { SessionStore.session().then((value) {
if (!mounted) { if (!mounted) {
return; return;
} }
_baseUrl.text = value; _baseUrl.text = value.baseUrl;
_username.text = value.username ?? '';
_password.text = value.password ?? '';
}); });
} }
@@ -43,7 +45,6 @@ class _LoginScreenState extends State<LoginScreen> {
setState(() => _loading = true); setState(() => _loading = true);
try { try {
final serverUrl = normalizeBaseUrl(_baseUrl.text); final serverUrl = normalizeBaseUrl(_baseUrl.text);
await SessionStore.saveBaseUrl(serverUrl);
final api = ApiClient(baseUrl: serverUrl); final api = ApiClient(baseUrl: serverUrl);
final data = await api.post('api/mobile/login', { final data = await api.post('api/mobile/login', {
'username': _username.text.trim(), 'username': _username.text.trim(),
@@ -53,7 +54,12 @@ class _LoginScreenState extends State<LoginScreen> {
if (token == null || token.isEmpty) { if (token == null || token.isEmpty) {
throw ApiException('Token login tidak diterima dari server.'); throw ApiException('Token login tidak diterima dari server.');
} }
await SessionStore.saveToken(token); await SessionStore.saveLogin(
baseUrl: serverUrl,
token: token,
username: _username.text.trim(),
password: _password.text,
);
if (!mounted) { if (!mounted) {
return; return;
} }
+106 -15
View File
@@ -4,7 +4,7 @@ class ApiClient {
ApiClient({required this.baseUrl, this.token}); ApiClient({required this.baseUrl, this.token});
final String baseUrl; final String baseUrl;
final String? token; String? token;
Uri _uri(String path, [Map<String, String>? query]) { Uri _uri(String path, [Map<String, String>? query]) {
final normalizedBase = normalizeBaseUrl(baseUrl); final normalizedBase = normalizeBaseUrl(baseUrl);
@@ -25,12 +25,55 @@ class ApiClient {
String path, String path,
Map<String, dynamic> body, Map<String, dynamic> body,
) async { ) async {
final uri = _uri(path); return _sendWithRenew(
path,
(client, uri) => client
.post(uri, headers: _headers, body: jsonEncode(body))
.timeout(const Duration(seconds: 15)),
);
}
Future<Map<String, dynamic>> get(
String path, [
Map<String, String>? query,
]) async {
return _sendWithRenew(
path,
(client, uri) => client
.get(uri, headers: _headers)
.timeout(const Duration(seconds: 15)),
query,
);
}
Future<Map<String, dynamic>> _sendWithRenew(
String path,
Future<http.Response> Function(http.Client client, Uri uri) send, [
Map<String, String>? 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<Map<String, dynamic>> _sendOnce(
Uri uri,
Future<http.Response> Function(http.Client client, Uri uri) send,
) async {
final client = _clientFor(uri); final client = _clientFor(uri);
try { try {
final response = await client final response = await send(client, uri);
.post(uri, headers: _headers, body: jsonEncode(body))
.timeout(const Duration(seconds: 15));
return _decode(response, uri); return _decode(response, uri);
} catch (error, stackTrace) { } catch (error, stackTrace) {
throw _connectionException(uri, error, stackTrace); throw _connectionException(uri, error, stackTrace);
@@ -39,19 +82,64 @@ class ApiClient {
} }
} }
Future<Map<String, dynamic>> get( bool _canRenew(String path, ApiException error) {
String path, [ return token != null &&
Map<String, String>? query, error.statusCode == 401 &&
]) async { path != 'api/mobile/login' &&
final uri = _uri(path, query); !path.endsWith('/login');
final client = _clientFor(uri); }
Future<bool> _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 { try {
debugPrint(
'MyLIS session expired untuk $failedUri; mencoba renew token.',
);
final response = await client final response = await client
.get(uri, headers: _headers) .post(
loginUri,
headers: const {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: jsonEncode({
'username': credentials.username,
'password': credentials.password,
}),
)
.timeout(const Duration(seconds: 15)); .timeout(const Duration(seconds: 15));
return _decode(response, uri); 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) { } catch (error, stackTrace) {
throw _connectionException(uri, error, stackTrace); debugPrint('MyLIS renew token gagal tidak terduga: $error');
debugPrintStack(stackTrace: stackTrace);
return false;
} finally { } finally {
client.close(); client.close();
} }
@@ -95,6 +183,7 @@ class ApiClient {
'Server mengembalikan HTTP ${response.statusCode}. ' 'Server mengembalikan HTTP ${response.statusCode}. '
'URL: $uri. Kemungkinan request masuk ke virtual host/proxy yang salah. ' 'URL: $uri. Kemungkinan request masuk ke virtual host/proxy yang salah. '
'Detail awal respons: $bodyPreview', 'Detail awal respons: $bodyPreview',
statusCode: response.statusCode,
); );
} }
@@ -112,6 +201,7 @@ class ApiClient {
throw ApiException( throw ApiException(
'Respons server tidak bisa dibaca. ' 'Respons server tidak bisa dibaca. '
'URL: $uri. Status HTTP: ${response.statusCode}.', 'URL: $uri. Status HTTP: ${response.statusCode}.',
statusCode: response.statusCode,
); );
} }
@@ -194,6 +284,7 @@ class ApiClient {
} }
class ApiException implements Exception { class ApiException implements Exception {
ApiException(this.message); ApiException(this.message, {this.statusCode});
final String message; final String message;
final int? statusCode;
} }
+45 -1
View File
@@ -3,6 +3,8 @@ part of '../app/app.dart';
class SessionStore { class SessionStore {
static const _tokenKey = 'mylis_token'; static const _tokenKey = 'mylis_token';
static const _baseUrlKey = 'mylis_base_url'; static const _baseUrlKey = 'mylis_base_url';
static const _usernameKey = 'mylis_username';
static const _passwordKey = 'mylis_password';
static Future<String?> token() async { static Future<String?> token() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -19,6 +21,8 @@ class SessionStore {
return SessionData( return SessionData(
token: prefs.getString(_tokenKey), token: prefs.getString(_tokenKey),
baseUrl: prefs.getString(_baseUrlKey) ?? kDefaultBaseUrl, baseUrl: prefs.getString(_baseUrlKey) ?? kDefaultBaseUrl,
username: prefs.getString(_usernameKey),
password: prefs.getString(_passwordKey),
); );
} }
@@ -27,6 +31,32 @@ class SessionStore {
await prefs.setString(_tokenKey, token); await prefs.setString(_tokenKey, token);
} }
static Future<void> saveLogin({
required String baseUrl,
required String token,
required String username,
required String password,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_baseUrlKey, normalizeBaseUrl(baseUrl));
await prefs.setString(_tokenKey, token);
await prefs.setString(_usernameKey, username);
await prefs.setString(_passwordKey, password);
}
static Future<StoredCredentials?> credentials() async {
final prefs = await SharedPreferences.getInstance();
final username = prefs.getString(_usernameKey);
final password = prefs.getString(_passwordKey);
if (username == null ||
username.trim().isEmpty ||
password == null ||
password.isEmpty) {
return null;
}
return StoredCredentials(username: username, password: password);
}
static Future<void> saveBaseUrl(String baseUrl) async { static Future<void> saveBaseUrl(String baseUrl) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString(_baseUrlKey, normalizeBaseUrl(baseUrl)); await prefs.setString(_baseUrlKey, normalizeBaseUrl(baseUrl));
@@ -39,10 +69,24 @@ class SessionStore {
} }
class SessionData { class SessionData {
const SessionData({this.token, this.baseUrl = kDefaultBaseUrl}); const SessionData({
this.token,
this.baseUrl = kDefaultBaseUrl,
this.username,
this.password,
});
final String? token; final String? token;
final String baseUrl; final String baseUrl;
final String? username;
final String? password;
}
class StoredCredentials {
const StoredCredentials({required this.username, required this.password});
final String username;
final String password;
} }
String normalizeBaseUrl(String value) { String normalizeBaseUrl(String value) {
+1 -1
View File
@@ -7,7 +7,7 @@ project(runner LANGUAGES CXX)
set(BINARY_NAME "mylis") set(BINARY_NAME "mylis")
# The unique GTK application identifier for this application. See: # The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID # https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "com.example.mylis") set(APPLICATION_ID "com.duidev.mylis")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent # Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake. # versions of CMake.
+3 -3
View File
@@ -395,7 +395,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.mylis.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.duidev.mylis.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/mylis.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/mylis"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/mylis.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/mylis";
@@ -409,7 +409,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.mylis.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.duidev.mylis.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/mylis.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/mylis"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/mylis.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/mylis";
@@ -423,7 +423,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.mylis.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.duidev.mylis.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/mylis.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/mylis"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/mylis.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/mylis";
+1 -1
View File
@@ -8,7 +8,7 @@
PRODUCT_NAME = MyLIS PRODUCT_NAME = MyLIS
// The application's bundle identifier // The application's bundle identifier
PRODUCT_BUNDLE_IDENTIFIER = com.example.mylis PRODUCT_BUNDLE_IDENTIFIER = com.duidev.mylis
// The copyright displayed in application information // The copyright displayed in application information
PRODUCT_COPYRIGHT = Copyright © 2026 Rumah Sakit Umum Daerah Dr. Saiful Anwar. All rights reserved. PRODUCT_COPYRIGHT = Copyright © 2026 Rumah Sakit Umum Daerah Dr. Saiful Anwar. All rights reserved.