Feat: add logic history boarding pass

This commit is contained in:
orangdeso 2025-04-22 23:16:36 +07:00
parent e8763bb85f
commit d565bb59fa
14 changed files with 764 additions and 110 deletions

View File

@ -343,6 +343,12 @@
"packageUri": "lib/",
"languageVersion": "2.12"
},
{
"name": "mobile_scanner",
"rootUri": "file:///C:/Users/ASUS/AppData/Local/Pub/Cache/hosted/pub.dev/mobile_scanner-6.0.10",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "path",
"rootUri": "file:///C:/Users/ASUS/AppData/Local/Pub/Cache/hosted/pub.dev/path-1.9.0",
@ -632,7 +638,7 @@
"languageVersion": "3.4"
}
],
"generated": "2025-04-08T08:36:36.076309Z",
"generated": "2025-04-22T06:48:38.993021Z",
"generator": "pub",
"generatorVersion": "3.5.0",
"flutterRoot": "file:///D:/Flutter/flutter_sdk/flutter_3.24.0",

View File

@ -214,6 +214,10 @@ meta
2.12
file:///C:/Users/ASUS/AppData/Local/Pub/Cache/hosted/pub.dev/meta-1.15.0/
file:///C:/Users/ASUS/AppData/Local/Pub/Cache/hosted/pub.dev/meta-1.15.0/lib/
mobile_scanner
3.4
file:///C:/Users/ASUS/AppData/Local/Pub/Cache/hosted/pub.dev/mobile_scanner-6.0.10/
file:///C:/Users/ASUS/AppData/Local/Pub/Cache/hosted/pub.dev/mobile_scanner-6.0.10/lib/
path
3.0
file:///C:/Users/ASUS/AppData/Local/Pub/Cache/hosted/pub.dev/path-1.9.0/

View File

@ -1,16 +1,20 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Permissions -->
<!-- <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission
<!-- Permissions -->
<!-- <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
<uses-permission
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
android:maxSdkVersion="34" /> -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- Untuk Android 13 dan lebih tinggi -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<application
android:label="e_porter"

View File

@ -1,3 +1,6 @@
org.gradle.jvmargs=-Xmx4G -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
dev.steenbakker.mobile_scanner.useUnbundled=true
kotlin.incremental=false
org.gradle.caching=false

View File

