test(ui): small-phone overflow guard; fix Home clipping on small screens
Adds test/ui/small_screen_overflow_test.dart: pumps the full screens at a 320x568 (iPhone-SE-class) viewport in the longest-text locales (es/pt/ast); a RenderFlex overflow fails the test, so a clipped title or save button is caught. It found — and this fixes — a real bug: the Home menu (a fixed, non-scrolling Center>Column) clipped ~80px on small phones in every locale. Home is now wrapped in LayoutBuilder + SingleChildScrollView + ConstrainedBox(minHeight: maxHeight): centered when it fits, scrolls when it doesn't, never clipped. Also makes the test harness faithful: wrapScreen/wrapDetail now map the locale through materialLocaleFor (ast->es) exactly like the app, so Asturian screens don't throw on MaterialLocalizations (AppBar tooltips) — that had masqueraded as an overflow. Scope notes in the test: full screens only (modal bottom sheets get an unbounded height in widget tests -> false huge overflow; they already scroll their body), and the variety-detail screen is skipped (its ~2px hit is on a DISPOSED/DEFUNCT RenderFlex — a transient during the cubit rebuild, not the stable layout).
This commit is contained in:
parent
caf9d2b96e
commit
2c33172ba4
3 changed files with 133 additions and 6 deletions
|
|
@ -40,10 +40,18 @@ class HomeScreen extends StatelessWidget {
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
const Positioned.fill(child: _SeedWatermark()),
|
const Positioned.fill(child: _SeedWatermark()),
|
||||||
Center(
|
// Scroll when the menu doesn't fit a short screen (small phones), so
|
||||||
child: ConstrainedBox(
|
// the market card / buttons are never clipped; stays centered when it
|
||||||
constraints: const BoxConstraints(maxWidth: 440),
|
// does fit.
|
||||||
child: Padding(
|
LayoutBuilder(
|
||||||
|
builder: (context, constraints) => SingleChildScrollView(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints:
|
||||||
|
BoxConstraints(minHeight: constraints.maxHeight),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 440),
|
||||||
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 32),
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 32),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|
@ -109,6 +117,9 @@ class HomeScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import 'package:tane/db/database.dart';
|
||||||
import 'package:tane/i18n/strings.g.dart';
|
import 'package:tane/i18n/strings.g.dart';
|
||||||
import 'package:tane/security/secret_store.dart';
|
import 'package:tane/security/secret_store.dart';
|
||||||
import 'package:tane/services/onboarding_store.dart';
|
import 'package:tane/services/onboarding_store.dart';
|
||||||
|
import 'package:tane/app.dart' show materialLocaleFor;
|
||||||
import 'package:tane/state/inventory_cubit.dart';
|
import 'package:tane/state/inventory_cubit.dart';
|
||||||
import 'package:tane/state/variety_detail_cubit.dart';
|
import 'package:tane/state/variety_detail_cubit.dart';
|
||||||
import 'package:tane/ui/variety_detail_screen.dart';
|
import 'package:tane/ui/variety_detail_screen.dart';
|
||||||
|
|
@ -69,7 +70,7 @@ Widget wrapScreen({
|
||||||
child: BlocProvider(
|
child: BlocProvider(
|
||||||
create: (_) => InventoryCubit(repository),
|
create: (_) => InventoryCubit(repository),
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
locale: locale.flutterLocale,
|
locale: materialLocaleFor(locale.flutterLocale),
|
||||||
supportedLocales: AppLocaleUtils.supportedLocales,
|
supportedLocales: AppLocaleUtils.supportedLocales,
|
||||||
localizationsDelegates: const [
|
localizationsDelegates: const [
|
||||||
GlobalMaterialLocalizations.delegate,
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
|
@ -103,7 +104,7 @@ Widget wrapDetail({
|
||||||
child: BlocProvider(
|
child: BlocProvider(
|
||||||
create: (_) => VarietyDetailCubit(repository, varietyId),
|
create: (_) => VarietyDetailCubit(repository, varietyId),
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
locale: locale.flutterLocale,
|
locale: materialLocaleFor(locale.flutterLocale),
|
||||||
supportedLocales: AppLocaleUtils.supportedLocales,
|
supportedLocales: AppLocaleUtils.supportedLocales,
|
||||||
localizationsDelegates: const [
|
localizationsDelegates: const [
|
||||||
GlobalMaterialLocalizations.delegate,
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
|
|
||||||
115
apps/app_seeds/test/ui/small_screen_overflow_test.dart
Normal file
115
apps/app_seeds/test/ui/small_screen_overflow_test.dart
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import 'package:commons_core/commons_core.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/db/enums.dart';
|
||||||
|
import 'package:tane/i18n/strings.g.dart';
|
||||||
|
import 'package:tane/ui/home_screen.dart';
|
||||||
|
import 'package:tane/ui/inventory_list_screen.dart';
|
||||||
|
import 'package:tane/ui/settings_screen.dart';
|
||||||
|
|
||||||
|
import '../support/test_support.dart';
|
||||||
|
|
||||||
|
/// Small-phone layout guard. A Flutter `RenderFlex overflowed` (a clipped title,
|
||||||
|
/// a save button pushed off a non-scrolling column, a Row too wide) is reported
|
||||||
|
/// as an error and FAILS the test — so pumping each screen at a tiny viewport,
|
||||||
|
/// in the languages whose text runs longest (es/pt/ast), catches exactly the
|
||||||
|
/// "the save button / title isn't fully visible on a small phone" reports.
|
||||||
|
///
|
||||||
|
/// FULL SCREENS ONLY. Modal bottom sheets (quick-add, the lot editor) are NOT
|
||||||
|
/// checked here: `showModalBottomSheet` gets an unbounded height in a widget
|
||||||
|
/// test, which fabricates a huge false overflow. Those sheets already wrap their
|
||||||
|
/// body in a `SingleChildScrollView`, so their save buttons stay reachable;
|
||||||
|
/// verify them on a real small device.
|
||||||
|
void main() {
|
||||||
|
// iPhone-SE-class logical viewport: narrow AND short, where clipping shows.
|
||||||
|
const small = Size(320, 568);
|
||||||
|
// The locales whose strings are longest — English rarely overflows first.
|
||||||
|
const longLocales = [AppLocale.es, AppLocale.pt, AppLocale.ast];
|
||||||
|
|
||||||
|
Future<void> pumpSmall(WidgetTester tester, Widget widget) async {
|
||||||
|
tester.view.physicalSize = small;
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
await tester.pumpWidget(widget);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final locale in longLocales) {
|
||||||
|
final tag = locale.languageCode;
|
||||||
|
|
||||||
|
testWidgets('home menu fits a small screen ($tag)', (tester) async {
|
||||||
|
final db = newTestDatabase();
|
||||||
|
addTearDown(db.close);
|
||||||
|
await pumpSmall(
|
||||||
|
tester,
|
||||||
|
wrapScreen(
|
||||||
|
repository: newTestRepository(db),
|
||||||
|
locale: locale,
|
||||||
|
child: const HomeScreen(marketEnabled: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await disposeTree(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('settings (language, backup, about) fits ($tag)', (tester) async {
|
||||||
|
final db = newTestDatabase();
|
||||||
|
addTearDown(db.close);
|
||||||
|
await pumpSmall(
|
||||||
|
tester,
|
||||||
|
wrapScreen(
|
||||||
|
repository: newTestRepository(db),
|
||||||
|
locale: locale,
|
||||||
|
child: const SettingsScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await disposeTree(tester);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('inventory (empty) fits a small screen', (tester) async {
|
||||||
|
final db = newTestDatabase();
|
||||||
|
addTearDown(db.close);
|
||||||
|
await pumpSmall(
|
||||||
|
tester,
|
||||||
|
wrapScreen(
|
||||||
|
repository: newTestRepository(db),
|
||||||
|
locale: AppLocale.es,
|
||||||
|
child: const InventoryListScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await disposeTree(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('inventory with a long-named seed fits a small screen',
|
||||||
|
(tester) async {
|
||||||
|
final db = newTestDatabase();
|
||||||
|
addTearDown(db.close);
|
||||||
|
final repo = newTestRepository(db);
|
||||||
|
final id = await repo.addQuickVariety(
|
||||||
|
label: 'Tomate rosa de Barbastro extraordinariamente larguísimo',
|
||||||
|
category: 'Solanáceas',
|
||||||
|
);
|
||||||
|
await repo.addLot(
|
||||||
|
varietyId: id,
|
||||||
|
harvestYear: 2024,
|
||||||
|
quantity: const Quantity(kind: QuantityKind.handful),
|
||||||
|
offerStatus: OfferStatus.shared,
|
||||||
|
);
|
||||||
|
await pumpSmall(
|
||||||
|
tester,
|
||||||
|
wrapScreen(
|
||||||
|
repository: repo,
|
||||||
|
locale: AppLocale.es,
|
||||||
|
child: const InventoryListScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await disposeTree(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
// NOTE: the variety-detail screen is deliberately NOT overflow-checked here.
|
||||||
|
// It reports a ~2px overflow on a RenderFlex that is already DISPOSED/DEFUNCT
|
||||||
|
// — a transient stale frame while its cubit's Drift stream rebuilds during
|
||||||
|
// pumpAndSettle, not the stable rendered layout. Guarding it would be flaky;
|
||||||
|
// its RTL/build coverage lives in rtl_smoke_test.dart.
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue