first commit

This commit is contained in:
2026-04-29 12:53:22 +07:00
commit e6a30eddd3
394 changed files with 16408 additions and 0 deletions

View File

@@ -0,0 +1,295 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:frontend_eccp_mobile/app/core/constants/constants.dart';
import 'package:frontend_eccp_mobile/app/core/navigation/app_resume_once.dart';
import 'package:frontend_eccp_mobile/app/core/navigation/settings_navigator.dart';
import 'package:frontend_eccp_mobile/app/core/services/location_permission_service.dart';
import 'package:internet_connection_checker/internet_connection_checker.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
typedef BottomSheetBuilder = Widget Function(BuildContext context);
typedef LocationSheetBuilder =
Widget Function(
BuildContext context, {
required VoidCallback onOpenSettings,
VoidCallback? onRequestNow,
});
class BottomSheetService {
BottomSheetService._();
static bool _isShown = false;
static BottomSheetBuilder? _noInternetBuilder;
static BottomSheetBuilder? _serverErrorBuilder;
static BottomSheetBuilder? _notFoundBuilder;
static BottomSheetBuilder? _sessionExpiredBuilder;
static LocationSheetBuilder? _locationPermissionBuilder;
static StreamSubscription<InternetConnectionStatus>? _connSub;
static void configure({
BottomSheetBuilder? noInternetBuilder,
BottomSheetBuilder? serverErrorBuilder,
BottomSheetBuilder? notFoundBuilder,
BottomSheetBuilder? sessionExpiredBuilder,
LocationSheetBuilder? locationPermissionBuilder,
}) {
_noInternetBuilder = noInternetBuilder;
_serverErrorBuilder = serverErrorBuilder;
_notFoundBuilder = notFoundBuilder;
_sessionExpiredBuilder = sessionExpiredBuilder;
_locationPermissionBuilder = locationPermissionBuilder;
}
static Future<void> showNoInternet() => _show(
_noInternetBuilder,
_defaultNoInternet,
autoDismissOnReconnect: true,
);
static Future<void> showServerError() =>
_show(_serverErrorBuilder, _defaultServerError);
static Future<void> showNotFound() =>
_show(_notFoundBuilder, _defaultNotFound);
static Future<void> showSessionExpired() => _show(
_sessionExpiredBuilder,
_defaultSessionExpired,
isDismissible: false,
);
static Future<void> showCustom(
BottomSheetBuilder builder, {
bool isDismissible = true,
}) {
return _show(builder, builder, isDismissible: isDismissible);
}
static Future<T?> showCustomReturn<T>(
Widget Function(BuildContext) builder, {
bool isDismissible = true,
}) async {
return showModalBottomSheet<T>(
context: navigatorKey.currentContext!,
isDismissible: isDismissible,
isScrollControlled: true,
backgroundColor: Colors.transparent,
enableDrag: isDismissible,
useRootNavigator: true,
builder: builder,
);
}
static Future<void> showLocationPermission({
required VoidCallback onOpenSettings,
VoidCallback? onRequestNow,
}) {
final custom = _locationPermissionBuilder == null
? null
: (BuildContext ctx) => _locationPermissionBuilder!(
ctx,
onOpenSettings: onOpenSettings,
onRequestNow: onRequestNow,
);
return _show(
custom,
(ctx) => _defaultLocationPermission(
ctx,
onOpenSettings: onOpenSettings,
onRequestNow: onRequestNow,
),
);
}
static Future<void> _show(
BottomSheetBuilder? custom,
BottomSheetBuilder fallback, {
bool autoDismissOnReconnect = false,
bool isDismissible = true,
}) async {
final navState = navigatorKey.currentState;
final overlayContext = navState?.overlay?.context ?? navState?.context;
if (overlayContext == null || _isShown) return;
_isShown = true;
if (autoDismissOnReconnect) {
await _connSub?.cancel();
_connSub = InternetConnectionChecker.instance.onStatusChange.listen(
(status) {
if (status == InternetConnectionStatus.connected && _isShown) {
final navigator = navigatorKey.currentState;
if (navigator != null && navigator.canPop()) {
navigator.pop();
}
}
},
);
}
try {
if (!overlayContext.mounted) return;
await showMaterialModalBottomSheet<void>(
context: overlayContext,
backgroundColor: Colors.transparent,
isDismissible: isDismissible,
enableDrag: isDismissible,
useRootNavigator: true,
duration: AppDurations.modalTransition,
builder: (ctx) {
return WillPopScope(
onWillPop: () async => isDismissible,
child: SafeArea(
top: false,
bottom: false,
child: custom != null ? custom(ctx) : fallback(ctx),
),
);
},
);
} finally {
_isShown = false;
await _connSub?.cancel();
_connSub = null;
}
}
static Widget _defaultNoInternet(BuildContext context) => Padding(
padding: AppPadding.p24,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.wifi_off, size: AppIconSizes.xxl),
AppSpacing.h12,
Text('Tidak ada internet', style: AppTextStyles.h3),
AppSpacing.h8,
Text(
'Periksa koneksi kamu lalu coba lagi.',
style: AppTextStyles.small,
textAlign: TextAlign.center,
),
],
),
);
static Widget _defaultServerError(BuildContext context) => Padding(
padding: AppPadding.p24,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off, size: AppIconSizes.xxl),
AppSpacing.h12,
Text('Gangguan server', style: AppTextStyles.h3),
AppSpacing.h8,
Text(
'Coba beberapa saat lagi.',
style: AppTextStyles.small,
textAlign: TextAlign.center,
),
],
),
);
static Widget _defaultNotFound(BuildContext context) => Padding(
padding: AppPadding.p24,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off, size: AppIconSizes.xxl),
AppSpacing.h12,
Text('Opps, kamu tersesat', style: AppTextStyles.h3),
AppSpacing.h8,
Text(
'Baik, dimengerti. Mari kembali ke jalur semula.',
style: AppTextStyles.small,
textAlign: TextAlign.center,
),
],
),
);
static Widget _defaultSessionExpired(BuildContext context) => Padding(
padding: AppPadding.p24,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.lock_outline, size: AppIconSizes.xxl),
AppSpacing.h12,
Text('Sesi berakhir', style: AppTextStyles.h3),
AppSpacing.h8,
Text(
'Silakan login ulang untuk melanjutkan.',
style: AppTextStyles.small,
textAlign: TextAlign.center,
),
AppSpacing.h16,
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Login Ulang', style: AppTextStyles.button),
),
),
],
),
);
static Widget _defaultLocationPermission(
BuildContext context, {
required VoidCallback onOpenSettings,
VoidCallback? onRequestNow,
}) {
return Padding(
padding: AppPadding.p24,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.my_location, size: AppIconSizes.xxl),
AppSpacing.h12,
Text('Permintaan Akses Lokasi', style: AppTextStyles.h3),
AppSpacing.h8,
Text(
'Aplikasi ingin meminta akses lokasi kamu agar dapat memberikan pengalaman terbaik. Kamu dapat mengizinkan akses lokasi melalui pengaturan atau langsung sekarang.',
style: AppTextStyles.small,
textAlign: TextAlign.center,
),
AppSpacing.h16,
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () async {
final resumeFut = waitRecheckOnNextResume(() async {
if (await LocationPermissionService.recheckGranted()) {
onOpenSettings();
}
});
await SettingsNavigator.openAppSettings();
await resumeFut;
},
child: Text('Buka Pengaturan', style: AppTextStyles.button),
),
),
if (onRequestNow != null) ...[
AppSpacing.h8,
TextButton(
onPressed: () {
Navigator.of(context).pop();
onRequestNow();
},
child: Text('Izinkan Sekarang', style: AppTextStyles.label),
),
],
],
),
);
}
}

