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

1
lib/app/app.dart Normal file
View File

@@ -0,0 +1 @@
export 'view/app.dart';

View File

@@ -0,0 +1,30 @@
import 'package:frontend_eccp_mobile/app/core/config/app_flavor.dart';
class AppEnvironment {
AppEnvironment._();
static String get baseUrl {
if (Flavor.isProd) {
return 'https://project.adhivasindo.co.id/api_eccp';
}
if (Flavor.isStaging) {
return 'https://project.adhivasindo.co.id/api_eccp';
}
return 'https://project.adhivasindo.co.id/api_eccp';
}
static String get imageBaseUrl {
if (Flavor.isProd) {
return 'https://project.adhivasindo.co.id/';
}
if (Flavor.isStaging) {
return 'https://project.adhivasindo.co.id/';
}
return 'https://project.adhivasindo.co.id/';
}
static const int connectTimeoutSeconds = 15;
static const int receiveTimeoutSeconds = 30;
static bool get enableHttpLog => !Flavor.isProd;
}

View File

@@ -0,0 +1,11 @@
import 'package:frontend_eccp_mobile/app/core/constants/enum/app_flavor_enum.dart';
class Flavor {
static late AppFlavor current;
static String get name => current.name;
static bool get isDev => current == AppFlavor.development;
static bool get isStaging => current == AppFlavor.staging;
static bool get isProd => current == AppFlavor.production;
}

View File

@@ -0,0 +1,21 @@
class AppUpdateConfig {
AppUpdateConfig({
required this.version,
required this.versionCode,
required this.linkUpdate,
required this.adminLink,
});
factory AppUpdateConfig.fromJson(Map<String, dynamic> json) {
return AppUpdateConfig(
version: json['version']?.toString() ?? '0.0.0',
versionCode: int.tryParse(json['version_code']?.toString() ?? '0') ?? 0,
linkUpdate: json['link_update']?.toString() ?? '',
adminLink: json['admin_link']?.toString() ?? '',
);
}
final String version;
final int versionCode;
final String linkUpdate;
final String adminLink;
}

View File

@@ -0,0 +1,3 @@
class ApiConstant {
static const String contentTypeJson = 'application/json';
}

View File

@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:hexcolor/hexcolor.dart';
class AppColors {
// PRIMARY
static Color primary900 = Colors.black;
static Color primary700 = Colors.black87;
static Color primary500 = Colors.black54;
static Color primary300 = Colors.black38;
// ACCENT
static Color accentRed600 = HexColor('#E10600');
static Color accentRed500 = HexColor('#FF3B30');
static Color accentPink = HexColor('#FF2D55');
static Color accentGreen = HexColor('#34C759');
// STATUS
static Color statusSuccess = HexColor('#28A745');
static Color statusWarning = HexColor('#FFC107');
static Color statusDanger = HexColor('#DC3545');
// NEUTRAL
static Color neutral50 = HexColor('#FAFAFA');
static Color neutral100 = HexColor('#F2F2F2');
static Color neutral200 = HexColor('#E0E0E0');
static Color neutral400 = HexColor('#BDBDBD');
static Color neutral500 = HexColor('#8C8C8C');
static Color neutral600 = HexColor('#757575');
static Color neutral700 = Colors.grey.shade700;
static Color neutral900 = HexColor('#212121');
static Color neutralBlack = HexColor('#000000');
static Color neutralWhite = HexColor('#FFFFFF');
// SUPPORT
static Color supportSteel600 = HexColor('#2D3E50');
static Color supportSteel300 = HexColor('#8FA3BF');
// SHIMMER/LOADING
static Color shimmerBackgroundColor = HexColor('#292729');
static Color shimmerBaseColor = HexColor('#292729');
static Color shimmerHighColor = HexColor('#363436');
}

View File

@@ -0,0 +1,10 @@
class AppDurations {
AppDurations._();
static const fast = Duration(milliseconds: 150);
static const normal = Duration(milliseconds: 250);
static const slow = Duration(milliseconds: 400);
static const pageTransition = Duration(milliseconds: 300);
static const modalTransition = Duration(milliseconds: 250);
}

View File

@@ -0,0 +1,10 @@
class AppIconSizes {
AppIconSizes._();
static const double xs = 14;
static const double sm = 16;
static const double md = 20;
static const double lg = 24;
static const double xl = 28;
static const double xxl = 32;
}

View File

@@ -0,0 +1,12 @@
class AppOpacity {
AppOpacity._();
static const double disabled = 0.4;
static const double muted = 0.6;
static const double subtle = 0.8;
static const double overlayLight = 0.05;
static const double opacity15 = 0.15;
static const double overlay = 0.1;
static const double overlayStrong = 0.2;
}

View File

@@ -0,0 +1,60 @@
import 'package:flutter/widgets.dart';
class AppPadding {
AppPadding._();
static const EdgeInsets p0 = EdgeInsets.zero;
static const EdgeInsets p2 = EdgeInsets.all(2);
static const EdgeInsets p4 = EdgeInsets.all(4);
static const EdgeInsets p6 = EdgeInsets.all(6);
static const EdgeInsets p8 = EdgeInsets.all(8);
static const EdgeInsets p10 = EdgeInsets.all(10);
static const EdgeInsets p12 = EdgeInsets.all(12);
static const EdgeInsets p14 = EdgeInsets.all(14);
static const EdgeInsets p16 = EdgeInsets.all(16);
static const EdgeInsets p24 = EdgeInsets.all(24);
static const EdgeInsets h4 = EdgeInsets.symmetric(horizontal: 4);
static const EdgeInsets h8 = EdgeInsets.symmetric(horizontal: 8);
static const EdgeInsets h10 = EdgeInsets.symmetric(horizontal: 10);
static const EdgeInsets h12 = EdgeInsets.symmetric(horizontal: 12);
static const EdgeInsets h14 = EdgeInsets.symmetric(horizontal: 14);
static const EdgeInsets h16 = EdgeInsets.symmetric(horizontal: 16);
static const EdgeInsets h18 = EdgeInsets.symmetric(horizontal: 18);
static const EdgeInsets h24 = EdgeInsets.symmetric(
horizontal: 24,
);
static const EdgeInsets v2 = EdgeInsets.symmetric(vertical: 2);
static const EdgeInsets v4 = EdgeInsets.symmetric(vertical: 4);
static const EdgeInsets v6 = EdgeInsets.symmetric(vertical: 6);
static const EdgeInsets v8 = EdgeInsets.symmetric(vertical: 8);
static const EdgeInsets v10 = EdgeInsets.symmetric(vertical: 10);
static const EdgeInsets v12 = EdgeInsets.symmetric(vertical: 12);
static const EdgeInsets v14 = EdgeInsets.symmetric(vertical: 14);
static const EdgeInsets v16 = EdgeInsets.symmetric(vertical: 16);
static const EdgeInsets v20 = EdgeInsets.symmetric(vertical: 20);
static const EdgeInsets t6 = EdgeInsets.only(top: 6);
static const EdgeInsets t8 = EdgeInsets.only(top: 8);
static const EdgeInsets t12 = EdgeInsets.only(top: 12);
static const EdgeInsets t16 = EdgeInsets.only(top: 16);
static const EdgeInsets t24 = EdgeInsets.only(top: 24);
static const EdgeInsets b2 = EdgeInsets.only(bottom: 2);
static const EdgeInsets b8 = EdgeInsets.only(bottom: 8);
static const EdgeInsets b10 = EdgeInsets.only(bottom: 10);
static const EdgeInsets b12 = EdgeInsets.only(bottom: 12);
static const EdgeInsets b14 = EdgeInsets.only(bottom: 14);
static const EdgeInsets b16 = EdgeInsets.only(bottom: 16);
static const EdgeInsets l6 = EdgeInsets.only(left: 6);
static const EdgeInsets l8 = EdgeInsets.only(left: 8);
static const EdgeInsets l12 = EdgeInsets.only(left: 12);
static const EdgeInsets l16 = EdgeInsets.only(left: 16);
static const EdgeInsets l26 = EdgeInsets.only(left: 26);
static const EdgeInsets r8 = EdgeInsets.only(right: 8);
static const EdgeInsets r12 = EdgeInsets.only(right: 12);
static const EdgeInsets r16 = EdgeInsets.only(right: 16);
}

View File

@@ -0,0 +1,13 @@
import 'package:flutter/widgets.dart';
class AppRadius {
AppRadius._();
static BorderRadius r6 = BorderRadius.circular(6);
static BorderRadius r10 = BorderRadius.circular(10);
static BorderRadius r8 = BorderRadius.circular(8);
static BorderRadius r14 = BorderRadius.circular(14);
static BorderRadius r18 = BorderRadius.circular(18);
static BorderRadius r24 = BorderRadius.circular(24);
static BorderRadius rFull = BorderRadius.circular(999);
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
class AppShadows {
AppShadows._();
static List<BoxShadow> xs = [
BoxShadow(
color: Colors.black.withOpacity(0.07),
blurRadius: 2,
),
];
static List<BoxShadow> sm = [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 1),
),
];
static List<BoxShadow> md = [
BoxShadow(
color: Colors.black.withOpacity(0.15),
blurRadius: 8,
offset: const Offset(0, 2),
),
];
static List<BoxShadow> lg = [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 16,
offset: const Offset(0, 4),
),
];
}

View File

@@ -0,0 +1,45 @@
class AppSizes {
AppSizes._();
static const double maxContentWidth = 600;
static const double bottomSheetHandleWidth = 40;
static const double bottomSheetHandleHeight = 6;
static const double avatarSm = 32;
static const double avatarMd = 40;
static const double avatarLg = 64;
static const double iconContainerSm = 32;
static const double iconContainerMd = 40;
static const double iconContainerLg = 48;
static const double progressHeight = 4;
static const double buttonHeight = 44;
static const double inputHeight = 48;
static const double illustrationWidthFactor = 0.6;
static const double s2 = 2;
static const double s4 = 4;
static const double s6 = 6;
static const double s8 = 8;
static const double s10 = 10;
static const double s12 = 12;
static const double s16 = 16;
static const double s24 = 24;
static const double s32 = 32;
static const double s40 = 40;
static const double s48 = 48;
static const double s56 = 56;
static const double s64 = 64;
static const double s72 = 72;
static const double s80 = 80;
static const double s88 = 88;
static const double s96 = 96;
static const double s104 = 104;
static const double s112 = 112;
static const double s120 = 120;
static const double s128 = 128;
}

View File

@@ -0,0 +1,28 @@
import 'package:flutter/widgets.dart';
class AppSpacing {
AppSpacing._();
static const SizedBox h2 = SizedBox(height: 2);
static const SizedBox h4 = SizedBox(height: 4);
static const SizedBox h6 = SizedBox(height: 6);
static const SizedBox h8 = SizedBox(height: 8);
static const SizedBox h12 = SizedBox(height: 12);
static const SizedBox h16 = SizedBox(height: 16);
static const SizedBox h20 = SizedBox(height: 20);
static const SizedBox h24 = SizedBox(height: 24);
static const SizedBox h32 = SizedBox(height: 32);
static const SizedBox h36 = SizedBox(height: 36);
static const SizedBox h40 = SizedBox(height: 40);
static const SizedBox h48 = SizedBox(height: 48);
static const SizedBox h64 = SizedBox(height: 64);
static const SizedBox w4 = SizedBox(width: 4);
static const SizedBox w6 = SizedBox(width: 6);
static const SizedBox w8 = SizedBox(width: 8);
static const SizedBox w10 = SizedBox(width: 10);
static const SizedBox w12 = SizedBox(width: 12);
static const SizedBox w14 = SizedBox(width: 14);
static const SizedBox w16 = SizedBox(width: 16);
static const SizedBox w24 = SizedBox(width: 24);
static const SizedBox w32 = SizedBox(width: 32);
}

View File

@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:frontend_eccp_mobile/app/core/constants/app_colors.dart';
import 'package:google_fonts/google_fonts.dart';
class AppTextStyles {
AppTextStyles._();
static final String fontFamily = GoogleFonts.inter().fontFamily!;
static TextStyle hAnalytics = GoogleFonts.inter(
fontSize: 24,
fontWeight: FontWeight.w700,
color: AppColors.neutral900,
);
static TextStyle h1 = GoogleFonts.inter(
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppColors.neutral900,
);
static TextStyle h2 = GoogleFonts.inter(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.neutral900,
);
static TextStyle h2Light = GoogleFonts.inter(
fontSize: 18,
fontWeight: FontWeight.w400,
color: AppColors.neutral900,
);
static TextStyle h3 = GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.neutral900,
);
static TextStyle h4 = GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.neutral900,
);
static TextStyle section = GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.neutral400,
);
static TextStyle label = GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.neutral900,
);
static TextStyle body = GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.neutral900,
);
static TextStyle small = GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w400,
color: AppColors.neutral600,
);
static TextStyle xs = GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w400,
color: AppColors.neutral400,
);
static TextStyle badge = GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.primary700,
);
static TextStyle button = GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
);
static TextStyle value = GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.neutral900,
);
}

View File

@@ -0,0 +1,13 @@
export 'api_constant.dart';
export 'app_colors.dart';
export 'app_durations.dart';
export 'app_icon_sizes.dart';
export 'app_opacity.dart';
export 'app_padding.dart';
export 'app_radius.dart';
export 'app_shadows.dart';
export 'app_sizes.dart';
export 'app_spacing.dart';
export 'app_text_styles.dart';
export 'dio_constant.dart';
export 'globalkey.dart';

View File

@@ -0,0 +1,3 @@
class DioExtraKey {
static const String noAuth = 'noAuth';
}

View File

@@ -0,0 +1,5 @@
enum AppFlavor {
development,
staging,
production,
}

View File

@@ -0,0 +1 @@
enum AppButtonType { primary, secondary }

View File

@@ -0,0 +1 @@
enum SimpleLocationStatus { granted, denied, deniedForever, serviceDisabled }

View File

@@ -0,0 +1,9 @@
enum NetworkErrorCode {
unauthorized,
noInternet,
serverError,
requestCancelled,
badCertificate,
unknown,
notFound,
}

View File

