Ticket
A ticket: two halves on one card, bitten at the sides where they meet, with a line to tear along.
import 'package:flutter/widgets.dart';
import 'ui/theme.dart';
import 'ui/ticket.dart';
class TicketDemo extends StatelessWidget {
const TicketDemo({super.key});
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final muted = t.text.xs.copyWith(color: t.colors.mutedForeground);
final strong = t.text.xxl;
Widget fact(String label, String value) => Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: muted),
Text(value, style: t.text.sm.copyWith(fontWeight: FontWeight.w500)),
],
),
);
return SizedBox(
width: 340,
child: Ticket(
top: Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('SFO', style: strong),
Text('San Francisco', style: muted),
],
),
Expanded(
child: Text('11h 05m', textAlign: TextAlign.center, style: muted),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('NRT', style: strong),
Text('Tokyo', style: muted),
],
),
],
),
bottom: Row(
children: [
fact('Date', 'Thu, 12 Mar'),
fact('Seat', '14A'),
fact('Gate', 'B22'),
],
),
),
);
}
}
Add it
In your Flutter project:
ashlar add ticket
It writes theme.dart, palette.dart and ticket.dart into lib/ui, keeping any you already have.
API
Read from the source above: each parameter with its type, its default, and what it is for.
Ticketclass
A ticket: top and bottom on one card, with a bite out of each side where they meet and a dashed line between them, the way a ticket tears. The bites follow the join, so they stay level with it whatever the two hold and however large the text.
| Parameter | Type | Default | What it is for |
|---|---|---|---|
toprequired |
Widget |
Above the tear: usually what the ticket is for. | |
bottomrequired |
Widget |
Below it: usually the details, the seat or the code. | |
padding |
EdgeInsetsGeometry |
const EdgeInsets.all(20) |
Round each half's content. |
bite |
double |
10 |
How far each bite reaches into the card. |
tear |
bool |
true |
The dashed line across the join; false leaves only the bites. |
TicketHalfclass
One half of a Ticket: rounded on its outer side, bitten at its two corners on the join. The join itself has no border; the lower half draws tear across it, dashed.
| Parameter | Type | Default | What it is for |
|---|---|---|---|
upperrequired |
bool |
The half above the join, or the one below it. | |
radius | double | 16 | |
bite | double | 10 | |
tear |
Color? |
The dashed line's colour, or null for none. | |
side |
BorderSide |
BorderSide.none |
Source
What lands in your app. It is yours from then on: change it as you would your own code, and ashlar add
will not write over it.
lib/ui/ticket.dart 154 lines
import 'package:flutter/widgets.dart';
import 'theme.dart';
/// A ticket: [top] and [bottom] on one card, with a bite out of each side where they meet
/// and a dashed line between them, the way a ticket tears. The bites follow the join, so
/// they stay level with it whatever the two hold and however large the text.
///
/// ```dart
/// Ticket(
/// top: Text('SFO to NRT, 11h 05m'),
/// bottom: Text('Seat 14A, gate B22'),
/// )
/// ```
class Ticket extends StatelessWidget {
const Ticket({
super.key,
required this.top,
required this.bottom,
this.padding = const EdgeInsets.all(20),
this.bite = 10,
this.tear = true,
});
/// Above the tear: usually what the ticket is for.
final Widget top;
/// Below it: usually the details, the seat or the code.
final Widget bottom;
/// Round each half's content.
final EdgeInsetsGeometry padding;
/// How far each bite reaches into the card.
final double bite;
/// The dashed line across the join; false leaves only the bites.
final bool tear;
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
TicketHalf half(bool upper) => TicketHalf(
upper: upper,
radius: t.radiusXl,
bite: bite,
tear: tear && !upper ? c.border : null,
side: BorderSide(color: c.border),
);
return DefaultTextStyle.merge(
style: TextStyle(color: c.cardForeground),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DecoratedBox(
decoration: ShapeDecoration(color: c.card, shape: half(true)),
child: Padding(padding: padding, child: top),
),
DecoratedBox(
decoration: ShapeDecoration(color: c.card, shape: half(false)),
child: Padding(padding: padding, child: bottom),
),
],
),
);
}
}
/// One half of a [Ticket]: rounded on its outer side, bitten at its two corners on the
/// join. The join itself has no border; the lower half draws [tear] across it, dashed.
class TicketHalf extends ShapeBorder {
const TicketHalf({
required this.upper,
this.radius = 16,
this.bite = 10,
this.tear,
this.side = BorderSide.none,
});
/// The half above the join, or the one below it.
final bool upper;
final double radius;
final double bite;
/// The dashed line's colour, or null for none.
final Color? tear;
final BorderSide side;
@override
EdgeInsetsGeometry get dimensions => EdgeInsets.all(side.width);
/// The outline with the join left open, for the border to follow.
Path _edge(Rect r) {
final b = bite, rad = Radius.circular(radius), cut = Radius.circular(b);
if (upper) {
return Path()
..moveTo(r.left + b, r.bottom)
..arcToPoint(Offset(r.left, r.bottom - b), radius: cut, clockwise: false)
..lineTo(r.left, r.top + radius)
..arcToPoint(Offset(r.left + radius, r.top), radius: rad)
..lineTo(r.right - radius, r.top)
..arcToPoint(Offset(r.right, r.top + radius), radius: rad)
..lineTo(r.right, r.bottom - b)
..arcToPoint(Offset(r.right - b, r.bottom), radius: cut, clockwise: false);
}
return Path()
..moveTo(r.right - b, r.top)
..arcToPoint(Offset(r.right, r.top + b), radius: cut, clockwise: false)
..lineTo(r.right, r.bottom - radius)
..arcToPoint(Offset(r.right - radius, r.bottom), radius: rad)
..lineTo(r.left + radius, r.bottom)
..arcToPoint(Offset(r.left, r.bottom - radius), radius: rad)
..lineTo(r.left, r.top + b)
..arcToPoint(Offset(r.left + b, r.top), radius: cut, clockwise: false);
}
@override
Path getOuterPath(Rect rect, {TextDirection? textDirection}) =>
_edge(rect)..close();
@override
Path getInnerPath(Rect rect, {TextDirection? textDirection}) =>
_edge(rect.deflate(side.width))..close();
@override
void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) {
if (side.style != BorderStyle.none) {
canvas.drawPath(_edge(rect.deflate(side.width / 2)), side.toPaint());
}
if (tear case final color?) {
final paint = Paint()
..color = color
..strokeWidth = side.width == 0 ? 1 : side.width;
for (var x = rect.left + bite + 6; x < rect.right - bite - 6; x += 10) {
canvas.drawLine(
Offset(x, rect.top),
Offset((x + 5).clamp(x, rect.right - bite - 6), rect.top),
paint,
);
}
}
}
@override
ShapeBorder scale(double t) => TicketHalf(
upper: upper,
radius: radius * t,
bite: bite * t,
tear: tear,
side: side.scale(t),
);
}