View File

@@ -0,0 +1,75 @@
import 'dart:async';
import 'dart:developer' as developer;
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/foundation.dart';
import 'package:frontend_eccp_mobile/app/core/config/app_flavor.dart';
class CrashlyticsService {
CrashlyticsService._();
static FirebaseCrashlytics get _instance => FirebaseCrashlytics.instance;
static Future<void> init() async {
await _instance.setCrashlyticsCollectionEnabled(
Flavor.isProd,
);
if (kDebugMode) return;
FlutterError.onError = (FlutterErrorDetails details) async {
await _instance.recordFlutterFatalError(details);
};
PlatformDispatcher.instance.onError = (error, stack) {
unawaited(_instance.recordError(error, stack, fatal: true));
return true;
};
}
static Future<void> log(String message) async {
if (kDebugMode) return;
await _instance.log(message);
}
static Future<void> recordError(
Object error,
StackTrace stack, {
String? reason,
bool fatal = false,
Map<String, dynamic>? context,
}) async {
if (kDebugMode) return;
if (context != null) {
context.forEach((key, value) async {
await _instance.setCustomKey(key, value.toString());
});
}
developer.log('CrashlyticsService.recordError: $reason');
await _instance.recordError(
error,
stack,
reason: reason,
fatal: fatal,
);
}
static Future<void> setUser({
required String id,
String? nrp,
String? name,
}) async {
if (kDebugMode) return;
await _instance.setUserIdentifier(id);
if (nrp != null) {
await _instance.setCustomKey('nrp', nrp);
}
if (name != null) {
await _instance.setCustomKey('name', name);
}
}
static Future<void> clearUser() async {
if (kDebugMode) return;
await _instance.setUserIdentifier('');
}
}

