From 2f378f7238b63ddd95eaac25d0715906c856b540 Mon Sep 17 00:00:00 2001 From: Levi Date: Tue, 3 Nov 2026 22:18:24 +0800 Subject: [PATCH] fix(ui): respect mobile safe areas in tablet layouts --- .../lib/core/window/app_window_frame.dart | 11 +++++ .../home/presentation/home_page_test.dart | 49 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/ui/flutter/lib/core/window/app_window_frame.dart b/ui/flutter/lib/core/window/app_window_frame.dart index 516e03c6..d254cf29 100644 --- a/ui/flutter/lib/core/window/app_window_frame.dart +++ b/ui/flutter/lib/core/window/app_window_frame.dart @@ -1,7 +1,9 @@ +import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform; import 'package:flutter/material.dart' show Colors; import 'package:flutter/widgets.dart'; import 'package:window_manager/window_manager.dart'; +import '../../shared/theme/app_palette.dart'; import '../../shared/widgets/window/desktop_window_header.dart'; import 'app_window_chrome.dart'; @@ -16,6 +18,15 @@ class AppWindowFrame extends StatelessWidget { Widget build(BuildContext context) { Widget framedChild = child; + // System insets apply to mobile devices at every responsive breakpoint. + // SafeArea also consumes the padding so page-level SafeAreas do not add it twice. + if (defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS) { + framedChild = ColoredBox( + color: AppPalette.of(context).bg, + child: SafeArea(child: framedChild), + ); + } + if (AppWindowChrome.usesCustomChrome) { framedChild = Stack( children: [ diff --git a/ui/flutter/test/features/home/presentation/home_page_test.dart b/ui/flutter/test/features/home/presentation/home_page_test.dart index eeb2943c..1d01cfb9 100644 --- a/ui/flutter/test/features/home/presentation/home_page_test.dart +++ b/ui/flutter/test/features/home/presentation/home_page_test.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride; import 'package:flutter/gestures.dart' show kSecondaryMouseButton; import 'package:flutter/material.dart' show Icons; import 'package:flutter/widgets.dart'; @@ -8,6 +9,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:gopeed/api/model/task.dart' as api; import 'package:gopeed/features/home/presentation/pages/home_page.dart'; +import 'package:gopeed/features/home/presentation/widgets/primary_rail.dart'; +import 'package:gopeed/features/home/presentation/widgets/tasks_top_bar.dart'; +import 'package:gopeed/core/window/app_window_frame.dart'; import 'package:gopeed/features/tasks/application/tasks_controller.dart'; import 'package:gopeed/features/tasks/domain/task_record.dart'; import 'package:gopeed/features/tasks/presentation/pages/task_details_page.dart'; @@ -162,6 +166,7 @@ Future<({GroupTasks controller, GoRouter router})> showHome(WidgetTester tester, child: shad.ShadcnApp.router( theme: AppTheme.light(), materialTheme: AppTheme.materialLight(), + builder: (context, child) => AppWindowFrame(child: child!), routerConfig: router, ), ), @@ -171,6 +176,50 @@ Future<({GroupTasks controller, GoRouter router})> showHome(WidgetTester tester, } void main() { + for (final platform in [TargetPlatform.android, TargetPlatform.iOS]) { + testWidgets('$platform keeps headers inside system insets across rotation', (tester) async { + debugDefaultTargetPlatformOverride = platform; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + addTearDown(tester.view.resetPadding); + addTearDown(tester.view.resetViewPadding); + await showHome(tester, 390); + + for (final size in [const Size(390, 844), const Size(700, 1100), const Size(1100, 700), const Size(700, 1100)]) { + tester.view.physicalSize = size; + tester.view.padding = FakeViewPadding.zero; + tester.view.viewPadding = FakeViewPadding.zero; + await settle(tester); + final header = size.width < 720 + ? find + .ancestor( + of: find.byKey(const ValueKey('tasks-mobile-create-button')), + matching: find.byType(SafeArea), + ) + .first + : find.byType(TasksTopBar); + final originalHeaderPosition = tester.getTopLeft(header); + + const insets = FakeViewPadding(top: 24, left: 16, right: 12, bottom: 20); + tester.view.padding = insets; + tester.view.viewPadding = insets; + await settle(tester); + + expect(tester.getRect(find.byType(HomePage)), Rect.fromLTRB(16, 24, size.width - 12, size.height - 20)); + // Nested page SafeAreas must consume the inset only once. + expect(tester.getTopLeft(header), originalHeaderPosition + const Offset(16, 24)); + expect(MediaQuery.paddingOf(tester.element(header)), EdgeInsets.zero); + if (size.width >= 720) { + expect(tester.getTopLeft(find.byType(PrimaryRail)), const Offset(16, 24)); + } else { + await tester.tap(find.byKey(const ValueKey('tasks-mobile-batch-button'))); + await settle(tester); + } + expect(tester.takeException(), isNull); + } + debugDefaultTargetPlatformOverride = null; + }); + } + for (final width in [390.0, 700.0, 1100.0]) { testWidgets('group expands actual member tasks and keeps a multi-file BT as one card at $width', (tester) async { final app = await showHome(tester, width);