@@ -0,0 +1,30 @@
enum NetworkErrorMessage {
unauthorized,
noInternet,
serverError,
requestCancelled,
badCertificate,
unknown,
notFound,
}
extension NetworkErrorMessageExt on NetworkErrorMessage {
String get value {
switch (this) {
case NetworkErrorMessage.unauthorized:
return 'Session expired. Please login again.';
case NetworkErrorMessage.noInternet:
return 'No internet connection.';
case NetworkErrorMessage.serverError:
return 'Server error occurred.';
case NetworkErrorMessage.requestCancelled:
return 'Request cancelled.';
case NetworkErrorMessage.badCertificate:
return 'Invalid certificate.';
case NetworkErrorMessage.unknown:
return 'Something went wrong.';
case NetworkErrorMessage.notFound:
return 'Oops,terjadi kesalahan tidak terduga. (404)';
}
}
}

View File

@@ -0,0 +1 @@
enum SnackBarType { success, error, warning, info }

View File

@@ -0,0 +1,3 @@
import 'package:flutter/material.dart';
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

View File

@@ -0,0 +1,69 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format width=80
// **************************************************************************
// InjectableConfigGenerator
// **************************************************************************
// ignore_for_file: type=lint
// coverage:ignore-file
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:frontend_eccp_mobile/modules/auth/login/bloc/login_cubit.dart'
as _i941;
import 'package:frontend_eccp_mobile/modules/auth/login/data/datasources/login_remote_datasource.dart'
as _i457;
import 'package:frontend_eccp_mobile/modules/auth/login/data/repositories/login_repository.dart'
as _i435;
import 'package:frontend_eccp_mobile/modules/auth/refresh_token/data/datasources/auth_remote_datasource.dart'
as _i813;
import 'package:frontend_eccp_mobile/modules/auth/refresh_token/data/repositories/auth_repository.dart'
as _i908;
import 'package:frontend_eccp_mobile/modules/auth/refresh_token/services/auth_service.dart'
as _i1069;
import 'package:frontend_eccp_mobile/modules/profile/bloc/logout/logout_cubit.dart'
as _i58;
import 'package:frontend_eccp_mobile/modules/profile/data/datasources/profile_remote_datasource.dart'
as _i222;
import 'package:frontend_eccp_mobile/modules/profile/data/repositories/profile_repository.dart'
as _i604;
import 'package:get_it/get_it.dart' as _i174;
import 'package:injectable/injectable.dart' as _i526;
extension GetItInjectableX on _i174.GetIt {
// initializes the registration of main-scope dependencies inside of GetIt
_i174.GetIt init({
String? environment,
_i526.EnvironmentFilter? environmentFilter,
}) {
final gh = _i526.GetItHelper(this, environment, environmentFilter);
gh.lazySingleton<_i457.LoginRemoteDataSource>(
() => _i457.LoginRemoteDataSource(),
);
gh.lazySingleton<_i813.AuthRemoteDataSource>(
() => _i813.AuthRemoteDataSource(),
);
gh.lazySingleton<_i222.ProfileRemoteDataSource>(
() => _i222.ProfileRemoteDataSource(),
);
gh.lazySingleton<_i435.LoginRepository>(
() => _i435.LoginRepository(gh<_i457.LoginRemoteDataSource>()),
);
gh.lazySingleton<_i604.ProfileRepository>(
() => _i604.ProfileRepository(gh<_i222.ProfileRemoteDataSource>()),
);
gh.lazySingleton<_i908.AuthRepository>(
() => _i908.AuthRepository(gh<_i813.AuthRemoteDataSource>()),
);
gh.factory<_i58.LogoutCubit>(
() => _i58.LogoutCubit(gh<_i604.ProfileRepository>()),
);
gh.factory<_i941.LoginCubit>(
() => _i941.LoginCubit(gh<_i435.LoginRepository>()),
);
gh.lazySingleton<_i1069.AuthService>(
() => _i1069.AuthService(gh<_i908.AuthRepository>()),
);
return this;
}
}

View File

@@ -0,0 +1,13 @@
import 'package:frontend_eccp_mobile/app/core/di/injection.config.dart';
import 'package:frontend_eccp_mobile/modules/auth/login/data/model/user_session.dart';
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
final GetIt getIt = GetIt.instance;
@InjectableInit()
Future<void> configureDependencies() async {
getIt
..registerLazySingleton<UserSession>(UserSession.new)
..init();
}

View File

@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:frontend_eccp_mobile/app/core/di/injection.dart';
import 'package:frontend_eccp_mobile/app/core/navigation/app_navigator.dart';
import 'package:frontend_eccp_mobile/app/core/navigation/app_routes.dart';
import 'package:frontend_eccp_mobile/app/core/notification/fcm_route/notification_router.dart';
import 'package:frontend_eccp_mobile/app/core/notification/notification_intent_store.dart';
import 'package:frontend_eccp_mobile/app/core/services/crashlytics_service.dart';
import 'package:frontend_eccp_mobile/app/core/widgets/bottomsheet/app_bottom_sheet.dart';
import 'package:frontend_eccp_mobile/app/core/widgets/bottomsheet/appbottomsheet_controller.dart';
import 'package:frontend_eccp_mobile/modules/auth/login/data/model/user_session.dart';
import 'package:frontend_eccp_mobile/modules/profile/bloc/logout/logout_cubit.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
class AuthHandler {
AuthHandler._();
static Future<void> handleAuth(BuildContext context) async {
if (!context.mounted) return;
if (getIt<UserSession>().isLoggedIn) {
await AppNavigator.clearAndPush(
context,
AppRoutes.home,
onOpened: () async {
final data = NotificationIntentStore.consume();
if (data != null) {
await NotificationRouter.handle(data);
}
},
);
} else {
await AppNavigator.clearAndPush(
context,
AppRoutes.login,
);
}
}
static Future<void> logout(BuildContext context) async {
await showMaterialModalBottomSheet<dynamic>(
context: context,
backgroundColor: const Color.fromARGB(0, 213, 135, 135),
builder: (_) {
return AppBottomSheet(
controller: AppBottomSheetController(),
useSecondaryButton: true,
title: 'Apakah anda yakin ingin keluar?',
subTitle:
'Jika ya, anda akan keluar dari aplikasi dan perlu login kembali.',
primaryText: 'Keluar',
secondaryText: 'Batal',
onPrimaryPressed: () async {
Navigator.pop(context);
await CrashlyticsService.clearUser();
getIt<UserSession>().clear();
if (!context.mounted) return;
await context.read<LogoutCubit>().logout();
},
);
},
);
}
}

View File

@@ -0,0 +1,17 @@
import 'package:frontend_eccp_mobile/app/core/services/remote_config_service.dart';
import 'package:url_launcher/url_launcher.dart';
class ContactAdmin {
static Future<void> openLink() async {
await RemoteConfigService.init();
final config = await RemoteConfigService.getUpdateConfig();
if (config == null || config.adminLink.isEmpty) return;
final uri = Uri.parse(config.adminLink);
await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
}
}

View File

@@ -0,0 +1,22 @@
class FcmPayload {
FcmPayload({
this.title,
this.body,
this.data,
});
factory FcmPayload.fromMessage(Map<String, dynamic> message) {
final notification = message['notification'] as Map<String, dynamic>?;
final rawData = message['data'];
return FcmPayload(
title: notification?['title']?.toString(),
body: notification?['body']?.toString(),
data: rawData is Map ? Map<String, dynamic>.from(rawData) : null,
);
}
final String? title;
final String? body;
final Map<String, dynamic>? data;
}

View File

@@ -0,0 +1,20 @@
class NotificationPayload {
NotificationPayload({
required this.page,
this.params,
});
factory NotificationPayload.fromMap(Map<String, dynamic> data) {
final param = data['params'];
return NotificationPayload(
page: data['page']?.toString() ?? '',
params: param is Map ? Map<String, dynamic>.from(param) : null,
);
}
final String page;
final Map<String, dynamic>? params;
bool get isValid => page.isNotEmpty;
}

View File

@@ -0,0 +1,119 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
typedef NavResult<T> = void Function(T? result);
typedef NavOpened = void Function();
class AppNavigator {
AppNavigator._();
static Future<T?> push<T extends Object?>(
BuildContext context,
String route, {
Object? extra,
NavOpened? onOpened,
NavResult<T>? onResult,
}) async {
final future = context.push<T>(route, extra: extra);
if (onOpened != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
onOpened();
});
}
if (onResult != null) {
await future.then(onResult);
}
return future;
}
static Future<void> clearAndPush(
BuildContext context,
String route, {
Object? extra,
NavOpened? onOpened,
}) async {
context.go(route, extra: extra);
if (onOpened != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
onOpened();
});
}
}
static void pop<T extends Object?>(
BuildContext context, [
T? result,
]) {
context.pop(result);
}
static void popToRoot(BuildContext context) {
while (context.canPop()) {
context.pop();
}
}
static Future<bool> maybePop(BuildContext context) async {
if (context.canPop()) {
context.pop();
return true;
}
return false;
}
static Future<T?> pushFade<T extends Object?>(
BuildContext context,
String route, {
Object? extra,
Duration duration = const Duration(milliseconds: 200),
NavOpened? onOpened,
NavResult<T>? onResult,
}) async {
final future = context.push<T>(
route,
extra: extra,
);
if (onOpened != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
onOpened();
});
}
if (onResult != null) {
await future.then(onResult);
}
return future;
}
static Future<T?> pushSlide<T extends Object?>(
BuildContext context,
String route, {
Object? extra,
Duration duration = const Duration(milliseconds: 250),
NavOpened? onOpened,
NavResult<T>? onResult,
}) async {
final future = context.push<T>(
route,
extra: extra,
);
if (onOpened != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
onOpened();
});
}
if (onResult != null) {
await future.then(onResult);
}
return future;
}
}

View File

@@ -0,0 +1,47 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
class _OneShotResumeObserver with WidgetsBindingObserver {
_OneShotResumeObserver(this._onResume);
final Future<void> Function() _onResume;
bool _done = false;
void start() => WidgetsBinding.instance.addObserver(this);
void stop() {
if (_done) return;
_done = true;
WidgetsBinding.instance.removeObserver(this);
}
@override
Future<void> didChangeAppLifecycleState(AppLifecycleState state) async {
if (state == AppLifecycleState.resumed && !_done) {
try {
await _onResume();
} finally {
stop();
}
}
}
}
Future<void> waitRecheckOnNextResume(
Future<void> Function() onResume, {
Duration timeout = const Duration(seconds: 45),
}) async {
final c = Completer<void>();
final obs = _OneShotResumeObserver(() async {
try {
await onResume();
} finally {
if (!c.isCompleted) c.complete();
}
})..start();
Future.delayed(timeout, () {
obs.stop();
if (!c.isCompleted) c.complete();
});
return c.future;
}

View File

@@ -0,0 +1,59 @@
import 'package:frontend_eccp_mobile/app/core/constants/globalkey.dart';
import 'package:frontend_eccp_mobile/app/core/navigation/app_routes.dart';
import 'package:frontend_eccp_mobile/app/core/navigation/main_navigation_page.dart';
import 'package:frontend_eccp_mobile/modules/auth/login/presentation/screen/login_page.dart';
import 'package:frontend_eccp_mobile/modules/splash/presentation/screen/splash_screen.dart';
import 'package:frontend_eccp_mobile/modules/update/presentation/update_page.dart';
import 'package:go_router/go_router.dart';
class AppRouter {
AppRouter._();
static final router = GoRouter(
initialLocation: AppRoutes.splash,
navigatorKey: navigatorKey,
routes: [
GoRoute(
path: AppRoutes.splash,
builder: (context, state) => const SplashScreen(),
),
GoRoute(
path: AppRoutes.updateApp,
builder: (context, state) {
final extra = state.extra as Map<String, dynamic>?;
final version = extra?['version'] as String? ?? '';
final link = extra?['link'] as String? ?? '';
return UpdatePage(
version: version,
link: link,
);
},
),
GoRoute(
path: AppRoutes.login,
builder: (context, state) => const LoginPage(),
),
GoRoute(
path: AppRoutes.home,
builder: (context, state) => const MainNavigationPage(),
),
GoRoute(
path: AppRoutes.berkas,
builder: (context, state) => const MainNavigationPage(
initialIndex: 1,
),
),
GoRoute(
path: AppRoutes.settings,
builder: (context, state) => const MainNavigationPage(
initialIndex: 2,
),
),
],
);
}

View File

@@ -0,0 +1,10 @@
class AppRoutes {
AppRoutes._();
static const splash = '/';
static const login = '/login';
static const updateApp = '/update-app';
static const home = '/home';
static const settings = '/settings';
static const berkas = '/berkas';
}

View File

@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:frontend_eccp_mobile/app/core/widgets/app_bottom_nav.dart';
import 'package:frontend_eccp_mobile/modules/berkas/presentation/screen/berkas_page.dart';
import 'package:frontend_eccp_mobile/modules/home/presentation/screen/home_page.dart';
import 'package:frontend_eccp_mobile/modules/profile/presentation/screen/profile_page.dart';
class MainNavigationPage extends StatefulWidget {
const MainNavigationPage({
super.key,
this.initialIndex = 0,
});
final int initialIndex;
@override
State<MainNavigationPage> createState() => _MainNavigationPageState();
}
class _MainNavigationPageState extends State<MainNavigationPage> {
late int _currentIndex;
final List<Widget> _pages = [
const HomePage(),
const BerkasPage(),
const ProfilePage(),
];
@override
void initState() {
super.initState();
_currentIndex = widget.initialIndex;
}
void _onTabChanged(int index) {
if (_currentIndex == index) return;
setState(() => _currentIndex = index);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _currentIndex,
children: _pages,
),
bottomNavigationBar: AppBottomNav(
currentIndex: _currentIndex,
onTap: _onTabChanged,
),
);
}
}

View File

@@ -0,0 +1,51 @@
import 'dart:io';
import 'package:android_intent_plus/android_intent.dart';
import 'package:app_settings/app_settings.dart';
import 'package:geolocator/geolocator.dart';
import 'package:permission_handler/permission_handler.dart' as ph;
import 'package:url_launcher/url_launcher.dart';
class SettingsNavigator {
SettingsNavigator._();
static Future<bool> openAppSettings() async {
try {
final ok = await Geolocator.openAppSettings();
if (ok) return true;
} catch (_) {}
try {
final ok = await ph.openAppSettings();
if (ok) return true;
} catch (_) {}
try {
await AppSettings.openAppSettings();
return true;
} catch (_) {}
if (Platform.isIOS) {
try {
final uri = Uri.parse('app-settings:');
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
return true;
}
} catch (_) {}
}
return false;
}
static Future<bool> openLocationSettings() async {
if (Platform.isIOS) return openAppSettings();
try {
final ok = await Geolocator.openLocationSettings();
if (ok) return true;
} catch (_) {}
try {
const i = AndroidIntent(
action: 'android.settings.LOCATION_SOURCE_SETTINGS',
);
await i.launch();
return true;
} catch (_) {}
return openAppSettings();
}
}