@ -0,0 +1,132 @@
import 'dart:developer';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_database/firebase_database.dart';
import '../../domain/models/transaction_model.dart';
import '../../domain/repositories/history_repository.dart';
class HistoryRepositoryImpl implements HistoryRepository {
final FirebaseDatabase _database = FirebaseDatabase.instance;
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
@override
Stream<List<TransactionModel>> getTransactionsStream(String userId, String status) {
final ref = _database.ref().child('transactions').child(userId);
return ref.onValue.map((event) {
final snapshot = event.snapshot;
// log('HistoryRepositoryImpl: snapshot diterima, exists: ${snapshot.exists}, has children: ${snapshot.children.isNotEmpty}');
if (!snapshot.exists || snapshot.value == null) {
// log('HistoryRepositoryImpl: tidak ada data transaksi untuk userId: $userId');
return <TransactionModel>[];
}
try {
final Map<dynamic, dynamic> userTransactions = snapshot.value as Map<dynamic, dynamic>;
// log('HistoryRepositoryImpl: jumlah grup transaksi: ${userTransactions.length}');
List<TransactionModel> transactions = [];
userTransactions.forEach((groupKey, groupData) {
if (groupData is Map<dynamic, dynamic>) {
groupData.forEach((transactionKey, transactionData) {
try {
if (transactionData is Map<dynamic, dynamic>) {
if (transactionData.containsKey('payment') &&
transactionData['payment'] is Map &&
transactionData['payment'].containsKey('status')) {
final paymentStatus = transactionData['payment']['status'];
if (paymentStatus == status) {
final payment = transactionData['payment'] as Map<dynamic, dynamic>;
final Map<String, dynamic> processedData = {
'id': payment['id'] ?? transactionKey,
'idBooking': transactionData['idBooking'] ?? '',
'ticketId': groupKey,
'flightId': transactionData['flight']?['code'] ?? '',
// Data payment
'amount': payment['amount'] ?? 0.0,
'method': payment['method'] ?? '',
'status': paymentStatus,
'proofUrl': payment['proofUrl'],
'createdAt': payment['createdAt'] != null
? DateTime.fromMillisecondsSinceEpoch(payment['createdAt'] as int)
: DateTime.now(),
'expiryTime': payment['expiryTime'] != null
? DateTime.fromMillisecondsSinceEpoch(payment['expiryTime'] as int)
: DateTime.now().add(Duration(hours: 1)),
'flightDetails': Map<String, dynamic>.from(transactionData['flight'] ?? {}),
'bandaraDetails': Map<String, dynamic>.from(transactionData['bandara'] ?? {}),
'porterServiceDetails': transactionData['porterService'] != null
? Map<String, dynamic>.from(transactionData['porterService'])
: null,
'userDetails': Map<String, dynamic>.from(transactionData['user'] ?? {}),
// Data tambahan
'passenger': transactionData['passenger'] ?? 0,
'passengerDetails': transactionData['passengerDetails'] ?? [],
'numberSeat': transactionData['numberSeat'] ?? [],
};
final transaction = TransactionModel.fromJson(processedData);
transactions.add(transaction);
// log('HistoryRepositoryImpl: transaksi dengan id: $transactionKey ditambahkan ke daftar (status: $status)');
}
}
}
} catch (e) {
log('HistoryRepositoryImpl: error parsing transaksi dengan id: $transactionKey, error: $e');
}
});
}
});
log('HistoryRepositoryImpl: jumlah transaksi dengan status $status: ${transactions.length}');
return transactions;
} catch (e) {
log('HistoryRepositoryImpl: error saat memproses snapshot: $e');
return <TransactionModel>[];
}
});
}
@override
Future<TransactionModel?> getTransactionFromFirestore(String ticketId, String transactionId) async {
try {
log('HistoryRepositoryImpl: mengambil transaksi dari Firestore dengan ticketId: $ticketId, transactionId: $transactionId');
// Referensi ke dokumen transaksi di Firestore
final docRef = _firestore.collection('tickets').doc(ticketId).collection('payments').doc(transactionId);
// Mengambil data dokumen
final docSnapshot = await docRef.get();
if (!docSnapshot.exists) {
log('HistoryRepositoryImpl: transaksi tidak ditemukan di Firestore');
return null;
}
// Mengambil data dari dokumen
final data = docSnapshot.data();
if (data == null) {
log('HistoryRepositoryImpl: data transaksi kosong');
return null;
}
// Pastikan id dokumen tersimpan dalam data
data['id'] = transactionId;
data['ticketId'] = ticketId;
// Konversi ke model TransactionModel
final transaction = TransactionModel.fromJson(data);
log('HistoryRepositoryImpl: berhasil mengambil transaksi dari Firestore');
return transaction;
} catch (e) {
log('HistoryRepositoryImpl: error saat mengambil transaksi dari Firestore: $e');
return null;
}
}
}

View File

@ -0,0 +1,25 @@
import 'package:get/get.dart';
import '../../data/repositories/history_repository_impl.dart';
import '../../presentation/controllers/history_controller.dart';
import '../repositories/history_repository.dart';
import '../usecases/history_usecase.dart';
class HistoryBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<HistoryRepository>(
() => HistoryRepositoryImpl(),
);
// Inject UseCase
Get.lazyPut<HistoryUseCase>(
() => HistoryUseCase(Get.find<HistoryRepository>()),
);
// Inject Controller
Get.lazyPut<HistoryController>(
() => HistoryController(Get.find<HistoryUseCase>()),
);
}
}

View File

@ -0,0 +1,6 @@
import '../models/transaction_model.dart';
abstract class HistoryRepository {
Stream<List<TransactionModel>> getTransactionsStream(String userId, String status);
Future<TransactionModel?> getTransactionFromFirestore(String ticketId, String transactionId);
}

View File

