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: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions doc/spec-x5-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 項在修正前失敗。

44 changes: 37 additions & 7 deletions lib/features/homepage/widgets/home_dashboard.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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),
],
),
],
);
},
)
Expand Down Expand Up @@ -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,
);
}
Expand Down Expand Up @@ -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(
Expand Down
116 changes: 82 additions & 34 deletions lib/features/settings/view/settings_page.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -140,43 +148,83 @@ class _SettingsPageState extends State<SettingsPage> 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(<String>[tr.themeMode.system, tr.themeMode.light, tr.themeMode.dark][themeModeIndex]),
trailing: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: SegmentedButton<int>(
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<ThemeCubit>().setThemeModeIndex(themeIndex);
context.read<SettingsBloc>().add(SettingsValueChanged(SettingsKeys.themeMode, themeIndex));
},
final title = tr.themeMode.title;
final subtitle = <String>[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<int>(
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<ThemeCubit>().setThemeModeIndex(themeIndex);
context.read<SettingsBloc>().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) {
Expand Down
5 changes: 3 additions & 2 deletions lib/features/settings/widgets/support_development_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ class _SupportDevelopmentDialogState extends State<SupportDevelopmentDialog> 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,
Expand All @@ -124,7 +125,7 @@ class _SupportDevelopmentDialogState extends State<SupportDevelopmentDialog> 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),
Expand Down
20 changes: 18 additions & 2 deletions lib/widgets/app_surface.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand All @@ -764,7 +780,7 @@ class AppDialogTitle extends StatelessWidget {
foregroundColor: error ? colorScheme.onErrorContainer : null,
),
sizedBoxW12H12,
Expanded(child: Text(title)),
Expanded(child: text),
],
);
}
Expand Down
Loading
Loading