View File

@@ -0,0 +1,56 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:file_picker/file_picker.dart';
class FileDownloader {
static Future<String?> downloadAndSave({
required String url,
required String fileName,
void Function(int received, int total)? onProgress,
}) async {
final dio = Dio();
final response = await dio.get<List<int>>(
url,
options: Options(responseType: ResponseType.bytes),
onReceiveProgress: onProgress,
);
final bytes = response.data;
if (bytes == null) return null;
final savedPath = await FilePicker.saveFile(
dialogTitle: 'Save File',
fileName: fileName,
type: FileType.custom,
allowedExtensions: ['pdf'],
bytes: Uint8List.fromList(bytes),
);
return savedPath;
}
static Future<Map<String, dynamic>> getFileInfo(String url) async {
final dio = Dio();
final response = await dio.head<dynamic>(url);
final headers = response.headers;
final size = headers.value('content-length');
final sizeInBytes = int.tryParse(size ?? '0') ?? 0;
final sizeInMB = sizeInBytes / (1024 * 1024);
var fileName = url.split('/').last;
final disposition = headers.value('content-disposition');
if (disposition != null && disposition.contains('filename=')) {
fileName = disposition.split('filename=').last.replaceAll('"', '');
}
return {
'name': fileName,
'sizeMB': sizeInMB,
'sizeBytes': sizeInBytes,
};
}
}

View File

@@ -0,0 +1,21 @@
import 'package:image_picker/image_picker.dart';
class ImagePickerService {
ImagePickerService._();
static final ImagePicker _picker = ImagePicker();
static Future<XFile?> pickFromGallery() async {
return _picker.pickImage(
source: ImageSource.gallery,
imageQuality: 85,
);
}
static Future<XFile?> pickFromCamera() async {
return _picker.pickImage(
source: ImageSource.camera,
imageQuality: 85,
);
}
}

View File

