first commit

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
class BerkasModel {
BerkasModel({
required this.title,
required this.description,
});
final String title;
final String description;
}

View File

@@ -0,0 +1,188 @@
import 'package:flutter/material.dart';
import 'package:frontend_eccp_mobile/app/core/constants/constants.dart';
import 'package:frontend_eccp_mobile/app/core/widgets/app_empty.dart';
import 'package:frontend_eccp_mobile/app/core/widgets/layout/app_screen.dart';
import 'package:frontend_eccp_mobile/modules/berkas/data/models/berkas_model.dart';
import 'package:frontend_eccp_mobile/modules/berkas/presentation/widgets/Berkas_card.dart';
import 'package:frontend_eccp_mobile/modules/berkas/presentation/widgets/Berkas_section_header.dart';
import 'package:pull_to_refresh_flutter3/pull_to_refresh_flutter3.dart';
class BerkasPage extends StatefulWidget {
const BerkasPage({super.key});
@override
State<BerkasPage> createState() => _BerkasPageState();
}
class _BerkasPageState extends State<BerkasPage> {
final RefreshController _refreshController = RefreshController();
Future<void> _onRefresh() async {
await Future<void>.delayed(const Duration(seconds: 1));
_refreshController.refreshCompleted();
}
@override
void dispose() {
_refreshController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AppScreen(
title: 'Berkas',
backgroundColor: AppColors.neutral50,
scrollableBody: false,
useShadowAppBar: false,
isSliver: true,
refreshController: _refreshController,
enablePullDown: true,
onRefresh: _onRefresh,
refreshHeader: const ClassicHeader(),
body: const SizedBox.shrink(),
slivers: [
if (todayBerkas.isNotEmpty)
_buildSection(
title: 'Terbaru',
items: todayBerkas,
),
if (earlierBerkas.isNotEmpty)
_buildSection(
title: 'Lebih Lama',
items: earlierBerkas,
),
if (todayBerkas.isEmpty && earlierBerkas.isEmpty)
SliverToBoxAdapter(
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.7,
child: Center(
child: empty(),
),
),
),
const SliverToBoxAdapter(child: AppSpacing.h24),
],
);
}
SliverMainAxisGroup _buildSection({
required String title,
required List<BerkasModel> items,
}) {
return SliverMainAxisGroup(
slivers: [
SliverPersistentHeader(
pinned: true,
delegate: BerkasSectionHeaderDelegate(title: title),
),
SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: AppSizes.s16,
),
sliver: SliverToBoxAdapter(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 900),
child: Column(
children: List.generate(items.length, (index) {
final item = items[index];
return Padding(
padding: AppPadding.t8,
child: BerkasCard(item: item),
);
}),
),
),
),
),
),
const SliverToBoxAdapter(child: AppSpacing.h16),
],
);
}
}
final todayBerkas = <BerkasModel>[
BerkasModel(
title: 'Rosaldo',
description: '2908388',
),
BerkasModel(
title: 'Rosaldo',
description: '2908388',
),
BerkasModel(
title: 'Rosaldo',
description: '2908388',
),
BerkasModel(
title: 'Rosaldo',
description: '2908388',
),
BerkasModel(
title: 'Rosaldo',
description: '2908388',
),
BerkasModel(
title: 'Rosaldo',
description: '2908388',
),
];
final earlierBerkas = <BerkasModel>[
BerkasModel(
title: 'Rosaldo 2',
description: '2908389',
),
BerkasModel(
title: 'Rosaldo 3',
description: '2928389',
),
BerkasModel(
title: 'Rosaldo 4',
description: '2900389',
),
BerkasModel(
title: 'Rosaldo 5',
description: '3908389',
),
BerkasModel(
title: 'Rosaldo 2',
description: '2908389',
),
BerkasModel(
title: 'Rosaldo 3',
description: '2928389',
),
BerkasModel(
title: 'Rosaldo 4',
description: '2900389',
),
BerkasModel(
title: 'Rosaldo 5',
description: '3908389',
),
BerkasModel(
title: 'Rosaldo 2',
description: '2908389',
),
BerkasModel(
title: 'Rosaldo 3',
description: '2928389',
),
BerkasModel(
title: 'Rosaldo 4',
description: '2900389',
),
BerkasModel(
title: 'Rosaldo 5',
description: '3908389',
),
];