@ -0,0 +1,23 @@
import 'package:e_porter/domain/repositories/history_repository.dart';
import '../models/transaction_model.dart';
class HistoryUseCase {
final HistoryRepository _repository;
HistoryUseCase(this._repository);
// Mendapatkan transaksi dengan status pending secara realtime
Stream<List<TransactionModel>> getPendingTransactionsStream(String userId) {
return _repository.getTransactionsStream(userId, 'pending');
}
// Mendapatkan transaksi dengan status active secara realtime
Stream<List<TransactionModel>> getActiveTransactionsStream(String userId) {
return _repository.getTransactionsStream(userId, 'active');
}
Future<TransactionModel?> getTransactionFromFirestore(String ticketId, String transactionId) {
return _repository.getTransactionFromFirestore(ticketId, transactionId);
}
}

View File

@ -0,0 +1,154 @@
import 'dart:async';
import 'dart:developer';
import 'package:e_porter/domain/usecases/history_usecase.dart';
import 'package:get/get.dart';
import '../../data/repositories/transaction_repository_impl.dart';
import '../../domain/models/transaction_model.dart';
class HistoryController extends GetxController {
final HistoryUseCase _historyUseCase;
final RxList<TransactionModel> pendingTransactions = <TransactionModel>[].obs;
final RxList<TransactionModel> activeTransactions = <TransactionModel>[].obs;
final Rx<TransactionModel?> selectedTransaction = Rx<TransactionModel?>(null);
final RxBool isLoading = true.obs;
final RxBool isCheckingExpiry = false.obs;
final RxString errorMessage = ''.obs;
StreamSubscription? _pendingSubscription;
StreamSubscription? _activeSubscription;
String _currentUserId = '';
HistoryController(this._historyUseCase);
void initStreams(String userId) {
isLoading.value = true;
_currentUserId = userId;
log('HistoryController: initStreams dipanggil dengan userId: $userId');
_cleanupStreams();
_pendingSubscription = _historyUseCase.getPendingTransactionsStream(userId).listen((transactions) {
log('HistoryController: pending transactions updated, count: ${transactions.length}');
final sortedTransactions = _sortTransactionsByCreatedAt(transactions);
pendingTransactions.value = sortedTransactions;
isLoading.value = false;
}, onError: (error) {
log('HistoryController: Error mendapatkan transaksi pending: $error');
isLoading.value = false;
});
_activeSubscription = _historyUseCase.getActiveTransactionsStream(userId).listen((transactions) {
log('HistoryController: active transactions updated, count: ${transactions.length}');
final sortedTransactions = _sortTransactionsByCreatedAt(transactions);
activeTransactions.value = sortedTransactions;
isLoading.value = false;
}, onError: (error) {
log('HistoryController: Error mendapatkan transaksi active: $error');
isLoading.value = false;
});
}
List<TransactionModel> _sortTransactionsByCreatedAt(List<TransactionModel> transactions) {
final sortedList = List<TransactionModel>.from(transactions);
sortedList.sort((a, b) => b.createdAt.compareTo(a.createdAt));
log('HistoryController: transaksi telah diurutkan berdasarkan createdAt (descending)');
return sortedList;
}
Future<void> checkExpiredPendingTransactions() async {
final now = DateTime.now();
final expiredTransactions = pendingTransactions.where((tx) => tx.expiryTime.isBefore(now)).toList();
if (expiredTransactions.isNotEmpty) {
log('HistoryController: Ditemukan ${expiredTransactions.length} transaksi kedaluwarsa');
isCheckingExpiry.value = true;
for (var transaction in expiredTransactions) {
log('HistoryController: Menghapus transaksi kedaluwarsa ${transaction.id} dari daftar pending');
pendingTransactions.removeWhere((tx) => tx.id == transaction.id);
}
await Future.delayed(Duration(seconds: 2));
await Get.find<TransactionRepositoryImpl>().checkAndCancelExpiredTransactions();
if (_currentUserId.isNotEmpty) {
await refreshTransactions(_currentUserId);
}
isCheckingExpiry.value = false;
}
}
Future<TransactionModel?> getTransactionFromFirestore(String ticketId, String transactionId) async {
try {
isLoading.value = true;
errorMessage.value = '';
log('HistoryController: mencoba mengambil transaksi dengan ticketId: $ticketId, transactionId: $transactionId');
final transaction = await _historyUseCase.getTransactionFromFirestore(ticketId, transactionId);
if (transaction != null) {
selectedTransaction.value = transaction;
} else {
errorMessage.value = 'Transaksi tidak ditemukan';
}
return transaction;
} catch (e) {
log('HistoryController: error saat mengambil transaksi dari Firestore: $e');
errorMessage.value = 'Terjadi kesalahan: $e';
return null;
} finally {
isLoading.value = false;
}
}
Future<void> refreshTransactions(String userId) async {
log('HistoryController: Memperbarui data transaksi untuk userId: $userId');
pendingTransactions.clear();
activeTransactions.clear();
isLoading.value = true;
_cleanupStreams();
initStreams(userId);
await Future.delayed(Duration(milliseconds: 500));
}
String getUserId() {
if (_pendingSubscription != null || _activeSubscription != null) {
return _currentUserId;
}
return '';
}
void _cleanupStreams() {
_pendingSubscription?.cancel();
_activeSubscription?.cancel();
_pendingSubscription = null;
_activeSubscription = null;
}
@override
void onClose() {
_cleanupStreams();
super.onClose();
}
void clearSelectedTransaction() {
selectedTransaction.value = null;
}
}

View File

@ -1,3 +1,5 @@
import 'dart:developer';
import 'package:e_porter/presentation/screens/boarding_pass/component/payment_count_down_timer.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_svg/svg.dart';
@ -9,9 +11,61 @@ import '../../../../_core/constants/typography.dart';
class CardBoardingPass extends StatelessWidget {
final bool isActive;
final String idBooking;
final String? opsiFlight;
final DateTime? expiryTime;
final String? airlines;
final String? codeAirlines;
final String? logo;
final List<String>? servicePorter;
final String? flightClass;
final int? passenger;
final String? departureCity;
final String? arrivalCity;
final String? departureCode;
final String? arrivalCode;
final String? departurePlane;
final String? arrivalPlane;
final String? transitPlane;
final String? transitStartDate;
final String? transitEndDate;
final String? departureTime;
final String? arrivalTime;
final String? departureDate;
final String? arrivalDate;
final String? duration;
final String? stop;
final VoidCallback? onTap;
const CardBoardingPass({Key? key, required this.isActive, this.onTap});
const CardBoardingPass({
Key? key,
required this.isActive,
required this.idBooking,
this.opsiFlight,
this.expiryTime,
this.airlines,
this.codeAirlines,
this.logo,
this.servicePorter,
this.flightClass,
this.passenger,
this.departureCity,
this.arrivalCity,
this.departureCode,
this.arrivalCode,
this.departurePlane,
this.arrivalPlane,
this.transitPlane,
this.transitStartDate,
this.transitEndDate,
this.departureTime,
this.arrivalTime,
this.departureDate,
this.arrivalDate,
this.duration,
this.stop,
this.onTap,
}) : super(key: key);
@override
Widget build(BuildContext context) {
@ -20,7 +74,7 @@ class CardBoardingPass extends StatelessWidget {
onTap: onTap,
child: Column(
children: [
_buildHeaderStatus(),
_buildHeaderStatus(idBooking: idBooking, opsiFlight: opsiFlight),
CustomeShadowCotainner(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(10.r),
@ -38,57 +92,61 @@ class CardBoardingPass extends StatelessWidget {
TypographyStyles.body('Kode Booking',
color: GrayColors.gray600, fontWeight: FontWeight.w400),
SizedBox(width: 20.w),
TypographyStyles.body('I2L8JRL', color: GrayColors.gray800),
TypographyStyles.body(idBooking, color: GrayColors.gray800),
],
),
Container(
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 6.h),
decoration: BoxDecoration(
color: GrayColors.gray100,
borderRadius: BorderRadius.circular(8.r),
border: Border.all(width: 1.w, color: GrayColors.gray200),
),
child: TypographyStyles.caption('Keberangkatan', color: GrayColors.gray800),
),
if (opsiFlight != null && opsiFlight!.isNotEmpty)
Container(
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 6.h),
decoration: BoxDecoration(
color: GrayColors.gray100,
borderRadius: BorderRadius.circular(8.r),
border: Border.all(width: 1.w, color: GrayColors.gray200),
),
child: TypographyStyles.caption(opsiFlight!, color: GrayColors.gray800),
)
else
SizedBox.shrink(),
],
),
if (!isActive)
Container(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
decoration: BoxDecoration(
color: RedColors.red200,
borderRadius: BorderRadius.circular(10.r),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TypographyStyles.caption("Batas Pembayaran",
color: RedColors.red600, fontWeight: FontWeight.w400),
TypographyStyles.caption("01:07:12", color: RedColors.red600)
],
),
),
if (!isActive) PaymentCountdownTimer(expiryTime: expiryTime!),
SizedBox(height: 22.h),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
TypographyStyles.caption("Citilink (103)", color: GrayColors.gray800),
TypographyStyles.caption("${airlines} (${codeAirlines})", color: GrayColors.gray800),
SizedBox(width: 10.w),
SvgPicture.asset('assets/images/citilink.svg', width: 40.w, height: 10.h),
logo != null && logo!.isNotEmpty
? Image.network(
logo!,
width: 40.w,
height: 26.h,
errorBuilder: (context, error, stackTrace) {
log("Error loading image: $error");
return Container(
width: 40.w,
height: 10.h,
child: Center(child: Icon(Icons.error)),
);
},
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Container(
width: 40.w,
height: 10.h,
child: Center(child: CircularProgressIndicator(strokeWidth: 1.0)),
);
},
)
: SvgPicture.asset('assets/images/citilink.svg', width: 10.w, height: 10.h),
],
),
SizedBox(height: 4.w),
Row(
children: [
TypographyStyles.small(
'Fast Track (FT)',
color: GrayColors.gray600,
letterSpacing: 0.2,
fontWeight: FontWeight.w400,
),
_buildText(context, text: 'Economy'),
_buildText(context, text: '2 Dewasa'),
],
Wrap(
spacing: 4.w,
runSpacing: 4.h,
crossAxisAlignment: WrapCrossAlignment.center,
children: _buildInfoItems(),
),
SizedBox(height: 20.h),
SvgPicture.asset('assets/images/divider_custome.svg', width: 348.w),
@ -98,13 +156,13 @@ class CardBoardingPass extends StatelessWidget {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TypographyStyles.caption("12:20", color: GrayColors.gray800),
TypographyStyles.small("Sen, 27 Jan", color: GrayColors.gray600, fontWeight: FontWeight.w400),
TypographyStyles.caption("$departureTime", color: GrayColors.gray800),
TypographyStyles.small("$departureDate", color: GrayColors.gray600, fontWeight: FontWeight.w400),
SizedBox(height: 20.h),
TypographyStyles.small("5j 40m", color: GrayColors.gray600, fontWeight: FontWeight.w400),
TypographyStyles.small("$duration", color: GrayColors.gray600, fontWeight: FontWeight.w400),
SizedBox(height: 20.h),
TypographyStyles.caption("12:20", color: GrayColors.gray800),
TypographyStyles.small("Sen, 27 Jan", color: GrayColors.gray600, fontWeight: FontWeight.w400),
TypographyStyles.caption("$arrivalTime", color: GrayColors.gray800),
TypographyStyles.small("$arrivalDate", color: GrayColors.gray600, fontWeight: FontWeight.w400),
],
),
SizedBox(width: 20.w),
@ -114,18 +172,18 @@ class CardBoardingPass extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TypographyStyles.caption("Yogyakarta (YIA)", color: GrayColors.gray800),
TypographyStyles.caption("$departureCity ($departureCode)", color: GrayColors.gray800),
TypographyStyles.caption(
"Bandar YIA, Terminal Domestic",
"$departurePlane",
color: GrayColors.gray600,
fontWeight: FontWeight.w400,
maxlines: 2,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 58.h),
TypographyStyles.caption("Yogyakarta (YIA)", color: GrayColors.gray800),
TypographyStyles.caption("$arrivalCity ($arrivalCode)", color: GrayColors.gray800),
TypographyStyles.caption(
"Bandar Zainuddin Abdul Madjid, Terminal Domestic",
"$arrivalPlane",
color: GrayColors.gray600,
fontWeight: FontWeight.w400,
maxlines: 2,
@ -145,7 +203,7 @@ class CardBoardingPass extends StatelessWidget {
);
}
Widget _buildHeaderStatus() {
Widget _buildHeaderStatus({required String idBooking, required String? opsiFlight}) {
final Color headerColor = isActive ? GreenColors.green100 : GrayColors.gray500;
if (isActive) {
return Container(
@ -173,35 +231,62 @@ class CardBoardingPass extends StatelessWidget {
children: [
TypographyStyles.body('Kode Booking', color: Colors.white, fontWeight: FontWeight.w400),
SizedBox(width: 20.w),
TypographyStyles.body('I2L8JRL', color: Colors.white),
TypographyStyles.body(idBooking, color: Colors.white),
],
),
Container(
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 6.h),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8.r)),
child: TypographyStyles.caption('Keberangkatan', color: GrayColors.gray800),
)
if (opsiFlight != null && opsiFlight.isNotEmpty)
Container(
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 6.h),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8.r)),
child: TypographyStyles.caption(opsiFlight, color: GrayColors.gray800),
)
else
SizedBox.shrink()
],
),
);
}
Widget _buildText(
BuildContext context, {
required String text,
}) {
return Row(
children: [
SizedBox(width: 10.w),
CircleAvatar(radius: 2.r, backgroundColor: Color(0xFFD9D9D9)),
SizedBox(width: 10.w),
TypographyStyles.small(
text,
List<Widget> _buildInfoItems() {
List<Widget> items = [];
if (servicePorter != null && servicePorter!.isNotEmpty) {
for (int i = 0; i < servicePorter!.length; i++) {
items.add(TypographyStyles.small(
servicePorter![i],
color: GrayColors.gray600,
letterSpacing: 0.2,
fontWeight: FontWeight.w400,
),
],
);
));
if (i < servicePorter!.length - 1 || flightClass != null) {
items.add(SizedBox(width: 5.w));
items.add(CircleAvatar(radius: 2.r, backgroundColor: Color(0xFFD9D9D9)));
items.add(SizedBox(width: 5.w));
}
}
}
if (flightClass != null && flightClass!.isNotEmpty) {
items.add(TypographyStyles.small(
flightClass!,
color: GrayColors.gray600,
letterSpacing: 0.2,
fontWeight: FontWeight.w400,
));
items.add(SizedBox(width: 5.w));
items.add(CircleAvatar(radius: 2.r, backgroundColor: Color(0xFFD9D9D9)));
items.add(SizedBox(width: 5.w));
}
items.add(TypographyStyles.small(
"${passenger ?? 1} Dewasa",
color: GrayColors.gray600,
letterSpacing: 0.2,
fontWeight: FontWeight.w400,
));
return items;
}
}

View File

@ -1,10 +1,17 @@
import 'dart:async';
import 'dart:developer';
import 'package:e_porter/_core/component/appbar/appbar_component.dart';
import 'package:e_porter/_core/constants/colors.dart';
import 'package:e_porter/_core/utils/formatter/date_helper.dart';
import 'package:e_porter/presentation/controllers/history_controller.dart';
import 'package:e_porter/presentation/screens/routes/app_rountes.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import '../../../../_core/service/preferences_service.dart';
import '../../../../_core/utils/map_helper.dart';
import '../../../../domain/models/transaction_model.dart';
import '../component/card_boarding_pass.dart';
class BoardingPassScreen extends StatefulWidget {
@ -16,19 +23,109 @@ class BoardingPassScreen extends StatefulWidget {
class _BoardingPassScreenState extends State<BoardingPassScreen> with SingleTickerProviderStateMixin {
late TabController _tabController;
final HistoryController _historyController = Get.find<HistoryController>();
Timer? _refreshTimer;
bool isLoading = true;
String userId = '';
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_loadUserData();
_refreshTimer = Timer.periodic(Duration(seconds: 10), (_) {
_checkExpiredTransactions();
});
}
@override
void dispose() {
_refreshTimer?.cancel();
_tabController.dispose();
super.dispose();
}
Future<void> _checkExpiredTransactions() async {
if (userId.isNotEmpty) {
final now = DateTime.now();
final expiredTransactions =
_historyController.pendingTransactions.where((tx) => tx.expiryTime.isBefore(now)).toList();
if (expiredTransactions.isNotEmpty) {
await _historyController.checkExpiredPendingTransactions();
setState(() {});
}
}
}
Future<void> _loadUserData() async {
final userData = await PreferencesService.getUserData();
if (userData != null) {
userId = userData.uid;
_historyController.initStreams(userId);
} else {
_historyController.isLoading.value = false;
}
setState(() {
isLoading = false;
});
}
String? _getPorterServiceInfo(TransactionModel transaction) {
if (transaction.porterServiceDetails == null || transaction.porterServiceDetails!.isEmpty) {
return null;
}
final porterService = transaction.porterServiceDetails!;
final keys = porterService.keys.toList();
if (keys.isEmpty) {
return null;
}
keys.sort();
if (keys.length == 1) {
final type = _getPorterTypeInIndonesian(keys.first);
return type;
}
final firstType = _getPorterTypeInIndonesian(keys.first);
return "$firstType & ${keys.length - 1}";
}
String _getPorterTypeInIndonesian(String type) {
switch (type) {
case 'arrival':
return 'Kedatangan';
case 'departure':
return 'Keberangkatan';
case 'transit':
return 'Transit';
default:
return type;
}
}
List<String> _getPorterServiceNames(TransactionModel transaction) {
List<String> serviceNames = [];
if (transaction.porterServiceDetails == null || transaction.porterServiceDetails!.isEmpty) {
return serviceNames;
}
transaction.porterServiceDetails!.forEach((serviceType, serviceDetails) {
if (serviceDetails is Map && serviceDetails.containsKey('name')) {
String serviceName = serviceDetails['name'] as String? ?? '';
if (serviceName.isNotEmpty) {
serviceNames.add(serviceName);
}
}
});
return serviceNames;
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -56,40 +153,63 @@ class _BoardingPassScreenState extends State<BoardingPassScreen> with SingleTick
],
),
),
Obx(() => _historyController.isCheckingExpiry.value
? Container(
width: double.infinity,
padding: EdgeInsets.symmetric(vertical: 8.h),
color: Colors.amber.shade100,
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 16.w,
height: 16.h,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.amber.shade800),
),
),
SizedBox(width: 8.w),
Text(
"Memeriksa status transaksi...",
style: TextStyle(
fontSize: 12.sp,
color: Colors.amber.shade800,
),
),
],
),
),
)
: SizedBox.shrink()),
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 20.h),
child: TabBarView(
controller: _tabController,
children: [
ListView.builder(
itemCount: 1,
itemBuilder: (context, index) {
return Padding(
padding: EdgeInsets.only(bottom: 16.h),
child: CardBoardingPass(
isActive: false,
onTap: () {},
),
);
},
),
ListView.builder(
itemCount: 2,
itemBuilder: (context, index) {
return Padding(
padding: EdgeInsets.only(bottom: 16.h),
child: CardBoardingPass(
isActive: true,
onTap: () {
Get.toNamed(Routes.DETAILTICKET);
},
),
);
},
),
],
),
child: Obx(() {
if (_historyController.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (_historyController.pendingTransactions.isEmpty && _historyController.activeTransactions.isEmpty) {
return const Center(
child: Text(
"Tidak ada transaksi",
style: TextStyle(
fontSize: 16,
color: GrayColors.gray500,
),
),
);
}
return TabBarView(
controller: _tabController,
children: [
_buildTransactionList(_historyController.pendingTransactions, false),
_buildTransactionList(_historyController.activeTransactions, true),
],
);
}),
),
),
],
@ -97,4 +217,85 @@ class _BoardingPassScreenState extends State<BoardingPassScreen> with SingleTick
),
);
}
Widget _buildTransactionList(List<TransactionModel> transactions, bool isActive) {
if (transactions.isEmpty) {
return const Center(
child: Text(
"Tidak ada transaksi",
style: TextStyle(
fontSize: 16,
color: GrayColors.gray500,
),
),
);
}
return RefreshIndicator(
onRefresh: () async {
await _historyController.refreshTransactions(userId);
_historyController.checkExpiredPendingTransactions();
},
child: ListView.builder(
itemCount: transactions.length,
itemBuilder: (context, index) {
final transaction = transactions[index];
final flightDetails = transaction.flightDetails;
final bandaraDetails = transaction.bandaraDetails;
final porterServiceInfo = _getPorterServiceInfo(transaction);
final porterServiceNames = _getPorterServiceNames(transaction);
final departureTime = DateFormatterHelper.formatFlightTime(transaction.flightDetails['departureTime']);
final arrivalTime = DateFormatterHelper.formatFlightTime(transaction.flightDetails['arrivalTime']);
final departureDate = DateFormatterHelper.formatFlightDate(transaction.flightDetails['departureTime']);
final arrivalDate = DateFormatterHelper.formatFlightDate(transaction.flightDetails['arrivalTime']);
final duration = DateFormatterHelper.calculateFlightDuration(
transaction.flightDetails['departureTime'],
transaction.flightDetails['arrivalTime'],
);
final departurePlane = MapHelper.getNestedValue(bandaraDetails, ['departure', 'name'], '');
final arrivalPlane = MapHelper.getNestedValue(bandaraDetails, ['arrival', 'name'], '');
return Padding(
padding: EdgeInsets.only(bottom: 16.h),
child: CardBoardingPass(
isActive: isActive,
idBooking: transaction.idBooking,
opsiFlight: porterServiceInfo,
expiryTime: transaction.expiryTime,
airlines: flightDetails['airLines'],
codeAirlines: flightDetails['code'],
logo: flightDetails['airlineLogo'],
servicePorter: porterServiceNames,
flightClass: flightDetails['flightClass'],
passenger: transaction.passenger,
departureTime: departureTime,
arrivalTime: arrivalTime,
departureDate: departureDate,
arrivalDate: arrivalDate,
duration: duration,
departureCity: flightDetails['cityDeparture'],
arrivalCity: flightDetails['cityArrival'],
departureCode: flightDetails['codeDeparture'],
arrivalCode: flightDetails['codeArrival'],
departurePlane: departurePlane,
arrivalPlane: arrivalPlane,
onTap: () {
final argument = {
'id_transaction': transaction.id,
'id_ticket': transaction.ticketId,
};
log('ID Transaction: ${transaction.id}');
log('ID Ticket: ${transaction.ticketId}');
Get.toNamed(Routes.DETAILTICKET, arguments: argument);
},
),
);
},
),
);
}
}

View File

@ -12,6 +12,7 @@ import firebase_auth
import firebase_core
import firebase_database
import firebase_storage
import mobile_scanner
import path_provider_foundation
import shared_preferences_foundation
@ -23,6 +24,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseDatabasePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseDatabasePlugin"))
FLTFirebaseStoragePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseStoragePlugin"))
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
}

View File

@ -448,6 +448,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.15.0"
mobile_scanner:
dependency: "direct main"
description:
name: mobile_scanner
sha256: f536c5b8cadcf73d764bdce09c94744f06aa832264730f8971b21a60c5666826
url: "https://pub.dev"
source: hosted
version: "6.0.10"
path:
dependency: "direct main"
description:

View File

@ -59,6 +59,7 @@ dependencies:
path: ^1.9.0
permission_handler: ^11.4.0
device_info_plus: ^11.3.0
mobile_scanner: ^6.0.10
# workmanager: ^0.5.2
# pin_code_fields: ^8.0.1