995 lines
35 KiB
Dart
995 lines
35 KiB
Dart
import 'dart:io';
|
|
import 'package:citycards_partner_flutter/constants/app_assets.dart';
|
|
import 'package:citycards_partner_flutter/core/app_router.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
|
import 'package:intl/intl.dart';
|
|
import '../../local_peference/local_preference.dart';
|
|
import '../bloc/submit_qr_code/submit_qr_code_bloc.dart';
|
|
import '../bloc/recent_scan_history/recent_scan_history_bloc.dart';
|
|
import '../models/recent_scan_history_model.dart';
|
|
|
|
|
|
class QrScanScreen extends StatefulWidget {
|
|
const QrScanScreen({super.key});
|
|
|
|
@override
|
|
State<QrScanScreen> createState() => _QrScanScreenState();
|
|
}
|
|
|
|
class _QrScanScreenState extends State<QrScanScreen>
|
|
with WidgetsBindingObserver {
|
|
late MobileScannerController _cameraController;
|
|
final ValueNotifier<bool> _isTorchOn = ValueNotifier(false);
|
|
bool _cameraAvailable = true;
|
|
|
|
final sheetContentKey = GlobalKey();
|
|
final ValueNotifier<double> sheetExtent = ValueNotifier(0.5);
|
|
final ValueNotifier<double> initialSize = ValueNotifier(0.5);
|
|
final DraggableScrollableController sheetController =
|
|
DraggableScrollableController();
|
|
final TextEditingController _manualCodeController = TextEditingController();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addObserver(this);
|
|
|
|
_cameraController = MobileScannerController();
|
|
|
|
// 🔍 Detect if running in iOS Simulator
|
|
if (Platform.isIOS && !Platform.isMacOS) {
|
|
_cameraAvailable = !Platform.environment.containsKey(
|
|
'SIMULATOR_DEVICE_NAME',
|
|
);
|
|
}
|
|
|
|
// 🔦 Update torch state
|
|
_cameraController.addListener(() {
|
|
final torchState = _cameraController.value.torchState;
|
|
_isTorchOn.value = (torchState == TorchState.on);
|
|
});
|
|
|
|
// 📡 Fetch recent scan history
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
context.read<RecentScanHistoryBloc>().add(FetchRecentScanHistory());
|
|
});
|
|
}
|
|
|
|
|
|
@override
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
_cameraController.dispose();
|
|
_manualCodeController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
/// Manage camera when app state changes
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
if (!mounted) return;
|
|
if (!_cameraAvailable) return;
|
|
|
|
if (state == AppLifecycleState.paused) {
|
|
_cameraController.stop();
|
|
} else if (state == AppLifecycleState.resumed) {
|
|
_safeStartCamera();
|
|
}
|
|
}
|
|
|
|
/// ✅ Start camera safely (prevent multiple starts)
|
|
void _safeStartCamera() async {
|
|
if (!mounted || !_cameraAvailable) return;
|
|
|
|
try {
|
|
if (!_cameraController.value.isStarting) {
|
|
await _cameraController.start();
|
|
}
|
|
} catch (e) {
|
|
debugPrint("⚠️ Camera already running or failed to start: $e");
|
|
}
|
|
}
|
|
|
|
String _extractQrCode(String rawData) {
|
|
if (!rawData.contains("QR Code :")) return rawData;
|
|
final lines = rawData.split('\n');
|
|
for (var line in lines) {
|
|
if (line.contains("QR Code :")) {
|
|
return line.split(':').last.trim();
|
|
}
|
|
}
|
|
return rawData;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final Offset scanCenter = Offset(
|
|
MediaQuery.of(context).size.width / 2,
|
|
MediaQuery.of(context).size.height / 2 - 78.h,
|
|
);
|
|
|
|
return BlocListener<SubmitQrCodeBloc, SubmitQrCodeState>(
|
|
listener: (context, state) {
|
|
if (state is SubmitQrCodeSuccess || state is SubmitQrCodeFailure) {
|
|
context.read<RecentScanHistoryBloc>().add(FetchRecentScanHistory());
|
|
// Delay to allow layout rebuild and height recalculation
|
|
Future.delayed(const Duration(milliseconds: 150), () {
|
|
if (mounted && sheetController.isAttached) {
|
|
sheetController.animateTo(
|
|
initialSize.value,
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeOut,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
},
|
|
child: Builder(
|
|
builder: (context) {
|
|
return BlocBuilder<SubmitQrCodeBloc, SubmitQrCodeState>(
|
|
builder: (context, state) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
final isInitial = state is SubmitQrCodeInitial;
|
|
final isExpanded = sheetExtent.value > 0.7;
|
|
|
|
// Only update initialSize if NOT expanded or if we are in a result state
|
|
if (state is! SubmitQrCodeInitial || !isExpanded) {
|
|
final RenderBox? box =
|
|
sheetContentKey.currentContext?.findRenderObject()
|
|
as RenderBox?;
|
|
if (box != null) {
|
|
final height = box.size.height;
|
|
final screenHeight = MediaQuery.of(context).size.height;
|
|
|
|
// Measure everything including header and spacers
|
|
final fraction = isInitial
|
|
? (height / screenHeight).clamp(0.45, 0.6)
|
|
: (height / screenHeight).clamp(0.3, 0.6);
|
|
|
|
if (initialSize.value != fraction) {
|
|
initialSize.value = fraction;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
return ValueListenableBuilder<double>(
|
|
valueListenable: initialSize,
|
|
builder: (context, initFraction, _) {
|
|
return Scaffold(
|
|
backgroundColor: Colors.black,
|
|
body: Stack(
|
|
children: [
|
|
/// 🚫 Show this if no camera available (iOS simulator)
|
|
if (!_cameraAvailable)
|
|
Center(
|
|
child: Text(
|
|
"Camera not available on iOS Simulator.\nPlease test on a real device.",
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 16.sp,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
)
|
|
else
|
|
|
|
/// 📷 QR Scanner
|
|
MobileScanner(
|
|
controller: _cameraController,
|
|
fit: BoxFit.cover,
|
|
onDetect: (capture) {
|
|
final barcode =
|
|
capture.barcodes.first.rawValue ?? '';
|
|
if (barcode.isNotEmpty &&
|
|
state is! SubmitQrCodeLoading &&
|
|
state is SubmitQrCodeInitial) {
|
|
final extractedCode = _extractQrCode(barcode);
|
|
_cameraController.stop();
|
|
context.read<SubmitQrCodeBloc>().add(
|
|
SubmitQrCodeEventTriggered(
|
|
qrCode: extractedCode));
|
|
}
|
|
},
|
|
),
|
|
|
|
/// 🔲 Scanner Frame
|
|
if (_cameraAvailable)
|
|
Positioned(
|
|
left: scanCenter.dx - 125.w,
|
|
top: scanCenter.dy - 200.h,
|
|
child: CustomPaint(
|
|
size: Size(250.w, 250.h),
|
|
painter: CornerBorderPainter(
|
|
const LinearGradient(
|
|
colors: [Colors.white, Colors.white],
|
|
),
|
|
strokeWidth: 4.w,
|
|
),
|
|
),
|
|
),
|
|
|
|
/// 🧾 Draggable Bottom Sheet
|
|
SafeArea(
|
|
bottom: false,
|
|
child: NotificationListener<
|
|
DraggableScrollableNotification>(
|
|
onNotification: (notification) {
|
|
final value = notification.extent;
|
|
sheetExtent.value = value;
|
|
|
|
if (value >= 0.9) {
|
|
_cameraController.stop();
|
|
} else if (value <= initFraction + 0.02) {
|
|
_safeStartCamera();
|
|
}
|
|
|
|
return false;
|
|
},
|
|
child: DraggableScrollableSheet(
|
|
controller: sheetController,
|
|
initialChildSize: initFraction,
|
|
minChildSize: initFraction,
|
|
maxChildSize: 1.0,
|
|
snap: true,
|
|
snapSizes: [
|
|
initFraction,
|
|
if (1.0 > initFraction) 1.0,
|
|
],
|
|
builder: (context, scrollController) {
|
|
return ValueListenableBuilder<double>(
|
|
valueListenable: sheetExtent,
|
|
builder: (context, extent, _) {
|
|
final isExpanded = extent > 0.7;
|
|
final double radius =
|
|
isExpanded ? 0 : 24.r;
|
|
|
|
return AnimatedContainer(
|
|
duration: const Duration(
|
|
milliseconds: 250,
|
|
),
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: 16.w, vertical: 8.h),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.vertical(
|
|
top: Radius.circular(radius),
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(
|
|
0.1,
|
|
),
|
|
blurRadius: 8.r,
|
|
offset: Offset(0, -2.h),
|
|
),
|
|
],
|
|
),
|
|
child: SingleChildScrollView(
|
|
controller: scrollController,
|
|
physics:
|
|
const BouncingScrollPhysics(),
|
|
child: Column(
|
|
key: sheetContentKey,
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.start,
|
|
children: [
|
|
_headerIcons(
|
|
context,
|
|
isExpanded,
|
|
state,
|
|
_cameraController,
|
|
sheetController,
|
|
initFraction,
|
|
),
|
|
SizedBox(height: 12.h),
|
|
if (state is SubmitQrCodeLoading)
|
|
const Center(
|
|
child: Padding(
|
|
padding:
|
|
EdgeInsets.all(20),
|
|
child:
|
|
CircularProgressIndicator(
|
|
color: Colors
|
|
.red)))
|
|
else if (state
|
|
is SubmitQrCodeSuccess)
|
|
_successLayout(state)
|
|
else if (state
|
|
is SubmitQrCodeFailure)
|
|
_failedLayout(state)
|
|
else
|
|
(isExpanded
|
|
? _expandedLayout(
|
|
context,
|
|
initFraction,
|
|
)
|
|
: _collapsedLayout(
|
|
context,
|
|
initFraction,
|
|
)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _headerIcons(
|
|
BuildContext context,
|
|
bool isExpanded,
|
|
SubmitQrCodeState status,
|
|
MobileScannerController controller,
|
|
DraggableScrollableController sheetController,
|
|
double initFraction,
|
|
) {
|
|
return ValueListenableBuilder<MobileScannerState>(
|
|
valueListenable: controller,
|
|
builder: (context, state, _) {
|
|
final isTorchOn = state.torchState == TorchState.on;
|
|
final statusIdle = status is SubmitQrCodeInitial;
|
|
|
|
return Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
// Left: Flash or Back
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: GestureDetector(
|
|
onTap: () async {
|
|
if (!statusIdle) {
|
|
context
|
|
.read<SubmitQrCodeBloc>()
|
|
.add(const ResetSubmitQrCodeEvent());
|
|
_safeStartCamera();
|
|
// Wait for layout to update back to initial state
|
|
Future.delayed(const Duration(milliseconds: 200), () {
|
|
if (mounted && sheetController.isAttached) {
|
|
sheetController.animateTo(
|
|
initialSize.value,
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeOut,
|
|
);
|
|
}
|
|
});
|
|
} else if (isExpanded) {
|
|
if (sheetController.isAttached) {
|
|
await sheetController.animateTo(
|
|
initFraction,
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeOut,
|
|
);
|
|
}
|
|
} else {
|
|
await controller.toggleTorch();
|
|
}
|
|
},
|
|
child: ((isExpanded) || (!statusIdle))
|
|
? Icon(
|
|
Icons.arrow_back,
|
|
color: Colors.redAccent,
|
|
size: 28.sp,
|
|
)
|
|
: Image.asset(
|
|
isTorchOn
|
|
? 'assets/scan/flash_on.png'
|
|
: 'assets/scan/flash.png',
|
|
width: 24.w,
|
|
height: 24.h,
|
|
fit: BoxFit.contain,
|
|
),
|
|
),
|
|
),
|
|
// Center: Logo
|
|
Image.asset(
|
|
AppAssets.appIcon,
|
|
width: 120.w,
|
|
height: 32.h,
|
|
fit: BoxFit.contain,
|
|
color: Colors.black,
|
|
),
|
|
// Right: History and Profile
|
|
Align(
|
|
alignment: Alignment.centerRight,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
GestureDetector(
|
|
onTap: () async {
|
|
await controller.stop();
|
|
await Navigator.pushNamed(context, AppRouter.scanHistory);
|
|
_safeStartCamera();
|
|
},
|
|
child: Image.asset(
|
|
'assets/scan/history.png',
|
|
width: 24.w,
|
|
height: 24.h,
|
|
fit: BoxFit.contain,
|
|
),
|
|
),
|
|
SizedBox(width: 12.w),
|
|
GestureDetector(
|
|
onTap: () async {
|
|
await controller.stop();
|
|
await Navigator.pushNamed(
|
|
context, AppRouter.profileScreen);
|
|
_safeStartCamera();
|
|
},
|
|
child: Image.asset(
|
|
'assets/scan/profile.png',
|
|
width: 24.w,
|
|
height: 24.h,
|
|
fit: BoxFit.contain,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
|
|
/// ✅ Success Layout
|
|
Widget _successLayout(SubmitQrCodeSuccess state) {
|
|
final redemption = state.data['redemption'] as Map<String, dynamic>? ?? {};
|
|
|
|
String formatDate(String? dateStr) {
|
|
if (dateStr == null) return "N/A";
|
|
try {
|
|
final date = DateTime.parse(dateStr);
|
|
return DateFormat('dd MMMM, yyyy').format(date);
|
|
} catch (e) {
|
|
return dateStr;
|
|
}
|
|
}
|
|
|
|
String formatScanTime(String? scanTime) {
|
|
if (scanTime == null) return "N/A";
|
|
try {
|
|
final date = DateFormat("dd-MM-yyyy HH:mm").parse(scanTime);
|
|
return DateFormat("dd/MM/yy 'at' hh:mm a").format(date);
|
|
} catch (e) {
|
|
return scanTime;
|
|
}
|
|
}
|
|
|
|
return Container(
|
|
padding: EdgeInsets.all(16.w),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFE6F4EA),
|
|
borderRadius: BorderRadius.circular(20.r),
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildRichText("Customer Name", redemption['customerName'] ?? 'N/A'),
|
|
SizedBox(height: 10.h),
|
|
_buildRichText("Pass Type", redemption['cardType'] ?? 'N/A'),
|
|
SizedBox(height: 10.h),
|
|
_buildRichText("Valid until", formatDate(redemption['validUpto'])),
|
|
SizedBox(height: 10.h),
|
|
_buildRichText("Time of scan", formatScanTime(redemption['timeScanned'])),
|
|
],
|
|
),
|
|
),
|
|
SizedBox(width: 12.w),
|
|
Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.check, color: const Color(0xFF1E8E3E), size: 48.sp),
|
|
Text(
|
|
"Success",
|
|
style: TextStyle(
|
|
color: const Color(0xFF1E8E3E),
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 18.sp,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// ❌ Failed Layout
|
|
Widget _failedLayout(SubmitQrCodeFailure state) => Container(
|
|
padding: EdgeInsets.all(16.w),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFFDE8E8),
|
|
borderRadius: BorderRadius.circular(20.r),
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildRichText("Reason", state.errorMessage),
|
|
SizedBox(height: 10.h),
|
|
_buildRichText(
|
|
"Suggested Action",
|
|
state.error ?? "Inform the guest this pass has already been redeemed.",
|
|
),
|
|
],
|
|
),
|
|
),
|
|
SizedBox(width: 12.w),
|
|
Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.cancel, color: const Color(0xFFD32F2F), size: 48.sp),
|
|
Text(
|
|
"Failed",
|
|
style: TextStyle(
|
|
color: const Color(0xFFD32F2F),
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 18.sp,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
Widget _buildRichText(String label, String value) {
|
|
return RichText(
|
|
text: TextSpan(
|
|
style: TextStyle(
|
|
color: Colors.black87,
|
|
fontSize: 14.sp,
|
|
height: 1.2,
|
|
),
|
|
children: [
|
|
TextSpan(text: "$label: "),
|
|
TextSpan(
|
|
text: value,
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 📱 Collapsed Layout
|
|
Widget _collapsedLayout(
|
|
BuildContext context,
|
|
double initFraction,
|
|
) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Center(
|
|
child: Image.asset("assets/scan/mobile.png",
|
|
width: 80.w, height: 80.h, fit: BoxFit.contain)),
|
|
SizedBox(height: 10.h),
|
|
Center(
|
|
child: Text(
|
|
"Position your phone to make sure the QR code is within the frame",
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(fontSize: 14.sp, color: Colors.black87),
|
|
),
|
|
),
|
|
SizedBox(height: 25.h),
|
|
Text(
|
|
"Quick Links",
|
|
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 18.sp),
|
|
),
|
|
SizedBox(height: 10.h),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _quickLink(
|
|
context,
|
|
"Redemption",
|
|
"assets/scan/ticket-fill.png",
|
|
AppRouter.ticketRedemptionScreen,
|
|
initFraction,
|
|
10.h, // Added padding
|
|
),
|
|
),
|
|
SizedBox(width: 8.w),
|
|
Expanded(
|
|
child: _quickLink(
|
|
context,
|
|
"Support",
|
|
"assets/scan/support.png",
|
|
AppRouter.helpSupportPage,
|
|
initFraction,
|
|
10.h, // Added padding
|
|
),
|
|
),
|
|
SizedBox(width: 8.w),
|
|
Expanded(
|
|
child: _quickLink(
|
|
context,
|
|
"Booking",
|
|
"assets/scan/page.png",
|
|
AppRouter.bookingPage,
|
|
initFraction,
|
|
10.h, // Added padding
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SizedBox(height: 10.h), // Empty space at bottom
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 🔼 Expanded Layout
|
|
Widget _expandedLayout(BuildContext context, double initFraction) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
"Manual Entry",
|
|
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16.sp),
|
|
),
|
|
SizedBox(height: 6.h),
|
|
Text("Enter redemption code", style: TextStyle(fontSize: 13.sp)),
|
|
SizedBox(height: 8.h),
|
|
TextField(
|
|
controller: _manualCodeController,
|
|
maxLength: 30,
|
|
style: TextStyle(fontSize: 14.sp),
|
|
decoration: InputDecoration(
|
|
hintText: "eg: QR-1683500000000-123",
|
|
counterText: "",
|
|
contentPadding:
|
|
EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
|
|
border:
|
|
OutlineInputBorder(borderRadius: BorderRadius.circular(10.r)),
|
|
suffixIcon: IconButton(
|
|
icon: const Icon(Icons.send, color: Colors.redAccent),
|
|
onPressed: () {
|
|
if (_manualCodeController.text.isNotEmpty) {
|
|
context.read<SubmitQrCodeBloc>().add(
|
|
SubmitQrCodeEventTriggered(
|
|
qrCode: _manualCodeController.text));
|
|
}
|
|
},
|
|
),
|
|
),
|
|
),
|
|
SizedBox(height: 20.h),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
"Recent Scans",
|
|
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 24.sp),
|
|
),
|
|
GestureDetector(
|
|
onTap: () async {
|
|
await _cameraController.stop();
|
|
await Navigator.pushNamed(context, AppRouter.scanHistory);
|
|
_safeStartCamera();
|
|
},
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
border: Border(bottom: BorderSide(color: Colors.redAccent,width: 0.8)),
|
|
),
|
|
child: Text(
|
|
"View All",
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w400,
|
|
fontSize: 14.sp,
|
|
color: Colors.redAccent,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SizedBox(height: 10.h),
|
|
BlocBuilder<RecentScanHistoryBloc, RecentScanHistoryState>(
|
|
builder: (context, state) {
|
|
if (state is RecentScanHistoryLoading) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
} else if (state is RecentScanHistoryError) {
|
|
return Text("Error: ${state.message}", style: const TextStyle(color: Colors.red));
|
|
} else if (state is RecentScanHistoryLoaded) {
|
|
if (state.history.isEmpty) {
|
|
return const Text("No recent scans found.");
|
|
}
|
|
// Only show the last 2 for the "Recent Scans" preview
|
|
final displayHistory = state.history.take(2).toList();
|
|
return Column(
|
|
children: displayHistory.asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
final item = entry.value;
|
|
final isSuccess = item.status.toLowerCase() == "success";
|
|
final label = index == 0 ? "Last Scan" : "Previous Scan";
|
|
|
|
return GestureDetector(
|
|
onTap: () {
|
|
Navigator.pushNamed(context, AppRouter.scanHistoryDetailPage, arguments: item.id);
|
|
},
|
|
child: Padding(
|
|
padding: EdgeInsets.only(bottom: 12.h),
|
|
child: _scanCard(
|
|
label,
|
|
item.attractionTitle.isEmpty ? "N/A" : item.attractionTitle,
|
|
item.status,
|
|
isSuccess ? const Color(0xFF1E8E3E) : const Color(0xFFD32F2F),
|
|
isSuccess ? const Color(0xFFE6F4EA) : const Color(0xFFFDE8E8),
|
|
reason: item.reason,
|
|
isSuccess: isSuccess,
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
},
|
|
),
|
|
SizedBox(height: 15.h),
|
|
|
|
Text(
|
|
"Quick Links",
|
|
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 18.sp),
|
|
),
|
|
SizedBox(height: 10.h),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _quickLink(
|
|
context,
|
|
"Support",
|
|
"assets/scan/support.png",
|
|
AppRouter.helpSupportPage,
|
|
initFraction,
|
|
22.h, // Added padding
|
|
),
|
|
),
|
|
SizedBox(width: 10.w),
|
|
Expanded(
|
|
child: _quickLink(
|
|
context,
|
|
"Booking",
|
|
"assets/scan/page.png",
|
|
AppRouter.bookingPage,
|
|
initFraction,
|
|
22.h, // Added padding
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SizedBox(height: 10.h),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _quickLink(
|
|
context,
|
|
"Redemption",
|
|
"assets/scan/ticket-fill.png",
|
|
AppRouter.ticketRedemptionScreen,
|
|
initFraction,
|
|
22.h, // Added padding
|
|
),
|
|
),
|
|
SizedBox(width: 10.w),
|
|
Expanded(
|
|
child: _quickLink(
|
|
context,
|
|
"Scan Image",
|
|
"assets/scan/qr.png",
|
|
AppRouter.qrScanScreen,
|
|
initFraction,
|
|
22.h, // Added padding
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 🔘 Scan Card
|
|
Widget _scanCard(
|
|
String label,
|
|
String title,
|
|
String status,
|
|
Color color,
|
|
Color bgColor, {
|
|
String? reason,
|
|
required bool isSuccess,
|
|
}) {
|
|
return Container(
|
|
padding: EdgeInsets.all(12.w),
|
|
decoration: BoxDecoration(
|
|
color: bgColor,
|
|
borderRadius: BorderRadius.circular(10.r),
|
|
border: Border.all(color: color.withOpacity(0.3), width: 1.w),
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 16.sp,
|
|
color: Colors.black54,
|
|
letterSpacing: 0.2,
|
|
),
|
|
),
|
|
SizedBox(height: 2.h),
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
isSuccess ? Icons.check_circle : Icons.cancel,
|
|
color: color,
|
|
size: 18.sp,
|
|
),
|
|
SizedBox(width: 4.w),
|
|
Text(
|
|
status,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.black87,
|
|
// color: color,
|
|
fontSize: 16.sp,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (!isSuccess && reason != null && reason.isNotEmpty)
|
|
Padding(
|
|
padding: EdgeInsets.only(top: 4.h),
|
|
child: Text(
|
|
"Reason: $reason",
|
|
style: TextStyle(
|
|
fontSize: 12.sp,
|
|
// color: Colors.red[700],
|
|
color: Colors.black87,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
SizedBox(width: 12.w),
|
|
Image.asset(
|
|
"assets/scan/ticket_qr.png",
|
|
width: 75.w,
|
|
height: 75.h,
|
|
fit: BoxFit.contain,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _quickLink(
|
|
BuildContext context,
|
|
String label,
|
|
String icon,
|
|
String route,
|
|
double initFraction,
|
|
double? verticalPadding, // Add this parameter
|
|
) {
|
|
return InkWell(
|
|
onTap: () async {
|
|
// await LocalPreference.clearAccessToken();
|
|
if (label == "Scan Image") {
|
|
if (sheetController.isAttached) {
|
|
await sheetController.animateTo(
|
|
initFraction,
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeOut,
|
|
);
|
|
}
|
|
} else {
|
|
await _cameraController.stop();
|
|
await Navigator.pushNamed(context, route);
|
|
_safeStartCamera();
|
|
}
|
|
},
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.red[100]?.withValues(alpha: 0.6),
|
|
borderRadius: BorderRadius.circular(10.r),
|
|
),
|
|
padding: EdgeInsets.symmetric(
|
|
vertical: verticalPadding ?? 12.h, // Use parameter, default to 12 if not provided
|
|
),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Image.asset(icon, scale: 4, width: 24.w, height: 24.h),
|
|
SizedBox(height: 6.h),
|
|
Text(
|
|
label,
|
|
style: TextStyle(fontSize: 13.sp, fontWeight: FontWeight.w600),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class CornerBorderPainter extends CustomPainter {
|
|
final LinearGradient gradient;
|
|
final double strokeWidth;
|
|
final double lineLength;
|
|
|
|
CornerBorderPainter(
|
|
this.gradient, {
|
|
this.strokeWidth = 4.0,
|
|
this.lineLength = 45.0,
|
|
});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final paint = Paint()
|
|
..strokeWidth = strokeWidth
|
|
..style = PaintingStyle.stroke
|
|
..strokeCap = StrokeCap.round;
|
|
|
|
final path = Path();
|
|
paint.shader = gradient.createShader(
|
|
Rect.fromLTWH(0, 0, lineLength, lineLength),
|
|
);
|
|
path.moveTo(0, lineLength);
|
|
path.lineTo(0, 0);
|
|
path.lineTo(lineLength, 0);
|
|
canvas.drawPath(path, paint);
|
|
path.reset();
|
|
|
|
path.moveTo(size.width - lineLength, 0);
|
|
path.lineTo(size.width, 0);
|
|
path.lineTo(size.width, lineLength);
|
|
canvas.drawPath(path, paint);
|
|
path.reset();
|
|
|
|
path.moveTo(0, size.height - lineLength);
|
|
path.lineTo(0, size.height);
|
|
path.lineTo(lineLength, size.height);
|
|
canvas.drawPath(path, paint);
|
|
path.reset();
|
|
|
|
path.moveTo(size.width - lineLength, size.height);
|
|
path.lineTo(size.width, size.height);
|
|
path.lineTo(size.width, size.height - lineLength);
|
|
canvas.drawPath(path, paint);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
|
}
|