View File

@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:frontend_eccp_mobile/app/core/constants/constants.dart';
import 'package:frontend_eccp_mobile/modules/berkas/data/models/berkas_model.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
class BerkasCard extends StatelessWidget {
const BerkasCard({
required this.item,
super.key,
});
final BerkasModel item;
@override
Widget build(BuildContext context) {
return Container(
padding: AppPadding.v16.add(AppPadding.h16),
decoration: BoxDecoration(
color: AppColors.neutralWhite,
borderRadius: AppRadius.r8,
boxShadow: AppShadows.sm,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ShadAvatar(
'https://sample.com/avatar-1.png',
placeholder: Text('RS'),
),
AppSpacing.w12,
Expanded(child: _buildContent()),
],
),
);
}
Widget _buildContent() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
style: AppTextStyles.small.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.neutral900,
),
),
AppSpacing.h2,
Text(
item.description,
style: AppTextStyles.xs.copyWith(
color: AppColors.neutral700,
),
),
],
);
}
}

View File

@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:frontend_eccp_mobile/app/core/constants/constants.dart';
class BerkasSectionHeaderDelegate extends SliverPersistentHeaderDelegate {
BerkasSectionHeaderDelegate({
required this.title,
});
final String title;
@override
double get minExtent => 48;
@override
double get maxExtent => 48;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
final isTablet = MediaQuery.of(context).size.width > 600;
return Container(
color: AppColors.neutral50,
alignment: Alignment.center,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 900),
child: Padding(
padding: isTablet ? EdgeInsets.zero : AppPadding.h16,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: AppTextStyles.label.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.neutral700,
),
),
Text(
'Lihat Semua',
style: AppTextStyles.small.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.primary900,
),
),
],
),
),
),
);
}
@override
bool shouldRebuild(
covariant SliverPersistentHeaderDelegate oldDelegate,
) {
return false;
}
}

View File

View File

View File

@@ -0,0 +1,71 @@
import 'dart:async';
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_text_styles.dart';
import 'package:frontend_eccp_mobile/app/core/widgets/layout/app_screen.dart';
import 'package:pull_to_refresh_flutter3/pull_to_refresh_flutter3.dart';
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
@override
Widget build(BuildContext context) {
return const HomeUI();
}
}
class HomeUI extends StatefulWidget {
const HomeUI({super.key});
@override
State<HomeUI> createState() => _HomeUIState();
}
class _HomeUIState extends State<HomeUI> {
final RefreshController _refreshController = RefreshController();
Future<void> _onRefresh() async {
onInit();
await Future<dynamic>.delayed(const Duration(seconds: 1));
_refreshController.refreshCompleted();
}
void onInit() {}
@override
void initState() {
super.initState();
onInit();
}
@override
void dispose() {
_refreshController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AppScreen(
backgroundColor: AppColors.neutralWhite,
refreshController: _refreshController,
enablePullDown: true,
onRefresh: _onRefresh,
refreshHeader: const ClassicHeader(),
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Center(
child: Text('Home Page', style: AppTextStyles.h4),
),
],
),
);
}
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,37 @@
import 'package:dio/dio.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:frontend_eccp_mobile/app/core/services/crashlytics_service.dart';
import 'package:frontend_eccp_mobile/modules/profile/bloc/logout/logout_state.dart';
import 'package:frontend_eccp_mobile/modules/profile/data/repositories/profile_repository.dart';
import 'package:injectable/injectable.dart';
@injectable
class LogoutCubit extends Cubit<LogoutState> {
LogoutCubit(this._repository) : super(LogoutInitial());
final ProfileRepository _repository;
Future<void> logout() async {
emit(LogoutLoading());
try {
final response = await _repository.logout();
if (response.status != 'success') {
emit(LogoutFailure(response.message));
return;
}
emit(LogoutSuccess());
} on DioException catch (e) {
emit(LogoutFailure(e.error?.toString()));
} catch (e, s) {
await CrashlyticsService.recordError(
e,
s,
fatal: true,
reason: 'Logout Unknown Error',
);
emit(const LogoutFailure());
}
}
}

