Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions lib/screens/favorites_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import '../l10n/app_localizations.dart';
import '../l10n/localized_labels.dart';
import '../widgets/eta_badge.dart';
import '../widgets/local_data_gate.dart';
import '../widgets/route_name_badge.dart';
import 'favorite_groups_screen.dart';
import '../widgets/background_image_wrapper.dart';
import '../widgets/cat_state_card.dart';
Expand Down Expand Up @@ -646,12 +647,7 @@ class _FavoritesScreenState extends State<_FavoritesView>
child: Card(
child: ListTile(
contentPadding: const EdgeInsets.all(14),
leading: CircleAvatar(
child: Text(
favorite.routeName.characters.take(2).toString(),
style: const TextStyle(fontWeight: FontWeight.w700),
),
),
leading: RouteNameBadge(name: favorite.routeName),
title: Text(
favorite.routeName,
style: TextStyle(
Expand Down
16 changes: 2 additions & 14 deletions lib/screens/search_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import '../l10n/app_localizations.dart';
import '../l10n/localized_labels.dart';
import '../widgets/background_image_wrapper.dart';
import '../widgets/cat_state_card.dart';
import '../widgets/route_name_badge.dart';
import '../widgets/route_search_keypad.dart';
import '../widgets/transit_station_name.dart';
import 'route_detail_navigation.dart';
Expand Down Expand Up @@ -1326,20 +1327,7 @@ class _SearchScreenState extends State<SearchScreen> {
final routeName = item.route.transitName.displayForLocale(locale);
final matchedStop = item.stopSearch?.matchedStop;
return ListTile(
leading: CircleAvatar(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
item.route.routeName.trim().isEmpty
? '?'
: item.route.routeName.characters.take(4).toString(),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
),
),
leading: RouteNameBadge(name: item.route.routeName),
title: matchedStop != null
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
Expand Down
106 changes: 106 additions & 0 deletions lib/widgets/route_name_badge.dart
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,

Copy link
Copy Markdown
Collaborator

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: FittedBox also cancels the system text scale inside the badge — at textScaler 2.0 the text is scaled back down to fit 52 px. That's acceptable here because of ExcludeSemantics and the duplicate title, just flagging it so it isn't mistaken for a11y-safe.

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 = {'(', '(', '[', '【', '〔'};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ _openingBrackets covers openers only, so nothing stops a split immediately before a closing bracket. Hand-tracing the current code on real data:

  • 0右 臺南火車站(成功路B)→臺南火車站(成功路B) — at index 13 the B→) transition counts as a code/non-code switch and sits 1 character from the middle, so it wins over the ( candidate at index 8. Result: 0右 臺南火車站(成功路B / )→臺南火車站(成功路B), i.e. line 2 opens with a stray ). Same for 0左 …(成功路A)→….
  • 32 of 1181 production route names end line 1 with an unclosed bracket, e.g. T501(每週二、四、六 發車) → T501(每週二 / 四、六 發車).

Skipping the candidate when current is a closer () ) ] 】 〕) would let these fall through to the opener instead. Worth a test case next to the 12(延駛飛機路) one.

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];
}
7 changes: 5 additions & 2 deletions test/favorites_reorder_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,11 @@ void main() {
expect(find.byType(ReorderableListView), findsOneWidget);
expect(_routeNamesIn(controller, 'Routes'), ['A路線', 'B路線', 'C路線']);

final start = tester.getCenter(find.text('A路線'));
final delta = tester.getCenter(find.text('B路線')).dy - start.dy;
// The name shows on both the badge and the title, so each tile matches
// once per text; every match is the same tile.
Finder tile(String name) => find.widgetWithText(ListTile, name).first;
final start = tester.getCenter(tile('A路線'));
final delta = tester.getCenter(tile('B路線')).dy - start.dy;

final gesture = await tester.startGesture(start);
// Hold past the long-press threshold so the delayed drag recognizer wins.
Expand Down
60 changes: 60 additions & 0 deletions test/route_name_badge_test.dart
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);
});
}
Loading