286 lines
9.7 KiB
Dart
286 lines
9.7 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
import 'package:google_fonts/google_fonts.dart';
|
|
|
|
class PassTicketCard extends StatelessWidget {
|
|
final dynamic pass;
|
|
|
|
const PassTicketCard({super.key, required this.pass});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Dimensions tuned to your screenshot
|
|
final double cardWidth = MediaQuery.of(context).size.width - 32.w;
|
|
final double topSectionHeight = 105.h; // where dotted line sits
|
|
final double bottomSectionHeight = 50.h;
|
|
final double cardHeight = topSectionHeight + bottomSectionHeight;
|
|
|
|
return SizedBox(
|
|
width: cardWidth,
|
|
child: CustomPaint(
|
|
// paints white background, border, corner radius, side cuts, shadow, and divider dots
|
|
painter: _TicketBackgroundPainter(
|
|
cornerRadius: 16.r,
|
|
notchRadius: 9.r,
|
|
dividerY: topSectionHeight,
|
|
borderColor: Colors.white,
|
|
shadowColor: Colors.black.withOpacity(0.08),
|
|
),
|
|
child: ClipPath(
|
|
// actual clipping so child content never bleeds outside the shape
|
|
clipper: _TicketClipper(
|
|
cornerRadius: 16.r,
|
|
notchRadius: 9.r,
|
|
dividerY: topSectionHeight,
|
|
),
|
|
child: Container(
|
|
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
|
|
child: Column(
|
|
children: [
|
|
// ---------- TOP SECTION ----------
|
|
SizedBox(
|
|
height: topSectionHeight - 12.h, // keep space for the dots line
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// thumbnail
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(10.r),
|
|
child: Image.asset(
|
|
pass.imageUrl,
|
|
height: 80.h,
|
|
width: 80.w,
|
|
fit: BoxFit.cover,
|
|
),
|
|
),
|
|
SizedBox(width: 10.w),
|
|
|
|
// details
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
if (pass.isActive)
|
|
Container(
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: 8.w, vertical: 3.h),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xff439F6E),
|
|
borderRadius: BorderRadius.circular(30.r),
|
|
),
|
|
child: Text(
|
|
"Active",
|
|
style: GoogleFonts.poppins(
|
|
color: Colors.white,
|
|
fontSize: 10.sp,
|
|
fontWeight: FontWeight.w400,
|
|
),
|
|
),
|
|
),
|
|
SizedBox(width: 8.w),
|
|
Text(
|
|
pass.duration, // "2 Days"
|
|
style: GoogleFonts.poppins(
|
|
color: Colors.black87,
|
|
fontSize: 12.sp,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SizedBox(height: 10.h),
|
|
Text(
|
|
pass.title,
|
|
style: GoogleFonts.poppins(
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 18.sp,
|
|
height: 1.1,
|
|
),
|
|
),
|
|
SizedBox(height: 4.h),
|
|
Text(
|
|
"Adults-${pass.adults} • Kids-${pass.kids}",
|
|
style: GoogleFonts.poppins(
|
|
color: Colors.black54,
|
|
fontSize: 11.sp,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// QR chip
|
|
CircleAvatar(
|
|
radius: 20.r,
|
|
backgroundColor: Color(0xffFEE7E7),
|
|
child: Image.asset(
|
|
"assets/images/qr_image.png",
|
|
scale: 6,
|
|
),
|
|
)
|
|
],
|
|
),
|
|
),
|
|
|
|
// space exactly where the dotted line is painted by the painter
|
|
SizedBox(height: 15.h),
|
|
|
|
// ---------- BOTTOM SECTION ----------
|
|
Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 4.w),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
"Valid Till: ${pass.validity}",
|
|
style: GoogleFonts.poppins(
|
|
fontSize: 11.sp,
|
|
color: Colors.black,
|
|
fontWeight: FontWeight.w400
|
|
),
|
|
),
|
|
Text(
|
|
pass.city, // "Melbourne"
|
|
style: GoogleFonts.poppins(
|
|
fontWeight: FontWeight.w500,
|
|
fontSize: 13.sp,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Clips the ticket with rounded corners and 2 side “cuts” centered at dividerY
|
|
class _TicketClipper extends CustomClipper<Path> {
|
|
final double cornerRadius;
|
|
final double notchRadius;
|
|
final double dividerY;
|
|
|
|
_TicketClipper({
|
|
required this.cornerRadius,
|
|
required this.notchRadius,
|
|
required this.dividerY,
|
|
});
|
|
|
|
@override
|
|
Path getClip(Size size) {
|
|
final rrectPath = Path()
|
|
..addRRect(RRect.fromRectAndRadius(
|
|
Rect.fromLTWH(0, 0, size.width, size.height),
|
|
Radius.circular(cornerRadius),
|
|
));
|
|
|
|
final cuts = Path()
|
|
..addOval(Rect.fromCircle(center: Offset(0, dividerY), radius: notchRadius))
|
|
..addOval(Rect.fromCircle(center: Offset(size.width, dividerY), radius: notchRadius));
|
|
|
|
// Rounded-rect MINUS the two circles
|
|
return Path.combine(PathOperation.difference, rrectPath, cuts);
|
|
}
|
|
|
|
@override
|
|
bool shouldReclip(covariant _TicketClipper old) =>
|
|
cornerRadius != old.cornerRadius ||
|
|
notchRadius != old.notchRadius ||
|
|
dividerY != old.dividerY;
|
|
}
|
|
|
|
|
|
/// Paints fill, border, shadow and the dotted perforation line
|
|
class _TicketBackgroundPainter extends CustomPainter {
|
|
final double cornerRadius;
|
|
final double notchRadius;
|
|
final double dividerY;
|
|
final Color borderColor;
|
|
final Color shadowColor;
|
|
|
|
_TicketBackgroundPainter({
|
|
required this.cornerRadius,
|
|
required this.notchRadius,
|
|
required this.dividerY,
|
|
required this.borderColor,
|
|
required this.shadowColor,
|
|
});
|
|
|
|
Path _ticketPath(Size size) {
|
|
final clipper = _TicketClipper(
|
|
cornerRadius: cornerRadius,
|
|
notchRadius: notchRadius,
|
|
dividerY: dividerY,
|
|
);
|
|
return clipper.getClip(size);
|
|
}
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final path = _ticketPath(size);
|
|
|
|
// Realistic layered shadow
|
|
canvas.save();
|
|
canvas.translate(0, 2); // tiny downward offset for depth
|
|
final shadowPaint = Paint()
|
|
..color = Colors.black.withOpacity(0.10)
|
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6);
|
|
canvas.drawPath(path, shadowPaint);
|
|
canvas.restore();
|
|
|
|
// Subtle ambient shadow (light spread around)
|
|
final ambientShadowPaint = Paint()
|
|
..color = Colors.black.withOpacity(0.04)
|
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12);
|
|
canvas.drawPath(path, ambientShadowPaint);
|
|
|
|
// Fill background
|
|
final fillPaint = Paint()
|
|
..style = PaintingStyle.fill
|
|
..color = const Color(0xffFFFBFB);
|
|
canvas.drawPath(path, fillPaint);
|
|
|
|
// Border stroke
|
|
final strokePaint = Paint()
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 0.8
|
|
..color = const Color(0xffE5E5E5);
|
|
canvas.drawPath(path, strokePaint);
|
|
|
|
// 🔹 Dotted perforation line
|
|
final dashPaint = Paint()
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 1
|
|
..color = const Color(0xff787878);
|
|
|
|
const double dashWidth = 4;
|
|
const double dashSpace = 4;
|
|
double startX = 12;
|
|
final double endX = size.width - 12;
|
|
|
|
while (startX < endX) {
|
|
final double currentEnd = (startX + dashWidth).clamp(0, endX);
|
|
canvas.drawLine(
|
|
Offset(startX, dividerY),
|
|
Offset(currentEnd, dividerY),
|
|
dashPaint,
|
|
);
|
|
startX += dashWidth + dashSpace;
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _TicketBackgroundPainter oldDelegate) {
|
|
return cornerRadius != oldDelegate.cornerRadius ||
|
|
notchRadius != oldDelegate.notchRadius ||
|
|
dividerY != oldDelegate.dividerY ||
|
|
borderColor != oldDelegate.borderColor ||
|
|
shadowColor != oldDelegate.shadowColor;
|
|
}
|
|
}
|