View File

@@ -0,0 +1,25 @@
import 'package:equatable/equatable.dart';
abstract class LogoutState extends Equatable {
const LogoutState();
@override
List<Object?> get props => [];
}
class LogoutInitial extends LogoutState {}
class LogoutLoading extends LogoutState {}
class LogoutSuccess extends LogoutState {}
class LogoutFailure extends LogoutState {
const LogoutFailure([this.message]);
final String? message;
bool get hasMessage => message != null && message!.isNotEmpty;
@override
List<Object?> get props => [message];
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,35 @@
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/profile/data/model/logout/logout_response.dart';
import 'package:injectable/injectable.dart';
@lazySingleton
class ProfileRemoteDataSource {
final Dio _dio = DioClient.getInstance();
Future<LogoutResponse> logout() async {
try {
final response = await _dio.post<Map<String, dynamic>>(
ApiEndpoint.logout,
options: Options(
extra: {DioExtraKey.noAuth: false},
),
);
final data = response.data;
if (data == null) {
throw Exception('EMPTY_RESPONSE');
}
return LogoutResponse.fromJson(data);
} on DioException catch (e) {
final responseData = e.response?.data;
if (responseData is Map<String, dynamic>) {
return LogoutResponse.fromJson(responseData);
}
throw Exception(e.error?.toString() ?? 'LOGOUT_FAILED');
}
}
}

View File

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

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,14 @@
import 'package:frontend_eccp_mobile/modules/profile/data/datasources/profile_remote_datasource.dart';
import 'package:frontend_eccp_mobile/modules/profile/data/model/logout/logout_response.dart';
import 'package:injectable/injectable.dart';
@lazySingleton
class ProfileRepository {
ProfileRepository(this._remoteDatasource);
final ProfileRemoteDataSource _remoteDatasource;
Future<LogoutResponse> logout() async {
return _remoteDatasource.logout();
}
}

View File

@@ -0,0 +1,160 @@
import 'package:clipboard/clipboard.dart';
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/handler/auth_handler.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_token_manager.dart';
import 'package:frontend_eccp_mobile/app/core/utils/show_message.dart';
import 'package:frontend_eccp_mobile/app/core/utils/token_manager.dart';
import 'package:frontend_eccp_mobile/app/core/widgets/app_button.dart';
import 'package:frontend_eccp_mobile/app/core/widgets/layout/app_screen.dart';
import 'package:frontend_eccp_mobile/modules/profile/bloc/logout/logout_cubit.dart';
import 'package:frontend_eccp_mobile/modules/profile/bloc/logout/logout_state.dart';
import 'package:frontend_eccp_mobile/modules/profile/presentation/widgets/profile_header_card.dart';
import 'package:frontend_eccp_mobile/modules/profile/presentation/widgets/profile_menu_section.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:pull_to_refresh_flutter3/pull_to_refresh_flutter3.dart';
class ProfilePage extends StatelessWidget {
const ProfilePage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => getIt<LogoutCubit>(),
child: const ProfileUI(),
);
}
}
class ProfileUI extends StatefulWidget {
const ProfileUI({super.key});
@override
State<ProfileUI> createState() => _ProfileUIState();
}
class _ProfileUIState extends State<ProfileUI> {
final RefreshController _refreshController = RefreshController();
Future<void> _onRefresh() async {
await Future<void>.delayed(const Duration(milliseconds: 800));
_refreshController.refreshCompleted();
}
bool get isTablet => MediaQuery.of(context).size.width > 600;
@override
Widget build(BuildContext context) {
return BlocConsumer<LogoutCubit, LogoutState>(
listener: (context, state) async {
if (state is LogoutLoading) return;
await TokenManager.clearToken();
if (!context.mounted) return;
await AppNavigator.clearAndPush(
context,
AppRoutes.login,
);
},
builder: (context, state) {
return AppScreen(
title: 'PROFIL',
backgroundColor: AppColors.neutral50,
refreshController: _refreshController,
enablePullDown: true,
onRefresh: _onRefresh,
refreshHeader: const ClassicHeader(),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 900),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AppSpacing.h16,
const Padding(
padding: AppPadding.h16,
child: ProfileHeaderCard(),
),
AppSpacing.h24,
ProfileMenuSection(
title: 'Personal Information',
items: [
ProfileMenuItem(
icon: IconsaxPlusLinear.personalcard,
label: 'My Details',
iconColor: AppColors.neutral900,
),
ProfileMenuItem(
icon: IconsaxPlusLinear.lock,
label: 'Change Password',
iconColor: AppColors.neutral900,
),
ProfileMenuItem(
icon: IconsaxPlusLinear.notification_1,
label: 'Copy FCM Token',
iconColor: AppColors.neutral900,
onTap: () async {
final token = await FcmTokenManager.getToken();
await FlutterClipboard.copy(token ?? '');
if (!context.mounted) return;
showMessage(
context,
'FCM Token copied : \n${token ?? ''}',
SnackBarType.success,
);
},
),
],
),
AppSpacing.h16,
Padding(
padding: AppPadding.p16,
child: AppButton(
height: 48,
paddingVertical: 0,
label: '',
customContent: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.logout_rounded,
color: Colors.white,
),
AppSpacing.w6,
Text(
'Log Out',
style: AppTextStyles.label.copyWith(
color: AppColors.neutralWhite,
),
),
],
),
isExpanded: true,
onPressed: () async {
await AuthHandler.logout(context);
},
isLoading: state is LogoutLoading,
),
),
AppSpacing.h32,
],
),
),
),
);
},
);
}
}

