diff --git a/lib/screens/favorites_screen.dart b/lib/screens/favorites_screen.dart index 7d19501..58b8984 100644 --- a/lib/screens/favorites_screen.dart +++ b/lib/screens/favorites_screen.dart @@ -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'; @@ -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( diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index c7198e2..89b2128 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -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'; @@ -1326,20 +1327,7 @@ class _SearchScreenState extends State { 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, diff --git a/lib/widgets/route_name_badge.dart b/lib/widgets/route_name_badge.dart new file mode 100644 index 0000000..5fd0552 --- /dev/null +++ b/lib/widgets/route_name_badge.dart @@ -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 = {'(', '(', '[', '【', '〔'}; +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 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]; +} diff --git a/test/favorites_reorder_test.dart b/test/favorites_reorder_test.dart index b7a318a..be9cc67 100644 --- a/test/favorites_reorder_test.dart +++ b/test/favorites_reorder_test.dart @@ -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. diff --git a/test/route_name_badge_test.dart b/test/route_name_badge_test.dart new file mode 100644 index 0000000..caa7fbf --- /dev/null +++ b/test/route_name_badge_test.dart @@ -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); + }); +}