diff --git a/lib/features/thread/v1/view/thread_page.dart b/lib/features/thread/v1/view/thread_page.dart index d8227234..553f07fd 100644 --- a/lib/features/thread/v1/view/thread_page.dart +++ b/lib/features/thread/v1/view/thread_page.dart @@ -860,7 +860,9 @@ class _ThreadPageState extends State 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)), ); }, ), diff --git a/test/regression/test_173_thread_content_scale_test.dart b/test/regression/test_173_thread_content_scale_test.dart index 55da638e..c7b8add7 100644 --- a/test/regression/test_173_thread_content_scale_test.dart +++ b/test/regression/test_173_thread_content_scale_test.dart @@ -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'; @@ -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'; @@ -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 fetch( RequestOptions options, Stream? requestStream, Future? 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}) {} @@ -101,10 +109,10 @@ String _floor({required int pid, required int floor, required int uid, required
floor $floor text
'''; -String _threadPage(List floors) => +String _threadPage(List floors, {String title = 'Scaled title'}) => ''' -

Scaled title

+

$title

${floors.join('\n')}
'''; @@ -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)); @@ -133,16 +142,14 @@ void main() { ..registerSingleton(CookieProvider.buildEmpty()) ..registerFactory(CookieProvider.buildEmpty, instanceName: ServiceKeys.empty); await settings.init(); + adapter = _ThreadAdapter(_threadPage([_floor(pid: 1, floor: 1, uid: _alice.uid!, name: 'Alice')])); getIt ..registerSingleton( ImageCacheProvider(NetClientProvider.buildNoCookie(dio: Dio()..httpClientAdapter = _OfflineAdapter())), ) ..registerFactory( () => 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()); @@ -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), ), ), @@ -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 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(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(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(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.