Repository navigation
fix(favorites): show the whole route name in the route badge #107
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| import 'package:flutter/material.dart'; | ||
|
|
||
| /// A route's full name in the rounded square `EtaBadge` uses, so route and | ||
| /// station cards line up in mixed favourite groups. | ||
| /// | ||
| /// The whole name is always shown; long names break onto a second line (see | ||
| /// [routeBadgeLines]) and shrink to fit rather than being cut short. | ||
| class RouteNameBadge extends StatelessWidget { | ||
| const RouteNameBadge({required this.name, this.size = 52, super.key}); | ||
|
|
||
| final String name; | ||
| final double size; | ||
|
|
||
| @override | ||
| Widget build(BuildContext context) { | ||
| final colorScheme = Theme.of(context).colorScheme; | ||
| final lines = routeBadgeLines(name); | ||
| // The card title already reads the route name out. | ||
| return ExcludeSemantics( | ||
| child: Container( | ||
| width: size, | ||
| height: size, | ||
| padding: EdgeInsets.all(size * 0.1), | ||
| alignment: Alignment.center, | ||
| decoration: BoxDecoration( | ||
| color: colorScheme.primaryContainer, | ||
| borderRadius: BorderRadius.circular(size * 0.31), | ||
| ), | ||
| child: FittedBox( | ||
| fit: BoxFit.scaleDown, | ||
| child: Text( | ||
| lines.isEmpty ? '?' : lines.join('\n'), | ||
| textAlign: TextAlign.center, | ||
| style: TextStyle( | ||
| color: colorScheme.onPrimaryContainer, | ||
| fontWeight: FontWeight.w800, | ||
| fontSize: size * 0.33, | ||
| height: 1.1, | ||
| ), | ||
| ), | ||
| ), | ||
| ), | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| const _lineSeparators = {' ', '-', '-', '–', '—', '/', '/', '、', '·'}; | ||
| const _openingBrackets = {'(', '(', '[', '【', '〔'}; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Skipping the candidate when |
||
| final _codeCharacter = RegExp(r'^[0-9A-Za-z]$'); | ||
|
|
||
| /// The one or two lines a [RouteNameBadge] shows for [name]. | ||
| /// | ||
| /// Up to four characters stay on one line. Longer names break at the | ||
| /// candidate nearest the middle: a separator, which is dropped ("19路 大灣→安平" | ||
| /// → 19路 / 大灣→安平); an opening bracket, which starts the second line; or | ||
| /// a switch between ASCII letters/digits and other text, which is where route | ||
| /// numbers meet their wording ("152區2" → 152 / 區2, "市民小巴4延" → 市民小巴 / | ||
| /// 4延). Pure codes such as "7327D" stay whole; other names with no candidate | ||
| /// are halved. | ||
| List<String> routeBadgeLines(String name) { | ||
| final characters = name.trim().characters.toList(growable: false); | ||
| if (characters.isEmpty) { | ||
| return const []; | ||
| } | ||
| if (characters.length <= 4) { | ||
| return [characters.join()]; | ||
| } | ||
|
|
||
| bool isCode(String character) => _codeCharacter.hasMatch(character); | ||
| final middle = characters.length / 2; | ||
| int? firstLineEnd; | ||
| int? secondLineStart; | ||
| var bestDistance = double.infinity; | ||
| for (var index = 1; index < characters.length; index++) { | ||
| final previous = characters[index - 1]; | ||
| final current = characters[index]; | ||
| if (_lineSeparators.contains(previous)) { | ||
| continue; | ||
| } | ||
| final isSeparator = _lineSeparators.contains(current); | ||
| if (!isSeparator && | ||
| !_openingBrackets.contains(current) && | ||
| isCode(previous) == isCode(current)) { | ||
| continue; | ||
| } | ||
| final distance = (index - middle).abs(); | ||
| if (distance < bestDistance) { | ||
| bestDistance = distance; | ||
| firstLineEnd = index; | ||
| secondLineStart = isSeparator ? index + 1 : index; | ||
| } | ||
| } | ||
|
|
||
| if (firstLineEnd == null || secondLineStart == null) { | ||
| if (characters.every(isCode)) { | ||
| return [characters.join()]; | ||
| } | ||
| firstLineEnd = secondLineStart = (characters.length / 2).ceil(); | ||
| } | ||
| final firstLine = characters.sublist(0, firstLineEnd).join().trim(); | ||
| final secondLine = characters.sublist(secondLineStart).join().trim(); | ||
| if (firstLine.isEmpty || secondLine.isEmpty) { | ||
| return [characters.join()]; | ||
| } | ||
| return [firstLine, secondLine]; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import 'package:flutter/material.dart'; | ||
| import 'package:flutter_test/flutter_test.dart'; | ||
| import 'package:taiwanbus_flutter/widgets/route_name_badge.dart'; | ||
|
|
||
| void main() { | ||
| group('routeBadgeLines', () { | ||
| test('keeps short names whole', () { | ||
| expect(routeBadgeLines('131'), ['131']); | ||
| expect(routeBadgeLines('綠3'), ['綠3']); | ||
| expect(routeBadgeLines('1661'), ['1661']); | ||
| }); | ||
|
|
||
| test('breaks where the route number meets its wording', () { | ||
| expect(routeBadgeLines('152區2'), ['152', '區2']); | ||
| expect(routeBadgeLines('黃17末班'), ['黃17', '末班']); | ||
| expect(routeBadgeLines('藍20不經三總'), ['藍20', '不經三總']); | ||
| expect(routeBadgeLines('200跳蛙公車'), ['200', '跳蛙公車']); | ||
| expect(routeBadgeLines('市民小巴4延'), ['市民小巴', '4延']); | ||
| expect(routeBadgeLines('幸福霧臺1路'), ['幸福霧臺', '1路']); | ||
| }); | ||
|
|
||
| test('drops separators and keeps brackets on the second line', () { | ||
| expect(routeBadgeLines('19路 大灣→安平'), ['19路', '大灣→安平']); | ||
| expect(routeBadgeLines('貓空左線-動物園線'), ['貓空左線', '動物園線']); | ||
| expect(routeBadgeLines('F931-1630'), ['F931', '1630']); | ||
| expect(routeBadgeLines('12(延駛飛機路)'), ['12', '(延駛飛機路)']); | ||
| }); | ||
|
|
||
| test('keeps pure codes whole and halves other unbroken names', () { | ||
| expect(routeBadgeLines('7327D'), ['7327D']); | ||
| expect(routeBadgeLines('NWT16492'), ['NWT16492']); | ||
| expect(routeBadgeLines('民權幹線去程半'), ['民權幹線', '去程半']); | ||
| }); | ||
|
|
||
| test('has nothing to show for a blank name', () { | ||
| expect(routeBadgeLines(' '), isEmpty); | ||
| }); | ||
| }); | ||
|
|
||
| Widget buildBadge(String name) => MaterialApp( | ||
| home: Center(child: RouteNameBadge(name: name)), | ||
| ); | ||
|
|
||
| testWidgets('shows the whole route name, not its first characters', ( | ||
| tester, | ||
| ) async { | ||
| await tester.pumpWidget(buildBadge('131')); | ||
| expect(find.text('131'), findsOneWidget); | ||
| expect(tester.getSize(find.byType(RouteNameBadge)), const Size(52, 52)); | ||
|
|
||
| await tester.pumpWidget(buildBadge('市民小巴4延')); | ||
| expect(find.text('市民小巴\n4延'), findsOneWidget); | ||
| expect(tester.getSize(find.byType(RouteNameBadge)), const Size(52, 52)); | ||
| }); | ||
|
|
||
| testWidgets('falls back to a placeholder for a blank name', (tester) async { | ||
| await tester.pumpWidget(buildBadge('')); | ||
| expect(find.text('?'), findsOneWidget); | ||
| }); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FittedBox(scaleDown)inside a fixed 52 px box has no lower bound. Measured against real data (52 px box, 0.1 padding → 41.6 px usable, base font 17.16 px): 148 of 1181 production route names land under ~5 px effective font, and the worst cases are unreadable —19 繞復國墩峰上承德路回不繞料羅新頭西村一≈ 2.2 px,9路 9人小巴 東溪埔寮福安宮→臺南轉運站(繞公親里)≈ 2.3 px.It is technically "the whole name", but at 2 px it reads as a smudge, and the tile title already shows the name, so the badge contributes nothing at that size. A minimum font size (then
maxLines: 2+ ellipsis, or drop the badge back to a short form) would keep it legible.Separately:
FittedBoxalso cancels the system text scale inside the badge — attextScaler2.0 the text is scaled back down to fit 52 px. That's acceptable here because ofExcludeSemanticsand the duplicate title, just flagging it so it isn't mistaken for a11y-safe.