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
4 changes: 3 additions & 1 deletion lib/features/thread/v1/view/thread_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -860,7 +860,9 @@ class _ThreadPageState extends State<ThreadPage> with SingleTickerProviderStateM
],
],
),
body: _buildBody(context, state),
// Read the body's MediaQuery after Scaffold has consumed the app bar's top inset.
// The content's text-scale override must not restore the outer status-bar padding.
body: Builder(builder: (bodyContext) => _buildBody(bodyContext, state)),
);
},
),
Expand Down
157 changes: 141 additions & 16 deletions test/regression/test_173_thread_content_scale_test.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import 'dart:async';
import 'dart:typed_data';

import 'package:dio/dio.dart';
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
Expand All @@ -20,6 +21,7 @@ import 'package:tsdm_client/features/notification/repository/notification_info_r
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/thread/v1/view/thread_page.dart';
import 'package:tsdm_client/features/thread/v1/widgets/post_list.dart';
import 'package:tsdm_client/i18n/strings.g.dart';
import 'package:tsdm_client/instance.dart';
import 'package:tsdm_client/shared/models/models.dart';
Expand Down Expand Up @@ -73,20 +75,26 @@ final class _OfflineAdapter implements HttpClientAdapter {
final class _ThreadAdapter implements HttpClientAdapter {
_ThreadAdapter(this.page);

final String page;
String page;

int requests = 0;

@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async => ResponseBody.fromString(
page,
200,
headers: {
Headers.contentTypeHeader: ['text/html; charset=utf-8'],
},
);
) async {
expect(options.method, 'GET', reason: 'layout tests must never perform a forum write');
requests++;
return ResponseBody.fromString(
page,
200,
headers: {
Headers.contentTypeHeader: ['text/html; charset=utf-8'],
},
);
}

@override
void close({bool force = false}) {}
Expand All @@ -101,10 +109,10 @@ String _floor({required int pid, required int floor, required int uid, required
<div class="pct"><div class="pcb"><div class="t_f" id="postmessage_$pid">floor $floor text</div></div></div></td>
</tr></tbody></table></div>''';

String _threadPage(List<String> floors) =>
String _threadPage(List<String> floors, {String title = 'Scaled title'}) =>
'''
<html><head><link rel="canonical" href="forum.php?mod=viewthread&tid=1264975" /></head><body>
<div id="postlist"><h1 class="ts"><span id="thread_subject">Scaled title</span></h1><div class="bm">
<div id="postlist"><h1 class="ts"><span id="thread_subject">$title</span></h1><div class="bm">
${floors.join('\n')}
</div></div>
</body></html>''';
Expand All @@ -116,6 +124,7 @@ void main() {
late SettingsBloc settingsBloc;
late UserBlockRepository blocks;
late AuthenticationRepository auth;
late _ThreadAdapter adapter;

setUpAll(() async {
talker = TalkerFlutter.init(settings: TalkerSettings(enabled: false));
Expand All @@ -133,16 +142,14 @@ void main() {
..registerSingleton<CookieProvider>(CookieProvider.buildEmpty())
..registerFactory<CookieProvider>(CookieProvider.buildEmpty, instanceName: ServiceKeys.empty);
await settings.init();
adapter = _ThreadAdapter(_threadPage([_floor(pid: 1, floor: 1, uid: _alice.uid!, name: 'Alice')]));
getIt
..registerSingleton<ImageCacheProvider>(
ImageCacheProvider(NetClientProvider.buildNoCookie(dio: Dio()..httpClientAdapter = _OfflineAdapter())),
)
..registerFactory<NetClientProvider>(
() => NetClientProvider.build(
dio: Dio(BaseOptions(baseUrl: baseUrl))
..httpClientAdapter = _ThreadAdapter(
_threadPage([_floor(pid: 1, floor: 1, uid: _alice.uid!, name: 'Alice')]),
),
dio: Dio(BaseOptions(baseUrl: baseUrl))..httpClientAdapter = adapter,
),
);
settingsBloc = SettingsBloc(settingsRepository: settings, fragmentsRepository: FragmentsRepository());
Expand Down Expand Up @@ -203,7 +210,7 @@ void main() {
],
// Like the app, the global scale is a linear scaler above the app (`lib/app.dart`).
child: MediaQuery(
data: MediaQueryData(textScaler: TextScaler.linear(globalScale)),
data: MediaQueryData.fromView(tester.view).copyWith(textScaler: TextScaler.linear(globalScale)),
child: MaterialApp.router(routerConfig: router, scaffoldMessengerKey: snackbarKey),
),
),
Expand Down Expand Up @@ -258,6 +265,124 @@ void main() {
expect(scaleAt(tester, floorText()), threadContentMaxTextScale, reason: '1.5 * 2 exceeds the cap');
expect(scaleAt(tester, find.byType(ReplyBar)), 1.5);
});

/// Real view metrics, including the status bar that Scaffold removes for its body.
void viewport(WidgetTester tester, Size size, FakeViewPadding padding) {
tester.view
..devicePixelRatio = 1
..physicalSize = size
..viewPadding = padding
..padding = padding;
}

/// The title starts directly below the app bar; every line is visible or reachable by scrolling.
Future<void> expectTitleVisible(WidgetTester tester, String title) async {
final list = find.byType(PostList);
final text = find.descendant(of: list, matching: find.text(title));
expect(text, findsOneWidget);
final titleRect = tester.getRect(text);
final appBarRect = tester.getRect(find.byType(AppBar));
final view = find.descendant(of: list, matching: find.byType(Viewport));
final visibleRect = tester.getRect(view.first);
expect(visibleRect.top, closeTo(appBarRect.bottom, 0.01), reason: 'no second status-bar inset in the body');
expect(titleRect.top - appBarRect.bottom, closeTo(8, 0.01), reason: 'only the normal title margin remains');
final paragraph = tester.renderObject<RenderParagraph>(text);
final boxes = paragraph.getBoxesForSelection(TextSelection(baseOffset: 0, extentOffset: title.length));
expect(boxes, isNotEmpty);
for (final box in boxes) {
final line = box.toRect().shift(paragraph.localToGlobal(Offset.zero));
expect(line.top, greaterThanOrEqualTo(visibleRect.top));
expect(line.bottom, lessThanOrEqualTo(titleRect.bottom + 0.01));
expect(line.left, greaterThanOrEqualTo(visibleRect.left));
expect(line.right, lessThanOrEqualTo(visibleRect.right));
}
final controller = tester.widget<PostList>(list).scrollController;
expect(controller.offset, closeTo(0, 0.01));
// A large multi-line title may legitimately exceed a short landscape viewport. Its last line must be
// reachable by scrolling, not clipped by a fixed-height title container.
if (titleRect.bottom > visibleRect.bottom) {
controller.jumpTo(titleRect.bottom - visibleRect.bottom);
await settle(tester);
final lastLine = boxes.last.toRect().shift(paragraph.localToGlobal(Offset.zero));
expect(lastLine.top, greaterThanOrEqualTo(visibleRect.top));
expect(lastLine.bottom, lessThanOrEqualTo(visibleRect.bottom + 0.01));
controller.jumpTo(0);
await settle(tester);
}
expect(tester.takeException(), isNull);
}

const shortTitle = '老人回归';
const longTitle = '老人回归:一起聊聊最近喜欢的动画与论坛生活';
final layouts = [
('small phone', TargetPlatform.android, const Size(360, 780), const FakeViewPadding(top: 24, bottom: 24)),
('reported phone', TargetPlatform.android, const Size(384, 792), const FakeViewPadding(top: 40, bottom: 24)),
('reported tablet', TargetPlatform.android, const Size(533, 853), const FakeViewPadding(top: 24, bottom: 24)),
(
'landscape left cutout',
TargetPlatform.android,
const Size(792, 368),
const FakeViewPadding(left: 40, bottom: 24),
),
(
'landscape right cutout',
TargetPlatform.android,
const Size(792, 368),
const FakeViewPadding(right: 40, bottom: 24),
),
('iOS', TargetPlatform.iOS, const Size(390, 844), const FakeViewPadding(top: 47, bottom: 34)),
('desktop', TargetPlatform.windows, const Size(1440, 900), FakeViewPadding.zero),
];
for (final (name, platform, size, padding) in layouts) {
for (final large in [false, true]) {
testWidgets('initial thread title: $name, large text=$large', (tester) async {
viewport(tester, size, padding);
addTearDown(tester.view.reset);
final title = large ? longTitle : shortTitle;
adapter.page = _threadPage([
for (var i = 1; i <= 10; i++) _floor(pid: i, floor: i, uid: _alice.uid!, name: 'Alice'),
], title: title);
if (large) {
await settings.setValue<double>(SettingsKeys.threadContentScale, 2);
await settings.setValue(SettingsKeys.textScaleFactor, 1.5);
}
await pump(tester, globalScale: large ? 1.5 : 1);
await expectTitleVisible(tester, title);
expect(adapter.requests, 1, reason: 'first open only, no pull to refresh');
if (large && size.width < 600) {
final text = find.descendant(of: find.byType(PostList), matching: find.text(title));
expect(tester.getSize(text).height, greaterThan(60), reason: 'a real multi-line large title');
}
}, variant: TargetPlatformVariant({platform}));
}
}

testWidgets('rotation keeps the title visible and consumes only the current safe area', (tester) async {
viewport(tester, const Size(384, 792), const FakeViewPadding(top: 40, bottom: 24));
addTearDown(tester.view.reset);
await pump(tester);
await expectTitleVisible(tester, 'Scaled title');
viewport(tester, const Size(792, 368), const FakeViewPadding(left: 40, bottom: 24));
await settle(tester);
await expectTitleVisible(tester, 'Scaled title');
viewport(tester, const Size(384, 792), const FakeViewPadding(top: 40, bottom: 24));
await settle(tester);
await expectTitleVisible(tester, 'Scaled title');
});

testWidgets('pull to refresh returns the title to the same inset-free position', (tester) async {
viewport(tester, const Size(384, 792), const FakeViewPadding(top: 40, bottom: 24));
addTearDown(tester.view.reset);
await pump(tester);
await expectTitleVisible(tester, 'Scaled title');
final before = adapter.requests;
await tester.drag(find.byType(PostList), const Offset(0, 300));
for (var i = 0; i < 20; i++) {
await settle(tester);
}
expect(adapter.requests, greaterThan(before), reason: 'the actual refresh callback reloaded the page');
await expectTitleVisible(tester, 'Scaled title');
});
}

/// Let database work and futures finish outside the fake zone, then build the frames it caused.
Expand Down
Loading