View File

@@ -0,0 +1,5 @@
class ApiEndpoint {
static const String refreshToken = '/api/v1/auth/refresh';
static const String login = '/api/v1/auth/login';
static const String logout = '/api/v1/auth/logout';
}

View File

@@ -0,0 +1,49 @@
import 'package:chucker_flutter/chucker_flutter.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:frontend_eccp_mobile/app/core/config/app_environment.dart';
import 'package:frontend_eccp_mobile/app/core/config/app_flavor.dart';
import 'package:frontend_eccp_mobile/app/core/network/interceptors/auth_interceptor.dart';
import 'package:frontend_eccp_mobile/app/core/network/interceptors/error_interceptor.dart';
import 'package:frontend_eccp_mobile/app/core/network/interceptors/logging_interceptor.dart';
import 'package:frontend_eccp_mobile/app/core/network/interceptors/retry_interceptor.dart';
import 'package:pretty_dio_logger/pretty_dio_logger.dart';
class DioClient {
DioClient._();
static final Dio _dio = Dio(
BaseOptions(
baseUrl: AppEnvironment.baseUrl,
validateStatus: (status) =>
status != null && status >= 200 && status < 300,
connectTimeout: const Duration(
seconds: AppEnvironment.connectTimeoutSeconds,
),
receiveTimeout: const Duration(
seconds: AppEnvironment.receiveTimeoutSeconds,
),
),
);
static Dio getInstance() {
_dio.interceptors.clear();
_dio.interceptors.add(AuthInterceptor());
_dio.interceptors.add(buildRetryInterceptor(_dio));
_dio.interceptors.add(LoggingInterceptor());
_dio.interceptors.add(ErrorInterceptor(_dio));
_dio.interceptors.add(
PrettyDioLogger(
requestHeader: true,
requestBody: true,
),
);
if (!kReleaseMode && Flavor.isDev) {
_dio.interceptors.add(ChuckerDioInterceptor());
}
return _dio;
}
}

View File

@@ -0,0 +1,69 @@
import 'dart:convert';
import 'dart:developer';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:frontend_eccp_mobile/app/core/utils/session_manager.dart';
class AuthInterceptor extends Interceptor {
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final noAuth = options.extra['noAuth'] == true;
if (!noAuth) {
final token = await SessionManager.getToken();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
} else {
options.headers.remove('Authorization');
}
}
if (options.data is Map || options.data is List) {
options.headers.putIfAbsent(
'Content-Type',
() => 'application/json',
);
}
if (!kReleaseMode) {
final curl = StringBuffer()
..write("curl -X ${options.method} '${options.uri}'");
options.headers.forEach((k, v) {
curl.write(" -H '$k: $v'");
});
if (options.data != null) {
if (options.data is FormData) {
final formData = options.data as FormData;
for (final field in formData.fields) {
curl.write(" -F '${field.key}=${field.value}'");
}
for (final file in formData.files) {
curl.write(
" -F '${file.key}=@${file.value.filename ?? 'file'}'",
);
}
} else {
curl.write(
" -d '${jsonEncode(options.data)}'",
);
}
}
log(
curl.toString(),
name: 'DIO_CURL',
);
}
handler.next(options);
}
}

View File

@@ -0,0 +1,244 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:frontend_eccp_mobile/app/core/constants/enum/network_error_code_enum.dart';
import 'package:frontend_eccp_mobile/app/core/constants/enum/network_error_message_enum.dart';
import 'package:frontend_eccp_mobile/app/core/constants/globalkey.dart';
import 'package:frontend_eccp_mobile/app/core/di/injection.dart';
import 'package:frontend_eccp_mobile/app/core/navigation/app_routes.dart';
import 'package:frontend_eccp_mobile/app/core/network/api_endpoint.dart';
import 'package:frontend_eccp_mobile/app/core/services/bottom_sheet_service.dart';
import 'package:frontend_eccp_mobile/app/core/services/crashlytics_service.dart';
import 'package:frontend_eccp_mobile/app/core/utils/session_manager.dart';
import 'package:frontend_eccp_mobile/app/core/utils/token_manager.dart';
import 'package:frontend_eccp_mobile/modules/auth/refresh_token/services/auth_service.dart';
import 'package:go_router/go_router.dart';
class ErrorInterceptor extends Interceptor {
ErrorInterceptor(this.dio);
final Dio dio;
final AuthService _authService = getIt<AuthService>();
bool _isRefreshing = false;
final List<void Function(String)> _queue = [];
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
final statusCode = err.response?.statusCode;
final isNoAuth = err.requestOptions.extra['noAuth'] == true;
String? backendMessage;
final data = err.response?.data;
if (data is Map<String, dynamic>) {
backendMessage = data['message']?.toString();
}
final code = _mapErrorCode(err);
if (_shouldSendToCrashlytics(code, statusCode)) {
await CrashlyticsService.recordError(
err,
err.stackTrace,
reason: backendMessage ?? code.name,
fatal: true,
context: {
'url': err.requestOptions.uri.toString(),
'method': err.requestOptions.method,
'status_code': statusCode ?? -1,
'error_code': code.name,
'response': data?.toString() ?? '-',
},
);
}
if (isNoAuth) {
return handler.reject(
_withMapped(
err,
backendMessage ?? _mapMessage(code).value,
),
);
}
if (statusCode == 401) {
await _handle401(err, handler);
return;
}
if (code == NetworkErrorCode.noInternet) {
if (err.requestOptions.extra['noInternetShown'] != true) {
err.requestOptions.extra['noInternetShown'] = true;
err.requestOptions.extra['handledGlobally'] = true;
await BottomSheetService.showNoInternet();
}
return handler.reject(
_withMapped(err, NetworkErrorMessage.noInternet.value),
);
}
if (code == NetworkErrorCode.serverError) {
if (err.requestOptions.extra['serverErrorShown'] != true) {
err.requestOptions.extra['serverErrorShown'] = true;
err.requestOptions.extra['handledGlobally'] = true;
await BottomSheetService.showServerError();
}
return handler.reject(
_withMapped(err, NetworkErrorMessage.serverError.value),
);
}
if (code == NetworkErrorCode.notFound) {
if (err.requestOptions.extra['serverErrorShown'] != true) {
err.requestOptions.extra['serverErrorShown'] = true;
err.requestOptions.extra['handledGlobally'] = true;
await BottomSheetService.showNotFound();
}
return handler.reject(
_withMapped(err, NetworkErrorMessage.notFound.value),
);
}
return handler.reject(
_withMapped(
err,
backendMessage ?? _mapMessage(code).value,
),
);
}
Future<void> _handle401(
DioException err,
ErrorInterceptorHandler handler,
) async {
final requestOptions = err.requestOptions;
if (requestOptions.path.contains(ApiEndpoint.refreshToken) ||
requestOptions.path.contains(ApiEndpoint.logout)) {
await _handleLogout();
return handler.reject(err);
}
if (_isRefreshing) {
_queue.add((token) async {
requestOptions.headers['Authorization'] = 'Bearer $token';
final response = await dio.fetch<dynamic>(requestOptions);
handler.resolve(response);
});
return;
}
_isRefreshing = true;
try {
final newToken = await _authService.refreshToken();
await SessionManager.saveToken(newToken.accessToken);
await SessionManager.saveRefreshToken(newToken.refreshToken);
for (final retry in _queue) {
retry(newToken.accessToken);
}
_queue.clear();
requestOptions.headers['Authorization'] =
'Bearer ${newToken.accessToken}';
final response = await dio.fetch<dynamic>(requestOptions);
handler.resolve(response);
} catch (e) {
await _handleLogout();
handler.reject(err);
} finally {
_isRefreshing = false;
}
}
Future<void> _handleLogout() async {
try {
await TokenManager.clearToken();
dio.options.headers.remove('Authorization');
navigatorKey.currentContext?.go(AppRoutes.login);
} catch (e) {
debugPrint('Logout error: $e');
}
}
bool _shouldSendToCrashlytics(
NetworkErrorCode code,
int? statusCode,
) {
if (code == NetworkErrorCode.noInternet) return false;
if (code == NetworkErrorCode.notFound) return true;
if (statusCode == 401) return false;
if (statusCode != null && statusCode < 500) return false;
return true;
}
NetworkErrorCode _mapErrorCode(DioException e) {
final statusCode = e.response?.statusCode;
switch (e.type) {
case DioExceptionType.badResponse:
if (statusCode == 404) return NetworkErrorCode.notFound;
if (statusCode == 401) return NetworkErrorCode.unauthorized;
if (statusCode != null && statusCode >= 500) {
return NetworkErrorCode.serverError;
}
return NetworkErrorCode.unknown;
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
case DioExceptionType.connectionError:
return NetworkErrorCode.noInternet;
case DioExceptionType.cancel:
return NetworkErrorCode.requestCancelled;
case DioExceptionType.badCertificate:
return NetworkErrorCode.badCertificate;
case DioExceptionType.unknown:
return NetworkErrorCode.unknown;
}
}
NetworkErrorMessage _mapMessage(NetworkErrorCode code) {
switch (code) {
case NetworkErrorCode.unauthorized:
return NetworkErrorMessage.unauthorized;
case NetworkErrorCode.noInternet:
return NetworkErrorMessage.noInternet;
case NetworkErrorCode.serverError:
return NetworkErrorMessage.serverError;
case NetworkErrorCode.notFound:
return NetworkErrorMessage.notFound;
case NetworkErrorCode.requestCancelled:
return NetworkErrorMessage.requestCancelled;
case NetworkErrorCode.badCertificate:
return NetworkErrorMessage.badCertificate;
case NetworkErrorCode.unknown:
return NetworkErrorMessage.unknown;
}
}
DioException _withMapped(DioException err, String message) {
return DioException(
requestOptions: err.requestOptions,
response: err.response,
type: err.type,
error: message,
);
}
}

View File

@@ -0,0 +1,36 @@
import 'dart:developer';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
class LoggingInterceptor extends Interceptor {
@override
void onResponse(
Response<dynamic> response,
ResponseInterceptorHandler handler,
) {
if (!kReleaseMode) {
log(
'RESPONSE ${response.statusCode}\n${response.requestOptions.uri}\n${response.data}',
name: 'DIO_RESPONSE',
);
}
handler.next(response);
}
@override
void onError(
DioException err,
ErrorInterceptorHandler handler,
) {
if (!kReleaseMode) {
log(
'ERROR ${err.response?.statusCode}\n'
'${err.requestOptions.uri}\n'
'${err.type}\n'
'${err.response?.data}',
name: 'DIO_ERROR',
);
}
handler.next(err);
}
}

View File

@@ -0,0 +1,30 @@
import 'dart:developer';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio_smart_retry/dio_smart_retry.dart';
RetryInterceptor buildRetryInterceptor(Dio dio) {
return RetryInterceptor(
dio: dio,
retryDelays: const [
Duration(milliseconds: 600),
Duration(seconds: 1),
Duration(seconds: 2),
],
logPrint: (o) => log(o, name: 'DIO_RETRY'),
retryEvaluator: (e, _) {
final code = e.response?.statusCode ?? 0;
final serverTransient = code == 502 || code == 503 || code == 504;
final connectionIssue =
e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout ||
e.type == DioExceptionType.sendTimeout ||
e.type == DioExceptionType.connectionError ||
(e.type == DioExceptionType.unknown && e.error is SocketException);
return serverTransient || connectionIssue;
},
);
}

View File

@@ -0,0 +1,9 @@
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:frontend_eccp_mobile/app/core/notification/fcm_notification_handler.dart';
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(
RemoteMessage message,
) async {
await FcmNotificationHandler.handleBackground(message);
}

View File

@@ -0,0 +1,15 @@
import 'package:firebase_messaging/firebase_messaging.dart';
class FcmConfig {
FcmConfig._();
static const String defaultChannelId = 'high_importance_channel';
static const String defaultChannelName = 'High Importance Notifications';
static const String defaultChannelDesc =
'Channel for important notifications';
static AndroidNotificationPriority priority =
AndroidNotificationPriority.highPriority;
static const bool enableForegroundNotification = true;
}

View File

@@ -0,0 +1,41 @@
import 'dart:developer';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:frontend_eccp_mobile/app/core/models/fcm_payload.dart';
import 'package:frontend_eccp_mobile/app/core/notification/fcm_route/notification_router.dart';
import 'package:frontend_eccp_mobile/app/core/notification/local_notification_service.dart';
class FcmNotificationHandler {
FcmNotificationHandler._();
static Future<void> handleForeground(RemoteMessage message) async {
log('Foreground FCM: ${message.data}', name: 'FCM');
final payload = FcmPayload.fromMessage({
'notification': message.notification?.toMap(),
'data': message.data,
});
if (payload.title != null && payload.body != null) {
await LocalNotificationService.show(
title: payload.title!,
body: payload.body!,
payload: payload.data,
);
}
}
static Future<void> handleOpenedApp(RemoteMessage message) async {
log('Opened from FCM: ${message.data}', name: 'FCM');
await NotificationRouter.handle(message.data);
}
static Future<void> handleLocalTap(String? payload) async {
if (payload == null) return;
await NotificationRouter.handleRaw(payload);
}
static Future<void> handleBackground(RemoteMessage message) async {
log('Background FCM: ${message.data}', name: 'FCM');
await NotificationRouter.handle(message.data);
}
}

View File

