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
+24 -5
View File
@@ -1,11 +1,21 @@
import java.util.Properties
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
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 {
namespace = "com.example.mylis"
namespace = "com.duidev.mylis"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
@@ -16,7 +26,7 @@ android {
defaultConfig {
// 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.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
@@ -25,11 +35,20 @@ android {
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 {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
signingConfig = signingConfigs.getByName("release")
}
}
}
+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
File diff suppressed because one or more lines are too long
+6 -6
View File
@@ -383,7 +383,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.mylis;
PRODUCT_BUNDLE_IDENTIFIER = com.duidev.mylis;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@@ -399,7 +399,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.mylis.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.duidev.mylis.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -416,7 +416,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.mylis.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.duidev.mylis.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -431,7 +431,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.mylis.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.duidev.mylis.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -563,7 +563,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.mylis;
PRODUCT_BUNDLE_IDENTIFIER = com.duidev.mylis;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -586,7 +586,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.mylis;
PRODUCT_BUNDLE_IDENTIFIER = com.duidev.mylis;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
+10 -4
View File
@@ -20,11 +20,13 @@ class _LoginScreenState extends State<LoginScreen> {
@override
void initState() {
super.initState();
SessionStore.baseUrl().then((value) {
SessionStore.session().then((value) {
if (!mounted) {
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);
try {
final serverUrl = normalizeBaseUrl(_baseUrl.text);
await SessionStore.saveBaseUrl(serverUrl);
final api = ApiClient(baseUrl: serverUrl);
final data = await api.post('api/mobile/login', {
'username': _username.text.trim(),
@@ -53,7 +54,12 @@ class _LoginScreenState extends State<LoginScreen> {
if (token == null || token.isEmpty) {
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) {
return;
}
+106 -15
View File
@@ -4,7 +4,7 @@ class ApiClient {
ApiClient({required this.baseUrl, this.token});
final String baseUrl;
final String? token;
String? token;
Uri _uri(String path, [Map<String, String>? query]) {
final normalizedBase = normalizeBaseUrl(baseUrl);
@@ -25,12 +25,55 @@ class ApiClient {
String path,
Map<String, dynamic> body,
) 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);
try {
final response = await client
.post(uri, headers: _headers, body: jsonEncode(body))
.timeout(const Duration(seconds: 15));
final response = await send(client, uri);
return _decode(response, uri);
} catch (error, stackTrace) {
throw _connectionException(uri, error, stackTrace);
@@ -39,19 +82,64 @@ class ApiClient {
}
}
Future<Map<String, dynamic>> get(
String path, [
Map<String, String>? query,
]) async {
final uri = _uri(path, query);
final client = _clientFor(uri);
bool _canRenew(String path, ApiException error) {
return token != null &&
error.statusCode == 401 &&
path != 'api/mobile/login' &&
!path.endsWith('/login');
}
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 {
debugPrint(
'MyLIS session expired untuk $failedUri; mencoba renew token.',
);
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));
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) {
throw _connectionException(uri, error, stackTrace);
debugPrint('MyLIS renew token gagal tidak terduga: $error');
debugPrintStack(stackTrace: stackTrace);
return false;
} finally {
client.close();
}
@@ -95,6 +183,7 @@ class ApiClient {
'Server mengembalikan HTTP ${response.statusCode}. '
'URL: $uri. Kemungkinan request masuk ke virtual host/proxy yang salah. '
'Detail awal respons: $bodyPreview',
statusCode: response.statusCode,
);
}
@@ -112,6 +201,7 @@ class ApiClient {
throw ApiException(
'Respons server tidak bisa dibaca. '
'URL: $uri. Status HTTP: ${response.statusCode}.',
statusCode: response.statusCode,
);
}
@@ -194,6 +284,7 @@ class ApiClient {
}
class ApiException implements Exception {
ApiException(this.message);
ApiException(this.message, {this.statusCode});
final String message;
final int? statusCode;
}
+45 -1
View File
@@ -3,6 +3,8 @@ part of '../app/app.dart';
class SessionStore {
static const _tokenKey = 'mylis_token';
static const _baseUrlKey = 'mylis_base_url';
static const _usernameKey = 'mylis_username';
static const _passwordKey = 'mylis_password';
static Future<String?> token() async {
final prefs = await SharedPreferences.getInstance();
@@ -19,6 +21,8 @@ class SessionStore {
return SessionData(
token: prefs.getString(_tokenKey),
baseUrl: prefs.getString(_baseUrlKey) ?? kDefaultBaseUrl,
username: prefs.getString(_usernameKey),
password: prefs.getString(_passwordKey),
);
}
@@ -27,6 +31,32 @@ class SessionStore {
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 {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_baseUrlKey, normalizeBaseUrl(baseUrl));
@@ -39,10 +69,24 @@ class SessionStore {
}
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 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) {
+1 -1
View File
@@ -7,7 +7,7 @@ project(runner LANGUAGES CXX)
set(BINARY_NAME "mylis")
# The unique GTK application identifier for this application. See:
# 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
# versions of CMake.
+3 -3
View File
@@ -395,7 +395,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.mylis.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.duidev.mylis.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/mylis.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/mylis";
@@ -409,7 +409,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.mylis.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.duidev.mylis.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/mylis.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/mylis";
@@ -423,7 +423,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.mylis.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.duidev.mylis.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/mylis.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/mylis";
+1 -1
View File
@@ -8,7 +8,7 @@
PRODUCT_NAME = MyLIS
// The application's bundle identifier
PRODUCT_BUNDLE_IDENTIFIER = com.example.mylis
PRODUCT_BUNDLE_IDENTIFIER = com.duidev.mylis
// The copyright displayed in application information
PRODUCT_COPYRIGHT = Copyright © 2026 Rumah Sakit Umum Daerah Dr. Saiful Anwar. All rights reserved.