Files
CityCards_Partner_Flutter/lib/scan/view/qr_scan_screen.dart
2026-04-24 10:37:05 +05:30

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