@@ -0,0 +1,110 @@
import 'dart:convert';
import 'dart:developer';
import 'package:frontend_eccp_mobile/app/core/constants/globalkey.dart';
import 'package:frontend_eccp_mobile/app/core/di/injection.dart';
import 'package:frontend_eccp_mobile/app/core/navigation/app_navigator.dart';
import 'package:frontend_eccp_mobile/app/core/navigation/app_routes.dart';
import 'package:frontend_eccp_mobile/app/core/notification/fcm_route/notification_routes.dart';
import 'package:frontend_eccp_mobile/modules/auth/login/data/model/user_session.dart';
class NotificationRouter {
NotificationRouter._();
static Future<void> handle(Map<String, dynamic> data) async {
log('NotificationRouter.handle: $data', name: 'FCM');
final page = data['page']?.toString();
if (page == null || page.isEmpty) {
log('Missing page in notification payload', name: 'FCM');
return;
}
Map<String, dynamic>? params;
final paramsRaw = data['params'];
if (paramsRaw is String && paramsRaw.isNotEmpty) {
try {
params = jsonDecode(paramsRaw) as Map<String, dynamic>;
} catch (_) {
log('Failed to decode params string', name: 'FCM');
}
} else if (paramsRaw is Map<String, dynamic>) {
params = paramsRaw;
}
params ??= Map<String, dynamic>.from(data)
..remove('page')
..remove('notification');
if (params.isEmpty) params = null;
await _navigate(page: page, params: params);
}
static Future<void> handleRaw(String raw) async {
log('NotificationRouter.handleRaw: $raw', name: 'FCM');
if (raw.trim().isEmpty) return;
try {
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
await handle(decoded);
return;
}
} catch (_) {}
if (raw.contains('=') && raw.contains('&')) {
try {
final uri = Uri(query: raw);
await handle(Map<String, dynamic>.from(uri.queryParameters));
return;
} catch (_) {}
}
try {
final normalized = raw
.replaceAll('{', '')
.replaceAll('}', '')
.split(',')
.map((e) => e.split(':'))
.where((e) => e.length == 2)
.fold<Map<String, dynamic>>({}, (map, pair) {
final key = pair[0].trim();
final value = pair[1].trim();
map[key] = int.tryParse(value) ?? value;
return map;
});
if (normalized.isNotEmpty) {
await handle(normalized);
return;
}
} catch (_) {}
log('handleRaw failed to parse payload', name: 'FCM');
}
static Future<void> _navigate({
required String? page,
Map<String, dynamic>? params,
}) async {
final navContext = navigatorKey.currentContext;
if (navContext == null) return;
if (!navContext.mounted) return;
if (!getIt<UserSession>().isLoggedIn) {
await AppNavigator.clearAndPush(
navContext,
AppRoutes.login,
);
return;
}
if (page != null) {
if (!navContext.mounted) return;
await NotificationRoutes.routeToPage(navContext, page, params);
}
}
}

View File

@@ -0,0 +1,50 @@
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:frontend_eccp_mobile/app/core/navigation/app_navigator.dart';
import 'package:frontend_eccp_mobile/app/core/navigation/app_routes.dart';
class NotificationRoutes {
static Future<void> routeToPage(
BuildContext context,
String page,
Map<String, dynamic>? params,
) async {
log('Routing to page=$page params=$params', name: 'FCM');
switch (page) {
case 'setting':
await AppNavigator.clearAndPush(
context,
AppRoutes.settings,
);
return;
case 'berkas':
final id = params?['id'];
await AppNavigator.clearAndPush(
context,
AppRoutes.berkas,
onOpened: () async {
if (id != null) {
await AppNavigator.push(
context,
AppRoutes.berkas,
extra: {
'berkasId': id.toString(),
},
);
}
},
);
return;
default:
await AppNavigator.clearAndPush(
context,
AppRoutes.home,
);
log('Unknown notification page: $page', name: 'FCM');
}
}
}

View File

@@ -0,0 +1,44 @@
import 'dart:developer';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:frontend_eccp_mobile/app/core/notification/fcm_background_handler.dart';
import 'package:frontend_eccp_mobile/app/core/notification/fcm_notification_handler.dart';
import 'package:frontend_eccp_mobile/app/core/notification/fcm_token_manager.dart';
import 'package:frontend_eccp_mobile/app/core/notification/local_notification_service.dart';
import 'package:frontend_eccp_mobile/app/core/notification/notification_intent_store.dart';
class FcmService {
FcmService._();
static final FirebaseMessaging _fcm = FirebaseMessaging.instance;
static Future<void> init() async {
await _fcm.requestPermission();
await LocalNotificationService.init(
onSelectNotification: FcmNotificationHandler.handleLocalTap,
);
final token = await FcmTokenManager.getToken();
log('Initial FCM Token: $token', name: 'FCM');
FcmTokenManager.listenRefresh((token) {
log('FCM Token refreshed: $token', name: 'FCM');
});
FirebaseMessaging.onMessage.listen(
FcmNotificationHandler.handleForeground,
);
FirebaseMessaging.onMessageOpenedApp.listen(
FcmNotificationHandler.handleOpenedApp,
);
final initialMessage = await _fcm.getInitialMessage();
if (initialMessage != null) {
NotificationIntentStore.pending = initialMessage.data;
}
FirebaseMessaging.onBackgroundMessage(
firebaseMessagingBackgroundHandler,
);
}
}

View File

@@ -0,0 +1,51 @@
import 'dart:developer';
import 'package:firebase_messaging/firebase_messaging.dart';
class FcmTokenManager {
FcmTokenManager._();
static final FirebaseMessaging _fcm = FirebaseMessaging.instance;
static Future<void> waitForAPNSToken() async {
String? apnsToken;
while (apnsToken == null) {
await Future<dynamic>.delayed(const Duration(milliseconds: 500));
apnsToken = await _fcm.getAPNSToken();
}
log('APNS Token ready: $apnsToken', name: 'FCM');
}
static Future<String?> getToken() async {
try {
await _fcm.requestPermission();
// await waitForAPNSToken();
final token = await _fcm.getToken();
log('FCM Token: $token', name: 'FCM');
return token;
} catch (e) {
log('Get token error: $e', name: 'FCM');
return null;
}
}
static Future<void> deleteToken() async {
try {
await _fcm.deleteToken();
log('FCM Token deleted', name: 'FCM');
} catch (e) {
log('Delete token error: $e', name: 'FCM');
}
}
static void listenRefresh(void Function(String token) onRefresh) {
FirebaseMessaging.instance.onTokenRefresh.listen((token) {
log('FCM Token refreshed: $token', name: 'FCM');
onRefresh(token);
});
}
}

View File

@@ -0,0 +1,54 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:frontend_eccp_mobile/app/core/notification/fcm_config.dart';
typedef NotificationTapCallback = void Function(String? payload);
class LocalNotificationService {
LocalNotificationService._();
static final FlutterLocalNotificationsPlugin _plugin =
FlutterLocalNotificationsPlugin();
static Future<void> init({
NotificationTapCallback? onSelectNotification,
}) async {
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
const ios = DarwinInitializationSettings();
const settings = InitializationSettings(
android: android,
iOS: ios,
);
await _plugin.initialize(
settings: settings,
onDidReceiveNotificationResponse: (response) {
onSelectNotification?.call(response.payload);
},
);
}
static Future<void> show({
required String title,
required String body,
Map<String, dynamic>? payload,
}) async {
const androidDetails = AndroidNotificationDetails(
FcmConfig.defaultChannelId,
FcmConfig.defaultChannelName,
channelDescription: FcmConfig.defaultChannelDesc,
importance: Importance.high,
priority: Priority.high,
);
const details = NotificationDetails(android: androidDetails);
await _plugin.show(
id: DateTime.now().millisecondsSinceEpoch ~/ 1000,
title: title,
body: body,
notificationDetails: details,
payload: payload?.toString(),
);
}
}

View File

@@ -0,0 +1,19 @@
class NotificationIntentStore {
NotificationIntentStore._();
static Map<String, dynamic>? _pendingData;
static set pending(Map<String, dynamic> data) {
_pendingData = Map<String, dynamic>.from(data);
}
static Map<String, dynamic>? get pending => _pendingData;
static Map<String, dynamic>? consume() {
final data = _pendingData;
_pendingData = null;
return data;
}
static bool get hasPending => _pendingData != null;
}

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;
}
}
}

View File

@@ -0,0 +1,166 @@
class AppValidators {
AppValidators._();
static String? required(
String value, {
String message = 'Field tidak boleh kosong',
}) {
if (value.trim().isEmpty) {
return message;
}
return null;
}
static String? minLength(
String value,
int min, {
String? message,
}) {
if (value.length < min) {
return message ?? 'Minimal $min karakter';
}
return null;
}
static String? maxLength(
String value,
int max, {
String? message,
}) {
if (value.length > max) {
return message ?? 'Maksimal $max karakter';
}
return null;
}
static final _emailRegex = RegExp(r'^[\w\.-]+@([\w-]+\.)+[\w-]{2,}$');
static String? email(
String value, {
String emptyMessage = 'Email tidak boleh kosong',
String invalidMessage = 'Format email tidak valid',
}) {
if (value.trim().isEmpty) {
return emptyMessage;
}
if (!_emailRegex.hasMatch(value.trim())) {
return invalidMessage;
}
return null;
}
static final _phoneRegex = RegExp(r'^[0-9]{9,15}$');
static String? phone(
String value, {
String emptyMessage = 'Nomor telepon tidak boleh kosong',
String invalidMessage = 'Nomor telepon tidak valid',
}) {
final v = value.replaceAll(RegExp('[^0-9]'), '');
if (v.isEmpty) {
return emptyMessage;
}
if (!_phoneRegex.hasMatch(v)) {
return invalidMessage;
}
return null;
}
static String? password(
String value, {
int minLength = 8,
bool requireUppercase = true,
bool requireNumber = true,
bool requireSymbol = false,
}) {
if (value.isEmpty) {
return 'Password tidak boleh kosong';
}
if (value.length < minLength) {
return 'Password minimal $minLength karakter';
}
if (requireUppercase && !value.contains(RegExp('[A-Z]'))) {
return 'Password harus mengandung huruf besar';
}
if (requireNumber && !value.contains(RegExp('[0-9]'))) {
return 'Password harus mengandung angka';
}
if (requireSymbol && !value.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]'))) {
return 'Password harus mengandung simbol';
}
return null;
}
static String? number(
String value, {
String message = 'Harus berupa angka',
}) {
if (value.isEmpty) return null;
if (num.tryParse(value) == null) {
return message;
}
return null;
}
static String? minNumber(
String value,
num min, {
String? message,
}) {
final n = num.tryParse(value);
if (n == null) return 'Harus berupa angka';
if (n < min) {
return message ?? 'Minimal $min';
}
return null;
}
static String? maxNumber(
String value,
num max, {
String? message,
}) {
final n = num.tryParse(value);
if (n == null) return 'Harus berupa angka';
if (n > max) {
return message ?? 'Maksimal $max';
}
return null;
}
static String? equals(
String value,
String otherValue, {
String message = 'Nilai tidak sama',
}) {
if (value != otherValue) {
return message;
}
return null;
}
static String? compose(
String value,
List<String? Function(String)> validators,
) {
for (final validator in validators) {
final result = validator(value);
if (result != null) return result;
}
return null;
}
}

View File

@@ -0,0 +1,80 @@
import 'package:intl/intl.dart';
class DateTimeUtils {
DateTimeUtils._();
static const _locale = 'id_ID';
static String yyyyMMdd(DateTime date) {
return DateFormat('yyyy-MM-dd', _locale).format(date);
}
static String ddMMMyyyy(DateTime date) {
return DateFormat('dd MMM yyyy', _locale).format(date);
}
static String ddMMMMyyyy(DateTime date) {
return DateFormat('dd MMMM yyyy', _locale).format(date);
}
static String ddMMMyyyyHHmm(DateTime date) {
return DateFormat('dd MMM yyyy HH:mm', _locale).format(date);
}
static String hhMM(DateTime date) {
return DateFormat('HH:mm', _locale).format(date);
}
static String hhMMss(DateTime date) {
return DateFormat('HH:mm:ss', _locale).format(date);
}
static String dayDDMMMMyyyy(DateTime date) {
return DateFormat('EEEE, dd MMMM yyyy', _locale).format(date);
}
static String dayDDMMMMyyyyHHmm(DateTime date) {
return DateFormat('EEEE, dd MMMM yyyy HH:mm:ss', _locale).format(date);
}
static DateTime? parse(String? value) {
if (value == null || value.isEmpty) return null;
return DateTime.tryParse(value);
}
static String toApi(DateTime date) {
return DateFormat("yyyy-MM-dd'T'HH:mm:ss", _locale).format(date);
}
static String timeAgo(DateTime date) {
final diff = DateTime.now().difference(date);
if (diff.inSeconds < 60) {
return '${diff.inSeconds} detik lalu';
} else if (diff.inMinutes < 60) {
return '${diff.inMinutes} menit lalu';
} else if (diff.inHours < 24) {
return '${diff.inHours} jam lalu';
} else if (diff.inDays < 7) {
return '${diff.inDays} hari lalu';
}
return dayDDMMMMyyyy(date);
}
static String formatDateTimeWithGMT(DateTime date) {
final formatted = DateFormat(
'MMM dd, yyyy hh:mm:ss a',
_locale,
).format(date);
final offset = date.timeZoneOffset;
final sign = offset.isNegative ? '-' : '+';
final hours = offset.inHours.abs().toString().padLeft(2, '0');
final minutes = (offset.inMinutes.abs() % 60).toString().padLeft(2, '0');
return '$formatted GMT$sign$hours:$minutes';
}
}

View File

@@ -0,0 +1,5 @@
String formatFileSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}

View File

@@ -0,0 +1,58 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
class SessionManager {
static const _refreshTokenKey = 'refreshToken';
static const _tokenKey = 'token';
static const _userKey = 'user';
static Future<void> saveToken(String token) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, token);
}
static Future<void> saveRefreshToken(String token) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_refreshTokenKey, token);
}
static Future<String?> getToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_tokenKey);
}
static Future<String?> getRefreshToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_refreshTokenKey);
}
static Future<void> saveUser(Map<String, dynamic> user) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_userKey, jsonEncode(user));
}
static Future<Map<String, dynamic>?> getUser() async {
final prefs = await SharedPreferences.getInstance();
final data = prefs.getString(_userKey);
if (data == null) return null;
return jsonDecode(data) as Map<String, dynamic>;
}
static Future<String?> getUserName() async {
final user = await getUser();
return user?['name']?.toString() ?? '';
}
static Future<String?> getNrp() async {
final user = await getUser();
return user?['nrp']?.toString() ?? '';
}
static Future<void> clearSession() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_tokenKey);
await prefs.remove(_userKey);
}
}

View File

@@ -0,0 +1,181 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:frontend_eccp_mobile/app/core/constants/app_colors.dart';
import 'package:frontend_eccp_mobile/app/core/constants/enum/snackbar_type_enum.dart';
void showMessage(
BuildContext context,
String message,
SnackBarType snackBarType, {
int duration = 2000,
}) {
final overlay = Overlay.of(context);
final indicatorColor = switch (snackBarType) {
SnackBarType.error => AppColors.primary900,
SnackBarType.success => AppColors.statusSuccess,
SnackBarType.warning => AppColors.statusWarning,
_ => const Color(0xFF7FAAF6),
};
late OverlayEntry entry;
entry = OverlayEntry(
builder: (_) => _TopSnackBar(
message: message,
duration: duration,
indicatorColor: indicatorColor,
onDismiss: () {
entry.remove();
},
),
);
overlay.insert(entry);
}
class _TopSnackBar extends StatefulWidget {
const _TopSnackBar({
required this.message,
required this.duration,
required this.indicatorColor,
required this.onDismiss,
});
final String message;
final int duration;
final Color indicatorColor;
final VoidCallback onDismiss;
@override
State<_TopSnackBar> createState() => _TopSnackBarState();
}
class _TopSnackBarState extends State<_TopSnackBar>
with SingleTickerProviderStateMixin {
late AnimationController controller;
late Animation<Offset> slideAnimation;
late Animation<double> opacityAnimation;
@override
void initState() {
super.initState();
controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 620),
reverseDuration: const Duration(milliseconds: 520),
);
slideAnimation =
Tween<Offset>(
begin: const Offset(0, -2),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: controller,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
),
);
opacityAnimation =
Tween<double>(
begin: 0,
end: 1,
).animate(
CurvedAnimation(
parent: controller,
curve: Curves.easeOut,
),
);
unawaited(controller.forward());
unawaited(_startTimer());
}
Future<void> _startTimer() async {
await Future<void>.delayed(Duration(milliseconds: widget.duration));
await dismiss();
}
Future<void> dismiss() async {
if (!mounted) return;
await controller.reverse();
widget.onDismiss();
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Positioned(
top: MediaQuery.of(context).padding.top + 8,
left: 16.w,
right: 16.w,
child: SlideTransition(
position: slideAnimation,
child: FadeTransition(
opacity: opacityAnimation,
child: Material(
color: Colors.transparent,
child: GestureDetector(
onTap: dismiss,
child: Container(
constraints: BoxConstraints(maxWidth: 600.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8.r),
boxShadow: [
BoxShadow(
color: Colors.black12,
blurRadius: 10.r,
offset: const Offset(0, 4),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 0),
child: Text(
widget.message,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
),
SizedBox(height: 12.h),
TweenAnimationBuilder<double>(
duration: Duration(milliseconds: widget.duration),
tween: Tween(begin: 1, end: 0),
builder: (_, value, _) {
return LinearProgressIndicator(
value: value,
color: widget.indicatorColor,
backgroundColor: Colors.transparent,
minHeight: 4.h,
);
},
),
],
),
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,50 @@
import 'dart:developer';
import 'package:shared_preferences/shared_preferences.dart';
class TokenManager {
static const _tokenKey = 'token';
static const _refreshTokenKey = 'refreshToken';
static const _oneTimeFlagPrefix = 'one_time_flag__';
static Future<void> setOneTimeFlag(
String name, {
required bool value,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('$_oneTimeFlagPrefix$name', value);
}
static Future<bool> getOneTimeFlag(String name) async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool('$_oneTimeFlagPrefix$name') ?? false;
}
static Future<void> clearOneTimeFlag(
String name,
) async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('$_oneTimeFlagPrefix$name');
}
static Future<void> saveRefreshToken(String token) async {
log('SAVE REFRESH TOKEN: $token');
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_refreshTokenKey, token);
}
static Future<void> clearToken() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_refreshTokenKey, '');
await prefs.setString(_tokenKey, '');
}
static Future<String?> getRefreshToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_refreshTokenKey);
}
static Future<void> removeToken() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_tokenKey);
}
}

View File

@@ -0,0 +1,20 @@
class VersionUtils {
static bool isNewer(String remote, String local) {
final r = _parse(remote);
final l = _parse(local);
for (var i = 0; i < 3; i++) {
if (r[i] > l[i]) return true;
if (r[i] < l[i]) return false;
}
return false;
}
static List<int> _parse(String v) {
final parts = v.split('.');
return List.generate(
3,
(i) => i < parts.length ? int.tryParse(parts[i]) ?? 0 : 0,
);
}
}

View File

@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:frontend_eccp_mobile/app/core/constants/constants.dart';
class AppAccordion extends StatefulWidget {
const AppAccordion({
required this.title,
required this.child,
this.description,
this.initialExpanded = false,
super.key,
});
final String title;
final String? description;
final Widget child;
final bool initialExpanded;
@override
State<AppAccordion> createState() => _AppAccordionState();
}
class _AppAccordionState extends State<AppAccordion> {
late bool expanded;
@override
void initState() {
super.initState();
expanded = widget.initialExpanded;
}
void toggle() {
setState(() {
expanded = !expanded;
});
}
@override
Widget build(BuildContext context) {
return Container(
margin: AppPadding.b12,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.neutral200),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.06),
blurRadius: 3,
),
],
),
child: Column(
children: [
InkWell(
onTap: toggle,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
child: Row(
children: [
Expanded(
child: Text(
widget.title,
style: AppTextStyles.label.copyWith(
fontWeight: FontWeight.w700,
),
),
),
AnimatedRotation(
turns: expanded ? 0.5 : 0,
duration: AppDurations.fast,
child: Icon(
Icons.keyboard_arrow_down,
color: AppColors.neutral400,
),
),
],
),
),
),
ClipRect(
child: AnimatedAlign(
alignment: Alignment.topCenter,
heightFactor: expanded ? 1 : 0,
duration: AppDurations.fast,
curve: Curves.easeInOut,
child: AnimatedOpacity(
opacity: expanded ? 1 : 0,
duration: AppDurations.fast,
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.description != null) ...[
Text(
widget.description!,
style: AppTextStyles.small.copyWith(
fontStyle: FontStyle.italic,
color: AppColors.neutral700,
),
),
AppSpacing.h8,
],
widget.child,
],
),
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:frontend_eccp_mobile/app/core/constants/constants.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
class AppBottomNav extends StatelessWidget {
const AppBottomNav({
required this.currentIndex,
required this.onTap,
super.key,
});
final int currentIndex;
final ValueChanged<int> onTap;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.only(
top: AppSizes.s12,
bottom: MediaQuery.of(context).viewPadding.bottom,
),
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: AppColors.neutral200),
),
color: Colors.white,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_NavItem(
0,
currentIndex,
IconsaxPlusLinear.home_2,
IconsaxPlusBold.home_2,
'Beranda',
onTap,
),
_NavItem(
1,
currentIndex,
IconsaxPlusLinear.document,
IconsaxPlusBold.document,
'Berkas',
onTap,
),
_NavItem(
2,
currentIndex,
IconsaxPlusLinear.setting,
IconsaxPlusBold.setting,
'Pengaturan',
onTap,
),
],
),
);
}
}
class _NavItem extends StatelessWidget {
const _NavItem(
this.index,
this.currentIndex,
this.icon,
this.activeIcon,
this.label,
this.onTap,
);
final int index;
final int currentIndex;
final IconData icon;
final IconData activeIcon;
final String label;
final ValueChanged<int> onTap;
bool get isActive => index == currentIndex;
@override
Widget build(BuildContext context) {
final activeColor = AppColors.primary900;
final inactiveColor = AppColors.neutral500;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onTap(index),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isActive ? activeIcon : icon,
size: AppIconSizes.lg,
color: AppColors.primary900,
),
AppSpacing.h4,
Text(
label,
style: AppTextStyles.badge.copyWith(
fontSize: 11,
color: isActive ? activeColor : inactiveColor,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:frontend_eccp_mobile/app/core/constants/constants.dart';
import 'package:frontend_eccp_mobile/app/core/constants/enum/appbar_type_enum.dart';
class AppButton extends StatelessWidget {
const AppButton({
required this.label,
required this.onPressed,
super.key,
this.type = AppButtonType.primary,
this.width,
this.height,
this.isExpanded = false,
this.radius = AppSizes.s8,
this.borderWidth = 1.5,
this.paddingVertical = 14,
this.paddingHorizontal = 24,
this.backgroundColor,
this.borderColor,
this.textColor,
this.fontSize = 16,
this.fontWeight = FontWeight.w600,
this.isLoading = false,
this.customContent,
});
final String label;
final VoidCallback? onPressed;
final AppButtonType type;
final double? width;
final double? height;
final bool isExpanded;
final double radius;
final double borderWidth;
final double paddingVertical;
final double paddingHorizontal;
final Color? backgroundColor;
final Color? borderColor;
final Color? textColor;
final double fontSize;
final FontWeight fontWeight;
final bool isLoading;
final Widget? customContent;
bool get _isPrimary => type == AppButtonType.primary;
@override
Widget build(BuildContext context) {
final bgColor = _isPrimary
? (backgroundColor ?? AppColors.primary900)
: (backgroundColor ?? AppColors.neutralWhite);
final brColor = _isPrimary
? Colors.transparent
: (borderColor ?? AppColors.primary900);
final txtColor = _isPrimary
? (textColor ?? Colors.white)
: (textColor ?? AppColors.primary900);
final defaultChild = Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: isLoading ? AppIconSizes.sm : 0,
margin: EdgeInsets.only(
right: isLoading ? AppSizes.s12 : 0,
),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: isLoading ? 1 : 0,
child: SizedBox(
width: AppIconSizes.sm,
height: AppIconSizes.sm,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(txtColor),
),
),
),
),
Text(
label,
style: AppTextStyles.button.copyWith(
fontSize: fontSize,
fontWeight: fontWeight,
color: txtColor,
),
),
],
);
final button = ElevatedButton(
onPressed: isLoading ? null : onPressed,
style: ElevatedButton.styleFrom(
elevation: 0,
backgroundColor: bgColor,
padding: EdgeInsets.symmetric(
vertical: paddingVertical,
horizontal: paddingHorizontal,
),
shape: RoundedRectangleBorder(
side: BorderSide(
color: brColor,
width: borderWidth,
),
borderRadius: BorderRadius.circular(radius),
),
),
child: AnimatedSize(
duration: const Duration(milliseconds: 300),
child: customContent ?? defaultChild,
),
);
return SizedBox(
width: isExpanded ? double.infinity : width,
height: height,
child: button,
);
}
}

View File

@@ -0,0 +1,65 @@
import 'package:animated_custom_dropdown/custom_dropdown.dart';
import 'package:flutter/material.dart';
import 'package:frontend_eccp_mobile/app/core/constants/constants.dart';
class AppDropdown<T> extends StatelessWidget {
const AppDropdown({
required this.items,
required this.onChanged,
super.key,
this.value,
this.title,
this.hint = 'Select',
this.showTitle = true,
this.isRequired = false,
this.isValid = true,
this.informationValid = '',
this.bgColor = const Color(0xFFF9FAFB),
this.borderColor = const Color(0xFFE5E7EB),
this.radius = AppSizes.s16,
});
final List<T> items;
final T? value;
final ValueChanged<T?> onChanged;
final String? title;
final String hint;
final bool showTitle;
final bool isRequired;
final bool isValid;
final String informationValid;
final Color bgColor;
final Color borderColor;
final double radius;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showTitle && title != null)
Padding(
padding: const EdgeInsets.only(bottom: AppSizes.s12),
child: Text(
title!,
style: AppTextStyles.label,
),
),
CustomDropdown<T>(
items: items,
initialItem: value,
onChanged: onChanged,
hintText: hint,
overlayHeight: 240,
decoration: CustomDropdownDecoration(
closedBorderRadius: BorderRadius.circular(radius),
closedFillColor: bgColor,
hintStyle: AppTextStyles.body,
),
),
],
);
}
}

View File

@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:frontend_eccp_mobile/app/core/constants/app_colors.dart';
import 'package:frontend_eccp_mobile/app/core/constants/app_spacing.dart';
import 'package:frontend_eccp_mobile/app/core/constants/app_text_styles.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
Widget empty() {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
IconsaxPlusLinear.task_square,
size: 42,
color: AppColors.neutral400,
),
AppSpacing.h12,
Text(
'Oops,',
style: AppTextStyles.label,
),
Text(
'No Data Found',
style: AppTextStyles.small.copyWith(
color: AppColors.neutral600,
),
),
],
);
}

View File

@@ -0,0 +1,212 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:frontend_eccp_mobile/app/core/constants/constants.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
class AppShadInput extends StatefulWidget {
const AppShadInput({
required this.title,
required this.hint,
super.key,
this.showTitle = true,
this.isRequired = false,
this.isEnabled = true,
this.width,
this.isPassword = false,
this.maxLines = 1,
this.maxLength,
this.keyboardType,
this.inputFormatters,
this.onChanged,
this.leading,
this.trailing,
this.focusNode,
this.textInputAction,
this.autofocus = false,
this.readOnly = false,
this.padding,
this.inputPadding = const EdgeInsets.symmetric(vertical: 7, horizontal: 5),
this.bgColor = Colors.white,
this.decoration,
this.controller,
this.isForm = false,
this.id,
this.validator,
this.description,
this.radius,
});
final String title;
final String hint;
final bool showTitle;
final bool isRequired;
final bool isEnabled;
final double? width;
final double? radius;
final bool isPassword;
final int maxLines;
final int? maxLength;
final TextInputType? keyboardType;
final List<TextInputFormatter>? inputFormatters;
final void Function(String)? onChanged;
final Widget? leading;
final Widget? trailing;
final FocusNode? focusNode;
final TextInputAction? textInputAction;
final bool autofocus;
final bool readOnly;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry inputPadding;
final Color bgColor;
final ShadDecoration? decoration;
final TextEditingController? controller;
final bool isForm;
final String? id;
final String? Function(String value)? validator;
final Widget? description;
@override
State<AppShadInput> createState() => _AppShadInputState();
}
class _AppShadInputState extends State<AppShadInput> {
bool _obscure = true;
@override
void initState() {
super.initState();
assert(
widget.isForm ? widget.id != null : widget.controller != null,
'Jika isForm=true maka id wajib diisi, jika isForm=false maka controller wajib diisi',
);
}
ShadDecoration get _decoration =>
widget.decoration ??
ShadDecoration(
color: widget.bgColor,
border: ShadBorder.all(
color: AppColors.neutral200,
radius: BorderRadius.circular(widget.radius ?? AppSizes.s12),
),
secondaryFocusedBorder: ShadBorder.all(
color: AppColors.neutral700,
width: 2,
radius: BorderRadius.circular(
(widget.radius ?? AppSizes.s12) + 4,
),
),
);
@override
Widget build(BuildContext context) {
final trailing = widget.isPassword
? ShadIconButton(
width: 24,
height: 24,
backgroundColor: AppColors.neutral200,
pressedBackgroundColor: AppColors.neutral200,
icon: Icon(
_obscure ? LucideIcons.eyeOff : LucideIcons.eye,
size: 18,
color: AppColors.neutral600,
),
onPressed: () {
setState(() => _obscure = !_obscure);
},
)
: widget.trailing;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.showTitle)
Padding(
padding: const EdgeInsets.only(bottom: AppSizes.s12),
child: Row(
children: [
Text(
widget.title,
style: AppTextStyles.label.copyWith(
color: AppColors.supportSteel600,
),
),
if (widget.isRequired)
const Text(
' *',
style: TextStyle(
fontSize: 16,
color: Colors.red,
),
),
],
),
),
SizedBox(
width: widget.width,
child: widget.isForm
? ShadInputFormField(
id: widget.id,
enabled: widget.isEnabled,
validator: widget.validator,
description: widget.description,
placeholder: Text(
widget.hint,
style: AppTextStyles.body.copyWith(
color: AppColors.neutral900.withOpacity(0.5),
),
),
decoration: _decoration,
obscureText: widget.isPassword && _obscure,
maxLines: widget.maxLines,
maxLength: widget.maxLength,
keyboardType: widget.keyboardType,
inputFormatters: widget.inputFormatters,
onChanged: widget.onChanged,
leading: widget.leading,
onPressedOutside: (event) => FocusScope.of(context).unfocus(),
trailing: trailing,
inputPadding: widget.inputPadding,
style: AppTextStyles.body,
)
: ShadInput(
controller: widget.controller,
enabled: widget.isEnabled,
focusNode: widget.focusNode,
readOnly: widget.readOnly,
autofocus: widget.autofocus,
keyboardType: widget.keyboardType,
textInputAction: widget.textInputAction,
maxLines: widget.maxLines,
maxLength: widget.maxLength,
inputFormatters: widget.inputFormatters,
obscureText: widget.isPassword && _obscure,
onChanged: widget.onChanged,
padding: widget.padding,
inputPadding: widget.inputPadding,
onPressedOutside: (event) => FocusScope.of(context).unfocus(),
decoration: _decoration,
placeholder: Text(
widget.hint,
style: AppTextStyles.body.copyWith(
color: AppColors.neutral900.withOpacity(0.5),
),
),
style: AppTextStyles.body,
leading: widget.leading,
trailing: trailing,
),
),
],
);
}
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:frontend_eccp_mobile/app/core/constants/app_colors.dart';
import 'package:frontend_eccp_mobile/app/core/constants/app_spacing.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
Widget somethingWrong() {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
IconsaxPlusLinear.cloud_remove,
size: 52,
color: AppColors.neutral400,
),
AppSpacing.h12,
Text(
'Oops, something wrong',
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.neutral900,
),
),
AppSpacing.h4,
Text(
'Please try again later',
style: GoogleFonts.inter(
fontSize: 12,
color: AppColors.neutral600,
),
),
AppSpacing.h12,
],
);
}

View File

@@ -0,0 +1,278 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:frontend_eccp_mobile/app/core/constants/constants.dart';
class AppTextField extends StatefulWidget {
const AppTextField({
required this.title,
required this.hint,
required this.password,
required this.controller,
required this.isValid,
required this.informationValid,
super.key,
this.maxLine = 1,
this.maxLength,
this.showTitle = true,
this.isEnabled = true,
this.keyboardType,
this.radius = AppSizes.s16,
this.phoneText,
this.inputFormatters,
this.width,
this.isRequired = false,
this.contentPaddingVertical = AppSizes.s12,
this.prefixIcon,
this.showPrefixIcon = false,
this.prefixIconColor,
this.prefixIconSize = AppIconSizes.md,
this.prefixIconPadding = AppSizes.s12,
this.isDense = false,
this.minHeight,
this.onChanged,
this.iconRight,
this.bgColor = const Color(0xFFF9FAFB),
this.hintColor = const Color(0x800A0A0A),
this.titleColor = const Color(0xFF364153),
this.borderColor = const Color(0xFFE5E7EB),
});
final String title;
final String hint;
final bool password;
final TextEditingController controller;
final int maxLine;
final int? maxLength;
final double contentPaddingVertical;
final bool isEnabled;
final bool showTitle;
final bool isRequired;
final bool isValid;
final String informationValid;
final double radius;
final double? width;
final String? phoneText;
final TextInputType? keyboardType;
final List<TextInputFormatter>? inputFormatters;
final Color bgColor;
final Color titleColor;
final Color hintColor;
final Color borderColor;
final Widget? prefixIcon;
final bool showPrefixIcon;
final Color? prefixIconColor;
final double prefixIconSize;
final double prefixIconPadding;
final bool isDense;
final double? minHeight;
final Widget? iconRight;
final void Function(String)? onChanged;
@override
State<AppTextField> createState() => _AppTextFieldState();
}
class _AppTextFieldState extends State<AppTextField> {
bool _showPass = true;
final FocusNode _focusNode = FocusNode();
final GlobalKey _fieldKey = GlobalKey();
OverlayEntry? _overlayEntry;
void _scrollIntoView() {
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!mounted) return;
await Scrollable.ensureVisible(
context,
alignment: 0.25,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
});
}
@override
void initState() {
super.initState();
_focusNode.addListener(() {
if (_focusNode.hasFocus) {
_addTapOutsideOverlay();
_scrollIntoView();
} else {
_removeTapOutsideOverlay();
}
});
}
void _addTapOutsideOverlay() {
_overlayEntry = OverlayEntry(
builder: (_) => Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: _focusNode.unfocus,
),
),
);
Overlay.of(context).insert(_overlayEntry!);
}
void _removeTapOutsideOverlay() {
_overlayEntry?.remove();
_overlayEntry = null;
}
@override
void dispose() {
_removeTapOutsideOverlay();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
key: _fieldKey,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.showTitle)
Padding(
padding: AppPadding.b8,
child: Row(
children: [
Text(
widget.title,
style: AppTextStyles.label.copyWith(
color: widget.titleColor,
fontWeight: FontWeight.w400,
),
),
if (widget.isRequired)
Text(
' *',
style: AppTextStyles.body.copyWith(
color: AppColors.primary500,
fontWeight: FontWeight.w600,
),
),
],
),
),
Container(
width: widget.width,
constraints: widget.minHeight != null
? BoxConstraints(minHeight: widget.minHeight!)
: null,
decoration: BoxDecoration(
color: widget.bgColor,
borderRadius: BorderRadius.circular(widget.radius),
border: Border.all(
color: widget.isValid ? widget.borderColor : AppColors.primary500,
width: 1.2,
),
),
child: Row(
children: [
if (widget.showPrefixIcon && widget.prefixIcon != null)
Padding(
padding: EdgeInsets.only(
left: widget.prefixIconPadding,
right: AppSizes.s12,
),
child: IconTheme(
data: IconThemeData(
size: widget.prefixIconSize,
color: widget.prefixIconColor ?? widget.hintColor,
),
child: widget.prefixIcon!,
),
)
else
AppSpacing.w16,
if (widget.phoneText != null)
Padding(
padding: AppPadding.r8,
child: Text(
widget.phoneText!,
style: AppTextStyles.body.copyWith(
color: widget.hintColor,
fontWeight: FontWeight.w500,
),
),
),
Expanded(
child: TextFormField(
controller: widget.controller,
obscureText: widget.password && _showPass,
maxLines: widget.maxLine,
enabled: widget.isEnabled,
maxLength: widget.maxLength,
keyboardType: widget.keyboardType,
inputFormatters: widget.inputFormatters,
style: AppTextStyles.body.copyWith(
fontSize: widget.isDense ? 13 : 14,
),
decoration: InputDecoration(
isDense: widget.isDense,
hintText: widget.hint,
hintStyle: AppTextStyles.body.copyWith(
fontSize: widget.isDense ? 12 : 13,
color: widget.hintColor,
),
contentPadding: EdgeInsets.symmetric(
vertical: widget.isDense
? AppSizes.s8
: widget.contentPaddingVertical,
),
border: InputBorder.none,
counterText: '',
),
onChanged: widget.onChanged,
),
),
if (widget.iconRight != null)
Padding(
padding: AppPadding.r12,
child: widget.iconRight,
),
if (widget.password)
Padding(
padding: AppPadding.r12,
child: GestureDetector(
onTap: () => setState(() => _showPass = !_showPass),
child: Icon(
_showPass
? Icons.visibility_off_rounded
: Icons.visibility_rounded,
size: AppIconSizes.md,
color: AppColors.neutral400,
),
),
),
],
),
),
if (!widget.isValid)
Padding(
padding: const EdgeInsets.only(
top: AppSizes.s8,
left: AppSizes.s12,
),
child: Text(
widget.informationValid,
style: AppTextStyles.small.copyWith(
color: AppColors.primary500,
),
),
),
],
);
}
}

View File

@@ -0,0 +1,211 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:frontend_eccp_mobile/app/core/constants/constants.dart';
import 'package:frontend_eccp_mobile/app/core/constants/enum/appbar_type_enum.dart';
import 'package:frontend_eccp_mobile/app/core/widgets/app_button.dart';
import 'package:frontend_eccp_mobile/app/core/widgets/bottomsheet/appbottomsheet_controller.dart';
class AppBottomSheet extends StatefulWidget {
const AppBottomSheet({
required this.controller,
super.key,
this.icon,
this.title,
this.subTitle,
this.content,
this.primaryText,
this.secondaryText,
this.onPrimaryPressed,
this.onSecondaryPressed,
this.usePrimaryButton = true,
this.useSecondaryButton = false,
this.onOpened,
this.onClosed,
this.onDismissed,
this.enableDrag = true,
});
final AppBottomSheetController controller;
final String? icon;
final String? title;
final String? subTitle;
final Widget? content;
final String? primaryText;
final String? secondaryText;
final VoidCallback? onPrimaryPressed;
final VoidCallback? onSecondaryPressed;
final bool useSecondaryButton;
final bool usePrimaryButton;
final VoidCallback? onOpened;
final VoidCallback? onClosed;
final VoidCallback? onDismissed;
final bool enableDrag;
@override
State<AppBottomSheet> createState() => _AppBottomSheetState();
}
class _AppBottomSheetState extends State<AppBottomSheet> {
bool _dismissedBySystem = true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onOpened?.call();
});
}
@override
void dispose() {
if (_dismissedBySystem) {
widget.onDismissed?.call();
}
widget.onClosed?.call();
super.dispose();
}
void _closeByAction() {
_dismissedBySystem = false;
Navigator.pop(context);
}
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: AnimatedBuilder(
animation: widget.controller,
builder: (_, _) {
return Align(
alignment: Alignment.bottomCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: AppSizes.maxContentWidth,
),
child: Container(
padding: EdgeInsets.fromLTRB(
AppSizes.s16,
AppSizes.s16,
AppSizes.s16,
MediaQuery.of(context).viewPadding.bottom + AppSizes.s16,
),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(
top: Radius.circular(16),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: AppSizes.bottomSheetHandleWidth,
height: AppSizes.bottomSheetHandleHeight,
margin: const EdgeInsets.only(
bottom: AppSizes.s16,
),
decoration: BoxDecoration(
color: AppColors.neutral200,
borderRadius: BorderRadius.circular(AppSizes.s8),
),
),
),
if (widget.icon != null) ...[
Center(
child: widget.icon!.endsWith('.svg')
? SvgPicture.asset(
widget.icon!,
width:
MediaQuery.of(context).size.width *
AppSizes.illustrationWidthFactor,
)
: Image.asset(
widget.icon!,
width:
MediaQuery.of(context).size.width *
AppSizes.illustrationWidthFactor,
),
),
AppSpacing.h16,
],
if (widget.title != null)
Text(
widget.title!,
style: AppTextStyles.h3,
),
if (widget.subTitle != null) ...[
AppSpacing.h8,
Text(
widget.subTitle!,
style: AppTextStyles.small,
),
],
if (widget.content != null) ...[
AppSpacing.h24,
widget.content!,
],
if (widget.useSecondaryButton || widget.usePrimaryButton)
AppSpacing.h24,
Row(
children: [
if (widget.useSecondaryButton) ...[
Expanded(
child: AppButton(
radius: AppSizes.s12,
paddingVertical: AppSizes.s12,
backgroundColor: Colors.white,
label: widget.secondaryText ?? 'Batal',
type: AppButtonType.secondary,
onPressed: widget.controller.isLoading
? null
: () {
_closeByAction();
widget.onSecondaryPressed?.call();
},
),
),
AppSpacing.w12,
],
if (widget.usePrimaryButton)
Expanded(
child: AppButton(
label: widget.primaryText ?? 'OK',
radius: AppSizes.s12,
paddingVertical: AppSizes.s12,
isLoading: widget.controller.isLoading,
onPressed: widget.controller.isLoading
? null
: () {
widget.onPrimaryPressed?.call();
},
),
),
],
),
],
),
),
),
);
},
),
);
}
}

View File

@@ -0,0 +1,13 @@
import 'package:flutter/foundation.dart';
class AppBottomSheetController extends ChangeNotifier {
bool _isLoading = false;
bool get isLoading => _isLoading;
void setLoading({required bool value}) {
if (_isLoading == value) return;
_isLoading = value;
notifyListeners();
}
}

View File

@@ -0,0 +1,289 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:frontend_eccp_mobile/app/core/constants/constants.dart';
import 'package:pull_to_refresh_flutter3/pull_to_refresh_flutter3.dart';
typedef AppAsyncCallback = Future<void> Function();
class AppScreen extends StatelessWidget {
const AppScreen({
required this.body,
super.key,
this.isSliver = false,
this.slivers,
this.appBar,
this.title,
this.centerTitle = true,
this.leading,
this.actions,
this.onBack,
this.useWillPopScope = false,
this.useShadowAppBar = true,
this.scrollableBody = true,
this.scrollController,
this.physics,
this.refreshController,
this.enablePullDown = false,
this.enablePullUp = false,
this.onRefresh,
this.onLoadMore,
this.refreshHeader,
this.refreshFooter,
this.footers,
this.footersPadding = AppPadding.p16,
this.hasSafeArea = true,
this.safeTop = true,
this.safeBottom,
this.safeLeft = true,
this.safeRight = true,
this.backgroundColor,
this.backgroundWidgets,
this.resizeToAvoidBottomInset,
this.floatingActionButton,
this.bottomNavigationBar,
this.extendBody = false,
this.extendBodyBehindAppBar = false,
this.statusBarStyle,
this.isLoading = false,
});
final bool isLoading;
final Widget body;
final bool isSliver;
final List<Widget>? slivers;
final PreferredSizeWidget? appBar;
final bool useShadowAppBar;
final String? title;
final bool centerTitle;
final Widget? leading;
final List<Widget>? actions;
final VoidCallback? onBack;
final bool useWillPopScope;
final bool scrollableBody;
final ScrollController? scrollController;
final ScrollPhysics? physics;
final RefreshController? refreshController;
final bool enablePullDown;
final bool enablePullUp;
final AppAsyncCallback? onRefresh;
final AppAsyncCallback? onLoadMore;
final Widget? refreshHeader;
final Widget? refreshFooter;
final List<Widget>? footers;
final EdgeInsets footersPadding;
final bool hasSafeArea;
final bool safeTop;
final bool? safeBottom;
final bool safeLeft;
final bool safeRight;
final Color? backgroundColor;
final List<Widget>? backgroundWidgets;
final bool? resizeToAvoidBottomInset;
final Widget? floatingActionButton;
final Widget? bottomNavigationBar;
final bool extendBody;
final bool extendBodyBehindAppBar;
final SystemUiOverlayStyle? statusBarStyle;
Widget _wrapScrollbar(BuildContext context, Widget child) {
return ScrollbarTheme(
data: ScrollbarThemeData(
thumbColor: MaterialStateProperty.all(AppColors.neutral400),
thickness: MaterialStateProperty.all(3),
radius: const Radius.circular(AppSizes.s24),
),
child: Scrollbar(
controller: scrollController,
interactive: true,
thumbVisibility:
MediaQuery.of(context).size.width > AppSizes.maxContentWidth,
child: child,
),
);
}
Widget _buildBodyContent(BuildContext context) {
Widget content;
if (isSliver) {
content = _wrapScrollbar(
context,
CustomScrollView(
controller: scrollController,
physics: physics,
slivers: slivers!,
),
);
} else {
content = body;
if (scrollableBody) {
content = _wrapScrollbar(
context,
SingleChildScrollView(
controller: scrollController,
physics: physics,
child: content,
),
);
}
}
if (refreshController != null) {
content = SmartRefresher(
controller: refreshController!,
enablePullDown: enablePullDown,
enablePullUp: enablePullUp,
onRefresh: onRefresh,
onLoading: onLoadMore,
header: refreshHeader,
footer: refreshFooter,
physics: physics,
child: content,
);
}
if (footers == null) return content;
return Column(
children: [
Expanded(child: content),
Padding(
padding: footersPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: footers!,
),
),
],
);
}
Widget _buildBody(BuildContext context) {
final content = backgroundWidgets == null
? _buildBodyContent(context)
: Stack(
children: [
...backgroundWidgets!,
_buildBodyContent(context),
],
);
final safeContent = hasSafeArea
? SafeArea(
top: safeTop,
left: safeLeft,
right: safeRight,
bottom:
safeBottom ??
(!kIsWeb && defaultTargetPlatform == TargetPlatform.android),
child: content,
)
: content;
return Stack(
children: [
safeContent,
_buildLoadingOverlay(),
],
);
}
Widget _buildLoadingOverlay() {
return AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: isLoading ? 1 : 0,
child: isLoading
? Positioned.fill(
child: ColoredBox(
color: Colors.black.withOpacity(0.1),
child: Center(
child: Container(
width: 70,
height: 70,
padding: AppPadding.p16,
decoration: BoxDecoration(
color: AppColors.neutralWhite,
borderRadius: AppRadius.r10,
),
child: CircularProgressIndicator(
color: AppColors.primary900,
strokeWidth: 3,
backgroundColor: Colors.transparent,
),
),
),
),
)
: const SizedBox(),
);
}
PreferredSizeWidget? _buildAppBar(BuildContext context) {
if (appBar != null) return appBar;
if (title == null) return null;
return AppBar(
backgroundColor: AppColors.neutralWhite,
surfaceTintColor: Colors.transparent,
scrolledUnderElevation: useShadowAppBar ? 4 : 0,
shadowColor: useShadowAppBar
? Colors.black.withOpacity(AppOpacity.opacity15)
: Colors.transparent,
title: Text(
title!,
style: AppTextStyles.h3,
),
centerTitle: centerTitle,
leading:
leading ??
(onBack != null
? IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: onBack,
)
: null),
actions: actions,
);
}
@override
Widget build(BuildContext context) {
final Widget screen = AnnotatedRegion<SystemUiOverlayStyle>(
value:
statusBarStyle ??
SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.transparent,
),
child: Scaffold(
backgroundColor: backgroundColor,
resizeToAvoidBottomInset: resizeToAvoidBottomInset,
appBar: _buildAppBar(context),
body: _buildBody(context),
floatingActionButton: floatingActionButton,
bottomNavigationBar: bottomNavigationBar,
extendBody: extendBody,
extendBodyBehindAppBar: extendBodyBehindAppBar,
),
);
if (!useWillPopScope) return screen;
return WillPopScope(
onWillPop: () async {
onBack?.call();
return false;
},
child: screen,
);
}
}

View File

@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
import 'package:frontend_eccp_mobile/app/core/constants/app_colors.dart';
import 'package:frontend_eccp_mobile/app/core/widgets/layout/app_screen.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
class PdfViewerPage extends StatelessWidget {
const PdfViewerPage({required this.url, super.key});
final String url;
@override
Widget build(BuildContext context) {
return AppScreen(
title: 'Preview File',
backgroundColor: AppColors.neutralWhite,
scrollableBody: false,
body: SfPdfViewer.network(
url,
),
);
}
}

70
lib/app/view/app.dart Normal file
View File

@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:frontend_eccp_mobile/app/core/navigation/app_router.dart';
import 'package:frontend_eccp_mobile/l10n/gen/app_localizations.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return ShadApp.custom(
themeMode: ThemeMode.light,
darkTheme: ShadThemeData(
brightness: Brightness.dark,
colorScheme: const ShadSlateColorScheme.dark(),
),
appBuilder: (contextShad) {
return ScreenUtilInit(
designSize: const Size(412, 915),
minTextAdapt: true,
splitScreenMode: true,
enableScaleWH: () {
final width = MediaQueryData.fromWindow(
WidgetsBinding.instance.window,
).size.width;
return width < 600;
},
enableScaleText: () {
final width = MediaQueryData.fromWindow(
WidgetsBinding.instance.window,
).size.width;
return width < 600;
},
builder: (context, child) {
return MaterialApp.router(
routerDelegate: AppRouter.router.routerDelegate,
routeInformationParser: AppRouter.router.routeInformationParser,
routeInformationProvider:
AppRouter.router.routeInformationProvider,
builder: (context, child) {
return ShadToaster(
child: child ?? const SizedBox(),
);
},
theme: ThemeData(
appBarTheme: AppBarTheme(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
useMaterial3: true,
),
debugShowCheckedModeBanner: false,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
);
},
);
},
);
}
}

146
lib/bootstrap.dart Normal file
View File

@@ -0,0 +1,146 @@
import 'dart:async';
import 'dart:developer';
import 'package:bloc/bloc.dart';
import 'package:chucker_flutter/chucker_flutter.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/widgets.dart';
import 'package:frontend_eccp_mobile/app/core/config/app_flavor.dart';
import 'package:frontend_eccp_mobile/app/core/di/injection.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/notification/fcm_service.dart';
import 'package:frontend_eccp_mobile/app/core/services/bottom_sheet_service.dart';
import 'package:frontend_eccp_mobile/app/core/services/crashlytics_service.dart';
import 'package:frontend_eccp_mobile/app/core/services/location_permission_service.dart';
import 'package:frontend_eccp_mobile/app/core/utils/session_manager.dart';
import 'package:frontend_eccp_mobile/app/core/widgets/bottomsheet/app_bottom_sheet.dart';
import 'package:frontend_eccp_mobile/app/core/widgets/bottomsheet/appbottomsheet_controller.dart';
import 'package:frontend_eccp_mobile/modules/auth/login/data/model/user_session.dart';
class AppBlocObserver extends BlocObserver {
const AppBlocObserver();
@override
void onChange(BlocBase<dynamic> bloc, Change<dynamic> change) {
super.onChange(bloc, change);
log('onChange(${bloc.runtimeType}, $change)');
}
@override
void onError(BlocBase<dynamic> bloc, Object error, StackTrace stackTrace) {
log('onError(${bloc.runtimeType}, $error, $stackTrace)');
super.onError(bloc, error, stackTrace);
}
}
Future<void> initSession() async {
final session = getIt<UserSession>();
final token = await SessionManager.getToken();
final user = await SessionManager.getUser();
if (token != null && user != null) {
session
..token = token
..user = user;
}
}
Future<void> bootstrap(FutureOr<Widget> Function() builder) async {
FlutterError.onError = (details) {
log(details.exceptionAsString(), stackTrace: details.stack);
};
WidgetsFlutterBinding.ensureInitialized();
await configureDependencies();
await Firebase.initializeApp();
await CrashlyticsService.init();
await FcmService.init();
await initSession();
Bloc.observer = const AppBlocObserver();
log('Running flavor: ${Flavor.name}');
if (!Flavor.isProd) {
ChuckerFlutter.showOnRelease = true;
ChuckerFlutter.showNotification = false;
ChuckerFlutter.isDebugMode = true;
}
BottomSheetService.configure(
noInternetBuilder: (context) => AppBottomSheet(
controller: AppBottomSheetController(),
icon: 'assets/images/img-no-internet.png',
title: 'Eh, jaringannya hilang...',
subTitle:
'Harap periksa kembali jaringan internetmu, coba lagi beberapa saat ya!',
primaryText: 'Baik, dimengerti',
onPrimaryPressed: () {
Navigator.pop(context);
},
),
serverErrorBuilder: (ctx) => AppBottomSheet(
controller: AppBottomSheetController(),
title: 'Mohon maaf kami sedang ada gangguan!',
primaryText: 'Baik, dimengerti',
icon: 'assets/images/img-server-error.png',
subTitle:
'Saat ini permintaan kamu belum dapat diproses, coba lagi beberapa saat lagi ya!\nJika berlanjut, hubungi CS.',
onPrimaryPressed: () {
Navigator.pop(ctx);
},
),
notFoundBuilder: (ctx) => AppBottomSheet(
controller: AppBottomSheetController(),
title: 'Opps, kamu tersesat',
primaryText: 'Baik, dimengerti',
icon: 'assets/images/img-not-found.png',
subTitle:
'Mari kembali ke jalur semula, coba lagi beberapa saat lagi ya!\nJika berlanjut, hubungi CS.',
onPrimaryPressed: () {
Navigator.pop(ctx);
},
),
sessionExpiredBuilder: (ctx) => AppBottomSheet(
controller: AppBottomSheetController(),
title: 'Sesi Berakhir',
primaryText: 'Login Ulang',
icon: 'assets/images/img-server-error.png',
subTitle: 'Silakan login ulang untuk melanjutkan.',
onPrimaryPressed: () {
Navigator.pop(ctx);
},
),
locationPermissionBuilder:
(
ctx, {
required onOpenSettings,
VoidCallback? onRequestNow,
}) {
return AppBottomSheet(
controller: AppBottomSheetController(),
title: 'Permintaan Akses Lokasi',
primaryText: 'Buka Pengaturan',
icon: 'assets/images/img-location-permission.png',
subTitle:
'E-CPP ingin meminta akses lokasi kamu agar paket yang diberikan lebih cocok untukmu!',
onPrimaryPressed: () async {
final resumeFut = waitRecheckOnNextResume(() async {
if (await LocationPermissionService.recheckGranted()) {
onOpenSettings();
}
});
await SettingsNavigator.openAppSettings();
await resumeFut;
if (!ctx.mounted) return;
Navigator.pop(ctx);
},
// onSecondary: onRequestNow,
);
},
);
runApp(await builder());
}

18
lib/l10n/arb/app_en.arb Normal file
View File

@@ -0,0 +1,18 @@
{
"@@locale": "en",
"counterAppBarTitle": "Counter",
"@counterAppBarTitle": {
"description": "Text shown in the AppBar of the Counter Page"
},
"@login": {
"title": "Sign-in to continue your maintenance tasks",
"labelUsername": "Employee ID / Username",
"descriptionUsername": "Enter your employee ID / Username",
"labelPassword": "Password",
"descriptionPassword": "Enter your password",
"loginForgotPassword": "Forgot Password?",
"loginButton": "Login",
"questTest": "Having trouble?",
"contactAdmin": "Contact Admin"
}
}

7
lib/l10n/arb/app_es.arb Normal file
View File

@@ -0,0 +1,7 @@
{
"@@locale": "es",
"counterAppBarTitle": "Contador",
"@counterAppBarTitle": {
"description": "Texto mostrado en la AppBar de la página del contador"
}
}

11
lib/l10n/arb/app_id.arb Normal file
View File

@@ -0,0 +1,11 @@
{
"@@locale": "id",
"counterAppBarTitle": "Penghitung",
"@counterAppBarTitle": {
"description": "Teks yang ditampilkan di AppBar pada Halaman Penghitung"
},
"@loginTitle": "Masuk",
"@loginAppBarTitle": {
"description": "Teks yang ditampilkan di AppBar pada Halaman Login"
}
}

8
lib/l10n/l10n.dart Normal file
View File

@@ -0,0 +1,8 @@
import 'package:flutter/widgets.dart';
import 'package:frontend_eccp_mobile/l10n/gen/app_localizations.dart';
export 'package:frontend_eccp_mobile/l10n/gen/app_localizations.dart';
extension AppLocalizationsX on BuildContext {
AppLocalizations get l10n => AppLocalizations.of(this);
}

View File

@@ -0,0 +1,9 @@
import 'package:frontend_eccp_mobile/app/app.dart';
import 'package:frontend_eccp_mobile/app/core/config/app_flavor.dart';
import 'package:frontend_eccp_mobile/app/core/constants/enum/app_flavor_enum.dart';
import 'package:frontend_eccp_mobile/bootstrap.dart';
Future<void> main() async {
Flavor.current = AppFlavor.development;
await bootstrap(() => const App());
}

9
lib/main_production.dart Normal file
View File

@@ -0,0 +1,9 @@
import 'package:frontend_eccp_mobile/app/app.dart';
import 'package:frontend_eccp_mobile/app/core/config/app_flavor.dart';
import 'package:frontend_eccp_mobile/app/core/constants/enum/app_flavor_enum.dart';
import 'package:frontend_eccp_mobile/bootstrap.dart';
Future<void> main() async {
Flavor.current = AppFlavor.production;
await bootstrap(() => const App());
}

9
lib/main_staging.dart Normal file
View File

@@ -0,0 +1,9 @@
import 'package:frontend_eccp_mobile/app/app.dart';
import 'package:frontend_eccp_mobile/app/core/config/app_flavor.dart';
import 'package:frontend_eccp_mobile/app/core/constants/enum/app_flavor_enum.dart';
import 'package:frontend_eccp_mobile/bootstrap.dart';
Future<void> main() async {
Flavor.current = AppFlavor.staging;
await bootstrap(() => const App());
}

View File

@@ -0,0 +1,73 @@
import 'package:dio/dio.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:frontend_eccp_mobile/app/core/di/injection.dart';
import 'package:frontend_eccp_mobile/app/core/services/crashlytics_service.dart';
import 'package:frontend_eccp_mobile/app/core/utils/session_manager.dart';
import 'package:frontend_eccp_mobile/modules/auth/login/bloc/login_state.dart';
import 'package:frontend_eccp_mobile/modules/auth/login/data/model/login_request.dart';
import 'package:frontend_eccp_mobile/modules/auth/login/data/model/user_session.dart';
import 'package:frontend_eccp_mobile/modules/auth/login/data/repositories/login_repository.dart';
import 'package:injectable/injectable.dart';
@injectable
class LoginCubit extends Cubit<LoginState> {
LoginCubit(this._repository) : super(LoginInitial());
final LoginRepository _repository;
Future<void> login({
required String nrp,
required String password,
}) async {
emit(LoginLoading());
try {
final response = await _repository.login(
LoginRequest(
nrp: nrp,
password: password,
),
);
if (response.status != 'success') {
emit(LoginFailure(response.message));
return;
}
await CrashlyticsService.setUser(
id: response.data.user.nrp,
nrp: response.data.user.nrp,
name: response.data.user.name,
);
await SessionManager.saveToken(response.data.accessToken);
await SessionManager.saveRefreshToken(response.data.refreshToken);
final userMap = {
'name': response.data.user.name,
'nrp': response.data.user.nrp,
};
await SessionManager.saveUser(userMap);
final _ = getIt<UserSession>()
..token = response.data.accessToken
..user = userMap;
emit(LoginSuccess());
} on DioException catch (e) {
final handled = e.requestOptions.extra['handledGlobally'] == true;
if (handled) return;
emit(LoginFailure(e.error?.toString()));
} catch (e, s) {
await CrashlyticsService.recordError(
e,
s,
fatal: true,
reason: 'Login Unknown Error',
);
emit(const LoginFailure('Terjadi kesalahan saat login'));
}
}
}

View File

@@ -0,0 +1,32 @@
import 'package:equatable/equatable.dart';
import 'package:frontend_eccp_mobile/app/core/constants/enum/snackbar_type_enum.dart';
abstract class LoginState extends Equatable {
const LoginState();
@override
List<Object?> get props => [];
}
class LoginInitial extends LoginState {}
class LoginLoading extends LoginState {}
class LoginSuccess extends LoginState {}
class LoginFailure extends LoginState {
const LoginFailure([
this.message,
this.snackBarType = SnackBarType.error,
this.duration = 2000,
]);
final String? message;
final SnackBarType snackBarType;
final int duration;
bool get hasMessage => message != null && message!.isNotEmpty;
@override
List<Object?> get props => [message, snackBarType, duration];
}

View File

@@ -0,0 +1,29 @@
import 'package:dio/dio.dart';
import 'package:frontend_eccp_mobile/app/core/constants/dio_constant.dart';
import 'package:frontend_eccp_mobile/app/core/network/api_endpoint.dart';
import 'package:frontend_eccp_mobile/app/core/network/dio_client.dart';
import 'package:frontend_eccp_mobile/modules/auth/login/data/model/login_request.dart';
import 'package:frontend_eccp_mobile/modules/auth/login/data/model/login_response.dart';
import 'package:injectable/injectable.dart';
@lazySingleton
class LoginRemoteDataSource {
final Dio _dio = DioClient.getInstance();
Future<LoginResponse> login(LoginRequest request) async {
final response = await _dio.post<Map<String, dynamic>>(
ApiEndpoint.login,
data: request.toJson(),
options: Options(
extra: {DioExtraKey.noAuth: true},
),
);
final data = response.data;
if (data == null) {
throw Exception('EMPTY_RESPONSE');
}
return LoginResponse.fromJson(data);
}
}

View File

@@ -0,0 +1,29 @@
import 'package:frontend_eccp_mobile/modules/auth/login/data/model/login_user.dart';
class LoginData {
LoginData({
required this.accessToken,
required this.refreshToken,
required this.tokenType,
required this.expiresIn,
required this.user,
});
factory LoginData.fromJson(Map<String, dynamic> json) {
return LoginData(
accessToken: json['access_token']?.toString() ?? '',
refreshToken: json['refresh_token']?.toString() ?? '',
tokenType: json['token_type']?.toString() ?? '',
expiresIn: int.tryParse(json['expires_in']?.toString() ?? '0') ?? 0,
user: LoginUser.fromJson(
json['user'] as Map<String, dynamic>? ?? {},
),
);
}
final String accessToken;
final String refreshToken;
final String tokenType;
final int expiresIn;
final LoginUser user;
}

View File

@@ -0,0 +1,16 @@
class LoginRequest {
LoginRequest({
required this.nrp,
required this.password,
});
final String nrp;
final String password;
Map<String, dynamic> toJson() {
return {
'nrp': nrp,
'password': password,
};
}
}

View File

@@ -0,0 +1,23 @@
import 'package:frontend_eccp_mobile/modules/auth/login/data/model/login_data.dart';
class LoginResponse {
LoginResponse({
required this.status,
required this.message,
required this.data,
});
factory LoginResponse.fromJson(Map<String, dynamic> json) {
return LoginResponse(
status: json['status']?.toString() ?? '',
message: json['message']?.toString() ?? '',
data: LoginData.fromJson(
json['data'] as Map<String, dynamic>? ?? {},
),
);
}
final String status;
final String message;
final LoginData data;
}

View File

@@ -0,0 +1,16 @@
class LoginUser {
LoginUser({
required this.name,
required this.nrp,
});
factory LoginUser.fromJson(Map<String, dynamic> json) {
return LoginUser(
name: json['name']?.toString() ?? '',
nrp: json['nrp']?.toString() ?? '',
);
}
final String name;
final String nrp;
}

View File

@@ -0,0 +1,14 @@
class UserSession {
String? token;
Map<String, dynamic>? user;
bool get isLoggedIn => token != null && token!.isNotEmpty;
String get name => user?['name']?.toString() ?? '';
String get nrp => user?['nrp']?.toString() ?? '';
void clear() {
token = null;
user = null;
}
}

View File

@@ -0,0 +1,15 @@
import 'package:frontend_eccp_mobile/modules/auth/login/data/datasources/login_remote_datasource.dart';
import 'package:frontend_eccp_mobile/modules/auth/login/data/model/login_request.dart';
import 'package:frontend_eccp_mobile/modules/auth/login/data/model/login_response.dart';
import 'package:injectable/injectable.dart';
@lazySingleton
class LoginRepository {
LoginRepository(this._remoteDatasource);
final LoginRemoteDataSource _remoteDatasource;
Future<LoginResponse> login(LoginRequest request) async {
return _remoteDatasource.login(request);
}
}

View File

@@ -0,0 +1,194 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:frontend_eccp_mobile/app/core/constants/constants.dart';
import 'package:frontend_eccp_mobile/app/core/constants/enum/snackbar_type_enum.dart';
import 'package:frontend_eccp_mobile/app/core/di/injection.dart';
import 'package:frontend_eccp_mobile/app/core/navigation/app_navigator.dart';
import 'package:frontend_eccp_mobile/app/core/navigation/app_routes.dart';
import 'package:frontend_eccp_mobile/app/core/services/crashlytics_service.dart';
import 'package:frontend_eccp_mobile/app/core/utils/app_validators.dart';
import 'package:frontend_eccp_mobile/app/core/utils/show_message.dart';
import 'package:frontend_eccp_mobile/app/core/widgets/app_button.dart';
import 'package:frontend_eccp_mobile/app/core/widgets/app_shad_textfield.dart';
import 'package:frontend_eccp_mobile/modules/auth/login/bloc/login_cubit.dart';
import 'package:frontend_eccp_mobile/modules/auth/login/bloc/login_state.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
class LoginPage extends StatelessWidget {
const LoginPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => getIt<LoginCubit>(),
child: const LoginUI(),
);
}
}
class LoginUI extends StatefulWidget {
const LoginUI({super.key});
@override
State<LoginUI> createState() => _LoginUIState();
}
class _LoginUIState extends State<LoginUI> {
final formKey = GlobalKey<ShadFormState>();
@override
void dispose() {
super.dispose();
}
Future<void> _onLogin() async {
if (!formKey.currentState!.saveAndValidate()) return;
await CrashlyticsService.log('Login attempt');
if (!mounted) return;
await context.read<LoginCubit>().login(
nrp: formKey.currentState!.value['nrp']?.toString() ?? '',
password: formKey.currentState!.value['password']?.toString() ?? '',
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.neutralWhite,
body: BlocConsumer<LoginCubit, LoginState>(
listener: (context, state) async {
await AppNavigator.clearAndPush(
context,
AppRoutes.home,
onOpened: () {
showMessage(
context,
'Selamat, anda berhasil masuk',
SnackBarType.success,
);
},
);
// if (state is LoginSuccess) {
// await AppNavigator.clearAndPush(
// context,
// AppRoutes.home,
// onOpened: () {
// showMessage(
// context,
// 'Selamat, anda berhasil masuk',
// SnackBarType.success,
// );
// },
// );
// } else if (state is LoginFailure && state.hasMessage) {
// showMessage(
// context,
// state.message ?? '',
// state.snackBarType,
// duration: state.duration,
// );
// }
},
builder: (context, state) {
return SafeArea(
child: ShadForm(
key: formKey,
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppSpacing.h48,
Padding(
padding: AppPadding.h16,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'E-CPP DIVPROPAM POLRI',
style: AppTextStyles.h1,
textAlign: TextAlign.start,
),
Text(
'Sign-in to continue',
style: AppTextStyles.h2Light,
textAlign: TextAlign.start,
),
],
),
),
Padding(
padding: AppPadding.h12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppSpacing.h36,
AppShadInput(
title: 'NRP',
hint: 'Enter your NRP',
isForm: true,
id: 'nrp',
validator: (v) => AppValidators.required(
v,
message: 'NRP tidak boleh kosong',
),
),
AppSpacing.h16,
const AppShadInput(
title: 'Password',
hint: 'Enter your password',
isForm: true,
isPassword: true,
id: 'password',
validator: AppValidators.password,
),
AppSpacing.h32,
AppButton(
label: 'Login',
isExpanded: true,
onPressed: _onLogin,
isLoading: state is LoginLoading,
),
],
),
),
const Spacer(),
Align(
alignment: Alignment.bottomCenter,
child: Text(
'© 2026 E-CPP MABES POLRI. All rights reserved.',
style: AppTextStyles.body.copyWith(
color: AppColors.neutral600,
),
),
),
AppSpacing.h40,
],
),
),
),
),
),
);
},
),
);
}
}

View File

@@ -0,0 +1,30 @@
import 'package:dio/dio.dart';
import 'package:frontend_eccp_mobile/app/core/config/app_environment.dart';
import 'package:frontend_eccp_mobile/app/core/network/api_endpoint.dart';
import 'package:frontend_eccp_mobile/app/core/utils/session_manager.dart';
import 'package:frontend_eccp_mobile/modules/auth/refresh_token/data/model/refresh_token_response.dart';
import 'package:injectable/injectable.dart';
@lazySingleton
class AuthRemoteDataSource {
final Dio _dio = Dio(
BaseOptions(
baseUrl: AppEnvironment.baseUrl,
),
);
Future<RefreshTokenResponse> refreshToken() async {
final token = await SessionManager.getRefreshToken();
final response = await _dio.post<Map<String, dynamic>>(
ApiEndpoint.refreshToken,
data: {'refresh_token': token},
);
final data = response.data;
if (data == null) {
throw Exception('EMPTY_RESPONSE');
}
return RefreshTokenResponse.fromJson(data);
}
}

View File

@@ -0,0 +1,30 @@
class RefreshTokenResponse {
RefreshTokenResponse({
required this.status,
required this.message,
required this.accessToken,
required this.refreshToken,
required this.tokenType,
required this.expiresIn,
});
factory RefreshTokenResponse.fromJson(Map<String, dynamic> json) {
final data = json['data'] as Map<String, dynamic>? ?? {};
return RefreshTokenResponse(
status: json['status']?.toString() ?? '',
message: json['message']?.toString() ?? '',
accessToken: data['access_token']?.toString() ?? '',
refreshToken: data['refresh_token']?.toString() ?? '',
tokenType: data['token_type']?.toString() ?? '',
expiresIn: int.tryParse(data['expires_in']?.toString() ?? '0') ?? 0,
);
}
final String status;
final String message;
final String accessToken;
final String refreshToken;
final String tokenType;
final int expiresIn;
}

View File

@@ -0,0 +1,20 @@
import 'package:frontend_eccp_mobile/modules/auth/refresh_token/data/datasources/auth_remote_datasource.dart';
import 'package:frontend_eccp_mobile/modules/auth/refresh_token/data/model/refresh_token_response.dart';
import 'package:injectable/injectable.dart';
@lazySingleton
class AuthRepository {
AuthRepository(this._remoteDatasource);
final AuthRemoteDataSource _remoteDatasource;
Future<RefreshTokenResponse> refreshToken() async {
final response = await _remoteDatasource.refreshToken();
if (response.status != 'success') {
throw Exception(response.message);
}
return response;
}
}

View File

@@ -0,0 +1,14 @@
import 'package:frontend_eccp_mobile/modules/auth/refresh_token/data/model/refresh_token_response.dart';
import 'package:frontend_eccp_mobile/modules/auth/refresh_token/data/repositories/auth_repository.dart';
import 'package:injectable/injectable.dart';
@lazySingleton
class AuthService {
AuthService(this._repository);
final AuthRepository _repository;
Future<RefreshTokenResponse> refreshToken() async {
return _repository.refreshToken();
}
}

Some files were not shown because too many files have changed in this diff Show More