From 8780d3d74a6d9aaed7b4788670891ee94df096e1 Mon Sep 17 00:00:00 2001 From: Carinoasd Date: Tue, 29 Sep 2026 22:39:12 +0800 Subject: [PATCH] fix: narrow phones keep the theme title, home entries and support title on one line Feedback on 1.29.1 from a 360dp phone: - The theme mode switch goes below the text when the title and the current mode do not fit beside it; the title was squeezed to one character per line. - Activities and medals share one row in both phone layouts of the greeting card; their labels shrink a little instead of wrapping. - The support dialog title, its GitHub button and the home support card title stay on one line (AppDialogTitle.singleLine). Refs #135 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++ doc/spec-x5-features.md | 8 ++ .../homepage/widgets/home_dashboard.dart | 44 +++++- lib/features/settings/view/settings_page.dart | 116 +++++++++++----- .../widgets/support_development_dialog.dart | 5 +- lib/widgets/app_surface.dart | 20 ++- .../regression/test_172_feedback111_test.dart | 130 ++++++++++++++++-- 7 files changed, 271 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 364a81e6..93baf2c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Changed + +- 底栏切换:点底栏切换首页/版块/设置等分页时改为水平滑动;分页状态保留,隐藏分页的动画会暂停,系统开启「减少动态效果」时直接切换。(#145) + +### Fixed + +- 窄屏手机(约 360dp 宽):设置「主题模式」的标题不再被挤成竖排,放不下时切换按钮自动移到文字下方;首页「活动总览」「勋章与称号」两个按钮改为同一行;「支援开发与功能许愿」弹窗标题与「到 GitHub 提出功能需求」按钮保持单行。 + ## [1.29.1] - 2026-09-29 ### Changed diff --git a/doc/spec-x5-features.md b/doc/spec-x5-features.md index bcee79a6..d4006265 100644 --- a/doc/spec-x5-features.md +++ b/doc/spec-x5-features.md @@ -1291,3 +1291,11 @@ B. 論壇提醒屏蔽規則 - 首頁/關於:「贊助與功能許願」改名「支援開發與功能許願」(三語)。 - 首頁桌面版稱號牌子:`homeGreetingBadgeWideWidth` 240 → 184。論壇的稱號圖只有 184×100(`img.tsdm39.com/img01/title/*.gif`,無 @2x),放大顯示必然模糊;改回原圖尺寸。手機端本來就 ≤184。 +## 43. 窄屏版面修正、底欄滑動(GitHub #145,2026-09-29) + +- 底欄滑動(#145,bbtu1):`StatefulShellRoute` 改用 `AnimatedBranchPageView`(PageView,關閉手勢),非當前分頁包 `TickerMode(enabled: false)`,`MediaQuery.disableAnimations` 時 `jumpToPage`。 +- 設定「主題模式」:切換鈕固定 156dp(compact 三段),`LayoutBuilder` 以 `TextPainter` 量標題與目前模式的寬度,放得下才放列尾,否則放文字下方。原因:720×1600(360dp)手機上列尾放不下,標題被擠成一字一行。 +- 首頁問候卡片:手機直向兩種排版(簽到/紅包並排或直疊)下,「活動總覽」「勳章與稱號」都改為一行各佔一半,標籤 `FittedBox` 縮字不換行。回報的手機(360dp、系統字級略大)落在直疊排版。 +- `AppDialogTitle(singleLine: true)`:標題單行、過長縮字;贊助彈窗與其 GitHub 按鈕、首頁贊助卡片標題都保持單行。 +- 驗證:test_172 第 4 組新增 320/360/384dp × 1/1.15/2 倍字級案例;主題模式與彈窗 6 項、首頁 1 項在修正前失敗。 + diff --git a/lib/features/homepage/widgets/home_dashboard.dart b/lib/features/homepage/widgets/home_dashboard.dart index 44b8f558..6ade7c5a 100644 --- a/lib/features/homepage/widgets/home_dashboard.dart +++ b/lib/features/homepage/widgets/home_dashboard.dart @@ -257,6 +257,8 @@ class HomeGreetingCard extends StatelessWidget { ); } if (constraints.maxWidth >= _dailyActionsRowWidth * textScale) { + // The two entries share the second row half and half: as a wrap they went below each other on + // a 360dp phone (feedback on 1.29.1). return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -268,14 +270,36 @@ class HomeGreetingCard extends StatelessWidget { Expanded(child: redPacket), ], ), - sizedBoxW12H12, - quickActions, + sizedBoxW8H8, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: activities), + sizedBoxW8H8, + Expanded(child: medals), + ], + ), ], ); } + // The two entries stay side by side here too, their labels shrink a little when needed: the + // phone of the report (360dp, larger system font) lands in this layout. return Column( crossAxisAlignment: CrossAxisAlignment.stretch, - children: [checkin, sizedBoxW8H8, redPacket, sizedBoxW12H12, quickActions], + children: [ + checkin, + sizedBoxW8H8, + redPacket, + sizedBoxW8H8, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: activities), + sizedBoxW8H8, + Expanded(child: medals), + ], + ), + ], ); }, ) @@ -359,7 +383,8 @@ class _QuickAction extends StatelessWidget { Widget build(BuildContext context) => OutlinedButton.icon( style: OutlinedButton.styleFrom(minimumSize: const Size(0, 44)), icon: Icon(icon), - label: Text(label), + // One line: in a half width button the label shrinks a little instead of wrapping. + label: FittedBox(fit: BoxFit.scaleDown, child: Text(label, maxLines: 1)), onPressed: onPressed, ); } @@ -460,9 +485,14 @@ class HomeSupportCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - context.t.aboutPage.supportDevelopment, - style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + FittedBox( + fit: BoxFit.scaleDown, + alignment: AlignmentDirectional.centerStart, + child: Text( + context.t.aboutPage.supportDevelopment, + maxLines: 1, + style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), ), sizedBoxW2H2, Text( diff --git a/lib/features/settings/view/settings_page.dart b/lib/features/settings/view/settings_page.dart index 37d2543e..ad4956eb 100644 --- a/lib/features/settings/view/settings_page.dart +++ b/lib/features/settings/view/settings_page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:math' as math; import 'package:collection/collection.dart'; import 'package:file_picker/file_picker.dart'; @@ -60,6 +61,13 @@ import 'package:tsdm_client/widgets/section_list_tile.dart'; import 'package:tsdm_client/widgets/section_switch_list_tile.dart'; import 'package:tsdm_client/widgets/shutdown.dart'; +/// Width of the theme mode switch: three compact icon segments. +const _themeSwitchWidth = 156.0; + +/// Width of a settings row that is not text: side paddings (16 + 16), leading icon with its gap (24 + 16) and the gap +/// before the trailing widget (16), plus a little slack. +const _themeTileChromeWidth = 96.0; + /// Window width from which the settings groups are laid out in two columns. const _settingsTwoColumnWidth = 1200.0; @@ -140,43 +148,83 @@ class _SettingsPageState extends State with WidgetsBindingObserver Widget _buildThemeModeTile(BuildContext context, int themeModeIndex) { final tr = context.t.settingsPage.appearanceSection; - // The switch sits at the end of the row, in one line with the title (feedback 111: below the text it left the - // right side of the row empty). Icons only: three labels do not fit a phone at a large text scale; each segment - // has a tooltip. - return SectionListTile( - leading: const Icon(Icons.contrast_outlined), - title: Text(tr.themeMode.title), - subtitle: Text([tr.themeMode.system, tr.themeMode.light, tr.themeMode.dark][themeModeIndex]), - trailing: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: SegmentedButton( - showSelectedIcon: false, - segments: [ - ButtonSegment( - value: ThemeMode.light.index, - icon: const Icon(Icons.light_mode_outlined), - tooltip: tr.themeMode.light, - ), - ButtonSegment( - value: ThemeMode.system.index, - icon: const Icon(Icons.auto_mode_outlined), - tooltip: tr.themeMode.system, - ), - ButtonSegment( - value: ThemeMode.dark.index, - icon: const Icon(Icons.dark_mode_outlined), - tooltip: tr.themeMode.dark, - ), - ], - selected: {themeModeIndex}, - onSelectionChanged: (selection) { - final themeIndex = selection.first; - context.read().setThemeModeIndex(themeIndex); - context.read().add(SettingsValueChanged(SettingsKeys.themeMode, themeIndex)); - }, + final title = tr.themeMode.title; + final subtitle = [tr.themeMode.system, tr.themeMode.light, tr.themeMode.dark][themeModeIndex]; + // Icons only: three labels do not fit a phone at a large text scale; each segment has a tooltip. Compact and of a + // fixed width, so the row can tell whether the title still fits beside it. + final themeSwitch = SizedBox( + width: _themeSwitchWidth, + child: SegmentedButton( + showSelectedIcon: false, + style: const ButtonStyle( + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), + segments: [ + ButtonSegment( + value: ThemeMode.light.index, + icon: const Icon(Icons.light_mode_outlined), + tooltip: tr.themeMode.light, + ), + ButtonSegment( + value: ThemeMode.system.index, + icon: const Icon(Icons.auto_mode_outlined), + tooltip: tr.themeMode.system, + ), + ButtonSegment( + value: ThemeMode.dark.index, + icon: const Icon(Icons.dark_mode_outlined), + tooltip: tr.themeMode.dark, + ), + ], + selected: {themeModeIndex}, + onSelectionChanged: (selection) { + final themeIndex = selection.first; + context.read().setThemeModeIndex(themeIndex); + context.read().add(SettingsValueChanged(SettingsKeys.themeMode, themeIndex)); + }, ), ); + // The switch sits at the end of the row, in one line with the title (feedback 111: below the text it left the + // right side of the row empty), but only while the title and the current mode still fit beside it: on a narrow + // phone the title was squeezed into one character per line (feedback on 1.29.1). Then it goes below the text. + return LayoutBuilder( + builder: (context, constraints) { + final textTheme = Theme.of(context).textTheme; + final textScaler = MediaQuery.textScalerOf(context); + final direction = Directionality.of(context); + double widthOf(String text, TextStyle? style) { + final painter = TextPainter( + text: TextSpan(text: text, style: style), + textScaler: textScaler, + textDirection: direction, + maxLines: 1, + )..layout(); + final width = painter.width; + painter.dispose(); + return width; + } + + final textWidth = math.max(widthOf(title, textTheme.bodyLarge), widthOf(subtitle, textTheme.bodyMedium)); + final beside = constraints.maxWidth - _themeTileChromeWidth - _themeSwitchWidth >= textWidth; + if (beside) { + return SectionListTile( + leading: const Icon(Icons.contrast_outlined), + title: Text(title, maxLines: 1), + subtitle: Text(subtitle, maxLines: 1), + trailing: themeSwitch, + ); + } + return SectionListTile( + leading: const Icon(Icons.contrast_outlined), + title: Text(title), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [Text(subtitle), sizedBoxW8H8, themeSwitch, sizedBoxW4H4], + ), + ); + }, + ); } Widget _buildAppearanceSection(BuildContext context, SettingsState state) { diff --git a/lib/features/settings/widgets/support_development_dialog.dart b/lib/features/settings/widgets/support_development_dialog.dart index d69e9326..1a432db3 100644 --- a/lib/features/settings/widgets/support_development_dialog.dart +++ b/lib/features/settings/widgets/support_development_dialog.dart @@ -100,7 +100,8 @@ class _SupportDevelopmentDialogState extends State wit // Same texts, order and actions as before; only grouped: the voluntary nature first, then the feature request // block, then the donation code on a white ground so it stays scannable in the dark theme. return AlertDialog( - title: AppDialogTitle(icon: Icons.volunteer_activism_outlined, title: tr.supportDevelopment), + // One line: the longer name wrapped to two lines on a narrow phone (feedback on 1.29.1). + title: AppDialogTitle(icon: Icons.volunteer_activism_outlined, title: tr.supportDevelopment, singleLine: true), scrollable: true, content: SizedBox( width: 360, @@ -124,7 +125,7 @@ class _SupportDevelopmentDialogState extends State wit onPressed: _openingRequest ? null : _openFeatureRequest, // No spinner while the browser opens: the disabled button is the pending state. icon: const Icon(Icons.open_in_new), - label: Text(tr.featureRequestAction), + label: FittedBox(fit: BoxFit.scaleDown, child: Text(tr.featureRequestAction, maxLines: 1)), ), if (_requestOpenFailed) ...[ const SizedBox(height: 8), diff --git a/lib/widgets/app_surface.dart b/lib/widgets/app_surface.dart index e8965513..dcd72652 100644 --- a/lib/widgets/app_surface.dart +++ b/lib/widgets/app_surface.dart @@ -741,7 +741,13 @@ class AppBottomActionBar extends StatelessWidget { /// [AlertDialog]. [error] uses the error container colors (destructive confirmations). class AppDialogTitle extends StatelessWidget { /// Constructor. - const AppDialogTitle({required this.icon, required this.title, this.error = false, super.key}); + const AppDialogTitle({ + required this.icon, + required this.title, + this.error = false, + this.singleLine = false, + super.key, + }); /// Icon of the tile. final IconData icon; @@ -752,9 +758,19 @@ class AppDialogTitle extends StatelessWidget { /// Use the error colors. final bool error; + /// Keep the title on one line, shrinking its text when the dialog is too narrow, instead of wrapping. + final bool singleLine; + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final text = singleLine + ? FittedBox( + fit: BoxFit.scaleDown, + alignment: AlignmentDirectional.centerStart, + child: Text(title, maxLines: 1), + ) + : Text(title); return Row( children: [ AppIconTile( @@ -764,7 +780,7 @@ class AppDialogTitle extends StatelessWidget { foregroundColor: error ? colorScheme.onErrorContainer : null, ), sizedBoxW12H12, - Expanded(child: Text(title)), + Expanded(child: text), ], ); } diff --git a/test/regression/test_172_feedback111_test.dart b/test/regression/test_172_feedback111_test.dart index 11fcb13b..819b4f6d 100644 --- a/test/regression/test_172_feedback111_test.dart +++ b/test/regression/test_172_feedback111_test.dart @@ -38,6 +38,7 @@ import 'package:tsdm_client/features/red_packet/widgets/daily_red_packet_button. import 'package:tsdm_client/features/settings/bloc/settings_bloc.dart'; import 'package:tsdm_client/features/settings/repositories/settings_repository.dart'; import 'package:tsdm_client/features/settings/view/settings_page.dart'; +import 'package:tsdm_client/features/settings/widgets/support_development_dialog.dart'; import 'package:tsdm_client/features/theme/cubit/theme_cubit.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/instance.dart'; @@ -545,9 +546,74 @@ void main() { expect(tester.takeException(), isNull); }); - for (final scale in [1.0, 2.0]) { - testWidgets('${scale}x text: the theme mode switch is in one line with its title', (tester) async { - tester.view.physicalSize = const Size(384, 792); + Future pumpSettings(WidgetTester tester, double width, double scale) async { + tester.view.physicalSize = Size(width, 792); + tester.view.devicePixelRatio = 1; + tester.platformDispatcher.textScaleFactorTestValue = scale; + addTearDown(() { + tester.view.reset(); + tester.platformDispatcher.clearTextScaleFactorTestValue(); + }); + await tester.pumpWidget( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: settingsBloc), + BlocProvider.value(value: theme), + ], + child: TranslationProvider(child: const MaterialApp(home: SettingsPage())), + ), + ); + await _settle(tester, rounds: 3); + } + + testWidgets('384dp phone: the theme mode switch is in one line with its title', (tester) async { + await pumpSettings(tester, 384, 1); + final title = tester.getRect(find.text(tr.settingsPage.appearanceSection.themeMode.title)); + final switcher = tester.getRect(find.byType(SegmentedButton)); + expect(switcher.left, greaterThan(title.right), reason: 'at the end of the row, not below the text'); + expect(switcher.top, lessThan(title.bottom + 8), reason: 'in the same row'); + expect(switcher.right, lessThanOrEqualTo(384), reason: 'inside the window'); + expect(tester.takeException(), isNull); + }); + + // Feedback on 1.29.1: on a 360dp phone the title was squeezed into one character per line. + for (final (width, scale) in [(320.0, 1.0), (360.0, 1.0), (384.0, 2.0), (360.0, 2.0)]) { + testWidgets('${width}dp, ${scale}x text: the theme mode title stays on one line, the switch in the window', ( + tester, + ) async { + await pumpSettings(tester, width, scale); + final titleFinder = find.text(tr.settingsPage.appearanceSection.themeMode.title); + final title = tester.getRect(titleFinder); + final lineHeight = tester.widget(titleFinder).style?.fontSize ?? 16; + expect(title.height, lessThan(lineHeight * scale * 2), reason: 'one line, not one character per line'); + final switcher = tester.getRect(find.byType(SegmentedButton)); + expect(switcher.right, lessThanOrEqualTo(width), reason: 'inside the window'); + expect( + switcher.left >= title.right || switcher.top >= title.bottom, + isTrue, + reason: 'beside the text when it fits, below it otherwise; never over it', + ); + expect(tester.takeException(), isNull); + }); + } + + // The phone of the report is 360dp wide with a slightly larger system font; the test font is narrower than the + // real one, so 320dp at 1.15x stands in for it. + for (final (width, scale) in [(360.0, 1.0), (320.0, 1.15)]) { + testWidgets('${width}dp, ${scale}x text: activities and medals share one row under check-in and red packet', ( + tester, + ) async { + final auth = _Auth(_alice); + final checkin = CheckinBloc( + checkinRepository: CheckinRepository(storageProvider: storage), + authenticationRepository: auth, + settingsRepository: settings, + ); + addTearDown(() async { + await checkin.close(); + await auth.close(); + }); + tester.view.physicalSize = Size(width, 800); tester.view.devicePixelRatio = 1; tester.platformDispatcher.textScaleFactorTestValue = scale; addTearDown(() { @@ -555,21 +621,55 @@ void main() { tester.platformDispatcher.clearTextScaleFactorTestValue(); }); await tester.pumpWidget( - MultiBlocProvider( - providers: [ - BlocProvider.value(value: settingsBloc), - BlocProvider.value(value: theme), - ], - child: TranslationProvider(child: const MaterialApp(home: SettingsPage())), + BlocProvider.value( + value: checkin, + child: TranslationProvider( + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + padding: const EdgeInsets.all(12), + child: HomeGreetingCard( + username: 'Alice', + uid: _alice.uid, + forumStatus: const ForumStatus.empty(), + dailyRedPacket: null, + formHash: 'XXXXXXXX', + compact: true, + ), + ), + ), + ), + ), ), ); await _settle(tester, rounds: 3); - final title = tester.getRect(find.text(tr.settingsPage.appearanceSection.themeMode.title)); - debugPrint('DBG switchers ${find.byType(SegmentedButton).evaluate().length}'); - final switcher = tester.getRect(find.byType(SegmentedButton)); - expect(switcher.left, greaterThan(title.right), reason: 'at the end of the row, not below the text'); - expect(switcher.top, lessThan(title.bottom + 8), reason: 'in the same row'); - expect(switcher.right, lessThanOrEqualTo(384), reason: 'inside the window'); + final packet = tester.getRect(find.text(tr.redPacket.daily.unavailable)); + final activities = tester.getRect(find.text(tr.activitiesPage.title)); + final medals = tester.getRect(find.text(tr.medalTitleHub.title)); + expect(activities.top, greaterThan(packet.bottom), reason: 'second row'); + expect(medals.center.dy, closeTo(activities.center.dy, 1), reason: 'the two entries on one row'); + expect(medals.right, lessThanOrEqualTo(width)); + expect(tester.takeException(), isNull); + }); + } + + for (final width in [320.0, 360.0]) { + testWidgets('${width}dp phone: the support dialog title and the GitHub button stay on one line', (tester) async { + tester.view.physicalSize = Size(width, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + TranslationProvider( + child: const MaterialApp(home: Scaffold(body: SupportDevelopmentDialog())), + ), + ); + await _settle(tester, rounds: 3); + final titleFinder = find.text(tr.aboutPage.supportDevelopment); + final title = tester.getRect(titleFinder); + final titleStyle = DefaultTextStyle.of(tester.element(titleFinder)).style; + expect(title.height, lessThan((titleStyle.fontSize ?? 24) * 2), reason: 'the title is one line'); + final action = tester.getRect(find.text(tr.aboutPage.featureRequestAction)); + expect(action.height, lessThan(32), reason: 'the GitHub button label is one line'); expect(tester.takeException(), isNull); }); }