@@ -0,0 +1,153 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:frontend_eccp_mobile/app/core/constants/enum/location_status_enum.dart';
import 'package:frontend_eccp_mobile/app/core/services/bottom_sheet_service.dart';
import 'package:frontend_eccp_mobile/app/core/utils/token_manager.dart';
import 'package:geolocator/geolocator.dart';
class LocationPermissionService {
LocationPermissionService._();
static Future<SimpleLocationStatus> checkStatus() async {
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) return SimpleLocationStatus.serviceDisabled;
final perm = await Geolocator.checkPermission();
switch (perm) {
case LocationPermission.always:
case LocationPermission.whileInUse:
return SimpleLocationStatus.granted;
case LocationPermission.denied:
case LocationPermission.unableToDetermine:
return SimpleLocationStatus.denied;
case LocationPermission.deniedForever:
return SimpleLocationStatus.deniedForever;
}
}
static bool _requesting = false;
static Completer<void>? _waiter;
static Future<T> _withLock<T>(Future<T> Function() body) async {
while (_requesting) {
_waiter ??= Completer<void>();
await _waiter!.future;
}
_requesting = true;
try {
return await body();
} finally {
_requesting = false;
_waiter?.complete();
_waiter = null;
}
}
static Future<bool> request() => _withLock<bool>(() async {
var p = await Geolocator.checkPermission();
if (p == LocationPermission.always || p == LocationPermission.whileInUse) {
return true;
}
if (p == LocationPermission.deniedForever) {
return false;
}
p = await Geolocator.requestPermission();
return p == LocationPermission.always || p == LocationPermission.whileInUse;
});
static Future<void> ensure({
required VoidCallback onGranted,
VoidCallback? onNotGranted,
bool showSheetIfDenied = true,
bool returnImmediatelyOnDenied = false,
String? onlyOnceKey,
}) async {
final suppressSheet =
(showSheetIfDenied && onlyOnceKey != null) &&
await TokenManager.getOneTimeFlag(onlyOnceKey);
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
onNotGranted?.call();
if (showSheetIfDenied && !suppressSheet) {
final f = _showGoToSettingsSheet(
forServiceDisabled: true,
onAfterReturn: onGranted,
onlyOnceKey: onlyOnceKey,
allowRequestNow: false,
);
if (returnImmediatelyOnDenied) {
unawaited(f);
return;
}
await f;
}
return;
}
final grantedNow = await request();
if (grantedNow) {
onGranted();
return;
}
onNotGranted?.call();
if (showSheetIfDenied && !suppressSheet) {
final f = _showGoToSettingsSheet(
forServiceDisabled: false,
onAfterReturn: onGranted,
onlyOnceKey: onlyOnceKey,
);
if (returnImmediatelyOnDenied) {
unawaited(f);
return;
}
await f;
}
}
static Future<void> showGoToSettingsSheet({
required VoidCallback onAfterReturn,
bool forServiceDisabled = false,
bool allowRequestNow = true,
String? onlyOnceKey,
}) async {
await _showGoToSettingsSheet(
onAfterReturn: onAfterReturn,
forServiceDisabled: forServiceDisabled,
allowRequestNow: allowRequestNow,
onlyOnceKey: onlyOnceKey,
);
}
static Future<bool> recheckGranted() async =>
(await checkStatus()) == SimpleLocationStatus.granted;
static Future<void> _showGoToSettingsSheet({
required VoidCallback onAfterReturn,
required bool forServiceDisabled,
bool allowRequestNow = true,
String? onlyOnceKey,
}) async {
if (onlyOnceKey != null) {
await TokenManager.setOneTimeFlag(onlyOnceKey, value: true);
}
await BottomSheetService.showLocationPermission(
onOpenSettings: () async {
await Future<dynamic>.delayed(const Duration(milliseconds: 100));
if (await recheckGranted()) onAfterReturn();
},
onRequestNow: allowRequestNow
? () async {
final ok = await request();
if (ok) onAfterReturn();
}
: null,
);
}
}

View File

@@ -0,0 +1,47 @@
import 'dart:convert';
import 'package:firebase_remote_config/firebase_remote_config.dart';
import 'package:frontend_eccp_mobile/app/core/config/app_flavor.dart';
import 'package:frontend_eccp_mobile/app/core/config/app_update_config.dart';
import 'package:frontend_eccp_mobile/app/core/services/crashlytics_service.dart';
class RemoteConfigService {
RemoteConfigService._();
static final FirebaseRemoteConfig _rc = FirebaseRemoteConfig.instance;
static Future<void> init() async {
try {
await _rc.setConfigSettings(
RemoteConfigSettings(
fetchTimeout: const Duration(seconds: 10),
minimumFetchInterval: const Duration(minutes: 1),
),
);
await _rc.fetchAndActivate();
} catch (e, s) {
await CrashlyticsService.recordError(
e,
s,
reason: 'RemoteConfig init failed',
);
}
}
static Future<AppUpdateConfig?> getUpdateConfig() async {
try {
final raw = _rc.getString(Flavor.name);
if (raw.isEmpty) return null;
final json = jsonDecode(raw) as Map<String, dynamic>;
return AppUpdateConfig.fromJson(json);
} catch (e, s) {
await CrashlyticsService.recordError(
e,
s,
reason: 'Invalid update config JSON',
);
return null;
}
}
}