View File

@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:frontend_eccp_mobile/app/core/constants/constants.dart';
class ProfileHeaderCard extends StatelessWidget {
const ProfileHeaderCard({super.key});
@override
Widget build(BuildContext context) {
return Container(
padding: AppPadding.p14,
decoration: BoxDecoration(
color: AppColors.neutralWhite,
borderRadius: AppRadius.r18,
boxShadow: AppShadows.md,
),
child: Row(
children: [
ClipRRect(
borderRadius: AppRadius.rFull,
child: Image.network(
'https://i.pravatar.cc/150?img=12',
width: 64,
height: 64,
fit: BoxFit.cover,
),
),
AppSpacing.w14,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Rosaldo',
style: AppTextStyles.h3,
),
AppSpacing.h2,
Text(
'NRP : 1882098',
style: AppTextStyles.badge.copyWith(
color: AppColors.neutral600,
),
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'package:frontend_eccp_mobile/app/core/constants/constants.dart';
class ProfileMenuSection extends StatelessWidget {
const ProfileMenuSection({
required this.title,
required this.items,
this.backgroundColor = Colors.white,
super.key,
});
final String title;
final List<ProfileMenuItem> items;
final Color backgroundColor;
@override
Widget build(BuildContext context) {
return Padding(
padding: AppPadding.h16,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: AppTextStyles.section,
),
AppSpacing.h12,
Container(
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: AppRadius.r14,
boxShadow: AppShadows.md,
),
child: Column(
children: List.generate(
items.length,
(index) {
final item = items[index];
final isLast = index == items.length - 1;
return Column(
children: [
_ProfileMenuTile(item: item),
if (!isLast)
Divider(
height: 1,
thickness: 1,
color: AppColors.neutral200,
),
],
);
},
),
),
),
],
),
);
}
}
class ProfileMenuItem {
ProfileMenuItem({
required this.icon,
required this.label,
required this.iconColor,
this.onTap,
this.useArrow = true,
this.color,
});
final IconData icon;
final String label;
final bool useArrow;
final VoidCallback? onTap;
Color? color = AppColors.supportSteel600;
Color iconColor;
}
class _ProfileMenuTile extends StatelessWidget {
const _ProfileMenuTile({
required this.item,
});
final ProfileMenuItem item;
@override
Widget build(BuildContext context) {
return InkWell(
borderRadius: AppRadius.r14,
onTap: item.onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 14,
),
child: Row(
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: item.iconColor.withValues(alpha: 0.1),
),
padding: AppPadding.p6,
child: Icon(
item.icon,
size: AppIconSizes.md,
color: item.iconColor,
),
),
AppSpacing.w12,
Expanded(
child: Text(
item.label,
style: AppTextStyles.body.copyWith(
color: item.color,
),
),
),
if (item.useArrow)
Icon(
Icons.chevron_right,
size: AppIconSizes.lg,
color: AppColors.neutral400,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,106 @@
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/handler/auth_handler.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/remote_config_service.dart';
import 'package:frontend_eccp_mobile/app/core/utils/version_utils.dart';
import 'package:package_info_plus/package_info_plus.dart';
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> {
@override
void initState() {
super.initState();
unawaited(_init());
}
Future<void> _init() async {
await Future<void>.delayed(const Duration(seconds: 2));
await RemoteConfigService.init();
final config = await RemoteConfigService.getUpdateConfig();
final info = await PackageInfo.fromPlatform();
if (!mounted) return;
if (config != null && VersionUtils.isNewer(config.version, info.version)) {
await AppNavigator.clearAndPush(
context,
AppRoutes.updateApp,
extra: {
'version': config.version,
'link': config.linkUpdate,
},
);
return;
}
await AuthHandler.handleAuth(context);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.primary700,
body: Stack(
children: [
Positioned.fill(
child: Image.asset(
'assets/images/signature_pattern.jpg',
fit: BoxFit.cover,
),
),
Positioned.fill(
child: Container(
color: AppColors.neutralWhite.withOpacity(0.9),
),
),
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/images/logo.png',
height: 140,
),
AppSpacing.h16,
Text(
'E-CPP',
style: AppTextStyles.h1.copyWith(
fontWeight: FontWeight.w900,
fontStyle: FontStyle.italic,
color: AppColors.primary900,
),
),
AppSpacing.h6,
Text(
'DIV PROPAM MABES POLRI',
style: AppTextStyles.body.copyWith(
fontWeight: FontWeight.w500,
color: AppColors.primary900,
),
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,179 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:frontend_eccp_mobile/app/core/constants/app_colors.dart';
import 'package:frontend_eccp_mobile/app/core/widgets/app_button.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:url_launcher/url_launcher.dart';
class UpdatePage extends StatelessWidget {
const UpdatePage({
required this.version,
required this.link,
super.key,
});
final String version;
final String link;
Future<void> _openLink() async {
if (link.isEmpty) return;
final uri = Uri.parse(link);
await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
}
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async => false,
child: Scaffold(
body: Stack(
children: [
Positioned.fill(
child: Image.asset(
'assets/images/signature_pattern.jpg',
fit: BoxFit.cover,
),
),
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/images/logo.png',
height: 140,
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'E-CPP',
style: GoogleFonts.inter(
fontSize: 24.72,
fontWeight: FontWeight.w900,
fontStyle: FontStyle.italic,
color: AppColors.neutralWhite,
),
),
],
),
const SizedBox(height: 6),
Text(
'DIV PROPAM MABES POLRI',
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.neutralWhite,
),
),
],
),
),
Positioned.fill(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4),
child: Container(
color: Colors.black.withOpacity(0.4),
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: _UpdateBottomSheet(
onUpdate: _openLink,
version: version,
),
),
),
),
],
),
),
);
}
}
class _UpdateBottomSheet extends StatelessWidget {
const _UpdateBottomSheet({
required this.onUpdate,
required this.version,
});
final String version;
final VoidCallback onUpdate;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
elevation: 12,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.asset(
'assets/images/signature_pattern.jpg',
width: double.infinity,
fit: BoxFit.cover,
),
),
const SizedBox(height: 16),
Text(
'App update is required',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 24,
),
),
const SizedBox(height: 8),
Text(
'Your current app version is expired. Please update\nthe app to continue',
textAlign: TextAlign.start,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.neutral900,
),
),
const SizedBox(height: 12),
Text(
'New Version Available : $version',
textAlign: TextAlign.start,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.neutral900,
fontWeight: FontWeight.w600,
fontStyle: FontStyle.italic,
),
),
const SizedBox(height: 24),
AppButton(
label: 'Update App',
isExpanded: true,
radius: 10,
onPressed: onUpdate,
),
],
),
),
);
}
}