first commit
This commit is contained in:
1
lib/app/app.dart
Normal file
1
lib/app/app.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'view/app.dart';
|
||||
30
lib/app/core/config/app_environment.dart
Normal file
30
lib/app/core/config/app_environment.dart
Normal 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;
|
||||
}
|
||||
11
lib/app/core/config/app_flavor.dart
Normal file
11
lib/app/core/config/app_flavor.dart
Normal 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;
|
||||
}
|
||||
21
lib/app/core/config/app_update_config.dart
Normal file
21
lib/app/core/config/app_update_config.dart
Normal 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;
|
||||
}
|
||||
3
lib/app/core/constants/api_constant.dart
Normal file
3
lib/app/core/constants/api_constant.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
class ApiConstant {
|
||||
static const String contentTypeJson = 'application/json';
|
||||
}
|
||||
42
lib/app/core/constants/app_colors.dart
Normal file
42
lib/app/core/constants/app_colors.dart
Normal 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');
|
||||
}
|
||||
10
lib/app/core/constants/app_durations.dart
Normal file
10
lib/app/core/constants/app_durations.dart
Normal 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);
|
||||
}
|
||||
10
lib/app/core/constants/app_icon_sizes.dart
Normal file
10
lib/app/core/constants/app_icon_sizes.dart
Normal 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;
|
||||
}
|
||||
12
lib/app/core/constants/app_opacity.dart
Normal file
12
lib/app/core/constants/app_opacity.dart
Normal 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;
|
||||
}
|
||||
60
lib/app/core/constants/app_padding.dart
Normal file
60
lib/app/core/constants/app_padding.dart
Normal 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);
|
||||
}
|
||||
13
lib/app/core/constants/app_radius.dart
Normal file
13
lib/app/core/constants/app_radius.dart
Normal 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);
|
||||
}
|
||||
36
lib/app/core/constants/app_shadows.dart
Normal file
36
lib/app/core/constants/app_shadows.dart
Normal 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),
|
||||
),
|
||||
];
|
||||
}
|
||||
45
lib/app/core/constants/app_sizes.dart
Normal file
45
lib/app/core/constants/app_sizes.dart
Normal 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;
|
||||
}
|
||||
28
lib/app/core/constants/app_spacing.dart
Normal file
28
lib/app/core/constants/app_spacing.dart
Normal 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);
|
||||
}
|
||||
90
lib/app/core/constants/app_text_styles.dart
Normal file
90
lib/app/core/constants/app_text_styles.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
13
lib/app/core/constants/constants.dart
Normal file
13
lib/app/core/constants/constants.dart
Normal 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';
|
||||
3
lib/app/core/constants/dio_constant.dart
Normal file
3
lib/app/core/constants/dio_constant.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
class DioExtraKey {
|
||||
static const String noAuth = 'noAuth';
|
||||
}
|
||||
5
lib/app/core/constants/enum/app_flavor_enum.dart
Normal file
5
lib/app/core/constants/enum/app_flavor_enum.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
enum AppFlavor {
|
||||
development,
|
||||
staging,
|
||||
production,
|
||||
}
|
||||
1
lib/app/core/constants/enum/appbar_type_enum.dart
Normal file
1
lib/app/core/constants/enum/appbar_type_enum.dart
Normal file
@@ -0,0 +1 @@
|
||||
enum AppButtonType { primary, secondary }
|
||||
1
lib/app/core/constants/enum/location_status_enum.dart
Normal file
1
lib/app/core/constants/enum/location_status_enum.dart
Normal file
@@ -0,0 +1 @@
|
||||
enum SimpleLocationStatus { granted, denied, deniedForever, serviceDisabled }
|
||||
9
lib/app/core/constants/enum/network_error_code_enum.dart
Normal file
9
lib/app/core/constants/enum/network_error_code_enum.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
enum NetworkErrorCode {
|
||||
unauthorized,
|
||||
noInternet,
|
||||
serverError,
|
||||
requestCancelled,
|
||||
badCertificate,
|
||||
unknown,
|
||||
notFound,
|
||||
}
|
||||
30
lib/app/core/constants/enum/network_error_message_enum.dart
Normal file
30
lib/app/core/constants/enum/network_error_message_enum.dart
Normal 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)';
|
||||
}
|
||||
}
|
||||
}
|
||||
1
lib/app/core/constants/enum/snackbar_type_enum.dart
Normal file
1
lib/app/core/constants/enum/snackbar_type_enum.dart
Normal file
@@ -0,0 +1 @@
|
||||
enum SnackBarType { success, error, warning, info }
|
||||
3
lib/app/core/constants/globalkey.dart
Normal file
3
lib/app/core/constants/globalkey.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||
69
lib/app/core/di/injection.config.dart
Normal file
69
lib/app/core/di/injection.config.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
13
lib/app/core/di/injection.dart
Normal file
13
lib/app/core/di/injection.dart
Normal 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();
|
||||
}
|
||||
65
lib/app/core/handler/auth_handler.dart
Normal file
65
lib/app/core/handler/auth_handler.dart
Normal 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();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
17
lib/app/core/handler/contact_admin.dart
Normal file
17
lib/app/core/handler/contact_admin.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
22
lib/app/core/models/fcm_payload.dart
Normal file
22
lib/app/core/models/fcm_payload.dart
Normal 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;
|
||||
}
|
||||
20
lib/app/core/models/notification_payload.dart
Normal file
20
lib/app/core/models/notification_payload.dart
Normal 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;
|
||||
}
|
||||
119
lib/app/core/navigation/app_navigator.dart
Normal file
119
lib/app/core/navigation/app_navigator.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
47
lib/app/core/navigation/app_resume_once.dart
Normal file
47
lib/app/core/navigation/app_resume_once.dart
Normal 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;
|
||||
}
|
||||
59
lib/app/core/navigation/app_router.dart
Normal file
59
lib/app/core/navigation/app_router.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
10
lib/app/core/navigation/app_routes.dart
Normal file
10
lib/app/core/navigation/app_routes.dart
Normal 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';
|
||||
}
|
||||
52
lib/app/core/navigation/main_navigation_page.dart
Normal file
52
lib/app/core/navigation/main_navigation_page.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
51
lib/app/core/navigation/settings_navigator.dart
Normal file
51
lib/app/core/navigation/settings_navigator.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
5
lib/app/core/network/api_endpoint.dart
Normal file
5
lib/app/core/network/api_endpoint.dart
Normal 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';
|
||||
}
|
||||
49
lib/app/core/network/dio_client.dart
Normal file
49
lib/app/core/network/dio_client.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
69
lib/app/core/network/interceptors/auth_interceptor.dart
Normal file
69
lib/app/core/network/interceptors/auth_interceptor.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
244
lib/app/core/network/interceptors/error_interceptor.dart
Normal file
244
lib/app/core/network/interceptors/error_interceptor.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
36
lib/app/core/network/interceptors/logging_interceptor.dart
Normal file
36
lib/app/core/network/interceptors/logging_interceptor.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
30
lib/app/core/network/interceptors/retry_interceptor.dart
Normal file
30
lib/app/core/network/interceptors/retry_interceptor.dart
Normal 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;
|
||||
},
|
||||
);
|
||||
}
|
||||
9
lib/app/core/notification/fcm_background_handler.dart
Normal file
9
lib/app/core/notification/fcm_background_handler.dart
Normal 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);
|
||||
}
|
||||
15
lib/app/core/notification/fcm_config.dart
Normal file
15
lib/app/core/notification/fcm_config.dart
Normal 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;
|
||||
}
|
||||
41
lib/app/core/notification/fcm_notification_handler.dart
Normal file
41
lib/app/core/notification/fcm_notification_handler.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
110
lib/app/core/notification/fcm_route/notification_router.dart
Normal file
110
lib/app/core/notification/fcm_route/notification_router.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
50
lib/app/core/notification/fcm_route/notification_routes.dart
Normal file
50
lib/app/core/notification/fcm_route/notification_routes.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
44
lib/app/core/notification/fcm_service.dart
Normal file
44
lib/app/core/notification/fcm_service.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
51
lib/app/core/notification/fcm_token_manager.dart
Normal file
51
lib/app/core/notification/fcm_token_manager.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
54
lib/app/core/notification/local_notification_service.dart
Normal file
54
lib/app/core/notification/local_notification_service.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
19
lib/app/core/notification/notification_intent_store.dart
Normal file
19
lib/app/core/notification/notification_intent_store.dart
Normal 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;
|
||||
}
|
||||
295
lib/app/core/services/bottom_sheet_service.dart
Normal file
295
lib/app/core/services/bottom_sheet_service.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
75
lib/app/core/services/crashlytics_service.dart
Normal file
75
lib/app/core/services/crashlytics_service.dart
Normal 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('');
|
||||
}
|
||||
}
|
||||
56
lib/app/core/services/file_downloader.dart
Normal file
56
lib/app/core/services/file_downloader.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
21
lib/app/core/services/image_picker_service.dart
Normal file
21
lib/app/core/services/image_picker_service.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
153
lib/app/core/services/location_permission_service.dart
Normal file
153
lib/app/core/services/location_permission_service.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
47
lib/app/core/services/remote_config_service.dart
Normal file
47
lib/app/core/services/remote_config_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
166
lib/app/core/utils/app_validators.dart
Normal file
166
lib/app/core/utils/app_validators.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
80
lib/app/core/utils/date_time_utils.dart
Normal file
80
lib/app/core/utils/date_time_utils.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
5
lib/app/core/utils/file_size.dart
Normal file
5
lib/app/core/utils/file_size.dart
Normal 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';
|
||||
}
|
||||
58
lib/app/core/utils/session_manager.dart
Normal file
58
lib/app/core/utils/session_manager.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
181
lib/app/core/utils/show_message.dart
Normal file
181
lib/app/core/utils/show_message.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
50
lib/app/core/utils/token_manager.dart
Normal file
50
lib/app/core/utils/token_manager.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
20
lib/app/core/utils/version_utils.dart
Normal file
20
lib/app/core/utils/version_utils.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
118
lib/app/core/widgets/app_accordion.dart
Normal file
118
lib/app/core/widgets/app_accordion.dart
Normal 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,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
108
lib/app/core/widgets/app_bottom_nav.dart
Normal file
108
lib/app/core/widgets/app_bottom_nav.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
128
lib/app/core/widgets/app_button.dart
Normal file
128
lib/app/core/widgets/app_button.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
65
lib/app/core/widgets/app_dropdown.dart
Normal file
65
lib/app/core/widgets/app_dropdown.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
32
lib/app/core/widgets/app_empty.dart
Normal file
32
lib/app/core/widgets/app_empty.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
212
lib/app/core/widgets/app_shad_textfield.dart
Normal file
212
lib/app/core/widgets/app_shad_textfield.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
36
lib/app/core/widgets/app_something_wrong.dart
Normal file
36
lib/app/core/widgets/app_something_wrong.dart
Normal 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,
|
||||
],
|
||||
);
|
||||
}
|
||||
278
lib/app/core/widgets/app_textfield.dart
Normal file
278
lib/app/core/widgets/app_textfield.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
211
lib/app/core/widgets/bottomsheet/app_bottom_sheet.dart
Normal file
211
lib/app/core/widgets/bottomsheet/app_bottom_sheet.dart
Normal 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();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
289
lib/app/core/widgets/layout/app_screen.dart
Normal file
289
lib/app/core/widgets/layout/app_screen.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
22
lib/app/core/widgets/pdf_viewer.dart
Normal file
22
lib/app/core/widgets/pdf_viewer.dart
Normal 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
70
lib/app/view/app.dart
Normal 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
146
lib/bootstrap.dart
Normal 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
18
lib/l10n/arb/app_en.arb
Normal 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
7
lib/l10n/arb/app_es.arb
Normal 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
11
lib/l10n/arb/app_id.arb
Normal 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
8
lib/l10n/l10n.dart
Normal 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);
|
||||
}
|
||||
9
lib/main_development.dart
Normal file
9
lib/main_development.dart
Normal 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
9
lib/main_production.dart
Normal 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
9
lib/main_staging.dart
Normal 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());
|
||||
}
|
||||
73
lib/modules/auth/login/bloc/login_cubit.dart
Normal file
73
lib/modules/auth/login/bloc/login_cubit.dart
Normal 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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
32
lib/modules/auth/login/bloc/login_state.dart
Normal file
32
lib/modules/auth/login/bloc/login_state.dart
Normal 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];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
29
lib/modules/auth/login/data/model/login_data.dart
Normal file
29
lib/modules/auth/login/data/model/login_data.dart
Normal 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;
|
||||
}
|
||||
16
lib/modules/auth/login/data/model/login_request.dart
Normal file
16
lib/modules/auth/login/data/model/login_request.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
23
lib/modules/auth/login/data/model/login_response.dart
Normal file
23
lib/modules/auth/login/data/model/login_response.dart
Normal 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;
|
||||
}
|
||||
16
lib/modules/auth/login/data/model/login_user.dart
Normal file
16
lib/modules/auth/login/data/model/login_user.dart
Normal 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;
|
||||
}
|
||||
14
lib/modules/auth/login/data/model/user_session.dart
Normal file
14
lib/modules/auth/login/data/model/user_session.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
194
lib/modules/auth/login/presentation/screen/login_page.dart
Normal file
194
lib/modules/auth/login/presentation/screen/login_page.dart
Normal 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,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
14
lib/modules/auth/refresh_token/services/auth_service.dart
Normal file
14
lib/modules/auth/refresh_token/services/auth_service.dart
Normal 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
Reference in New Issue
Block a user