feat: make selecting a user fancy
This commit is contained in:
@@ -28,20 +28,6 @@ class SelectedTabDetails extends StatelessWidget {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
tab.customerName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
Divider(),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: tab.items.isEmpty
|
||||
? Center(
|
||||
|
||||
+296
-83
@@ -12,6 +12,29 @@ import '../../utils/money.dart';
|
||||
import '../../utils/name_index.dart';
|
||||
import 'selected_tab_details.dart';
|
||||
|
||||
/// Which half of the panel gets most of the height.
|
||||
enum _PanelFocus { balanced, names, items }
|
||||
|
||||
/// Space below every tab row in the names list.
|
||||
const _tabRowGap = 8.0;
|
||||
|
||||
/// The search field's height plus the gap below it.
|
||||
const _searchHeight = 56.0;
|
||||
|
||||
const _panelAnimation = Duration(milliseconds: 250);
|
||||
const _panelCurve = Curves.easeOutCubic;
|
||||
|
||||
/// The selected tile sits on its items, so the corners where they meet are
|
||||
/// squared off.
|
||||
const _attachedTileRadius = BorderRadius.vertical(
|
||||
top: Radius.circular(14),
|
||||
bottom: Radius.circular(4),
|
||||
);
|
||||
const _attachedDetailsRadius = BorderRadius.vertical(
|
||||
top: Radius.circular(4),
|
||||
bottom: Radius.circular(16),
|
||||
);
|
||||
|
||||
class TabPanel extends StatefulWidget {
|
||||
final List<BarTab> tabs;
|
||||
final BarTab? selectedTab;
|
||||
@@ -50,6 +73,76 @@ class _TabPanelState extends State<TabPanel> {
|
||||
/// The letter under the finger while the index bar is pressed.
|
||||
String? _touchedLetter;
|
||||
|
||||
_PanelFocus _focus = _PanelFocus.balanced;
|
||||
|
||||
/// The names-list offset the selected tile was last lined up at.
|
||||
double? _attachedOffset;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// The row height is only known once the list is laid out
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
void _focusOn(_PanelFocus focus) {
|
||||
if (focus != _focus) setState(() => _focus = focus);
|
||||
}
|
||||
|
||||
/// Where the names list scrolls so the selected tab's tile sits right on
|
||||
/// top of its items. Null while browsing the names, when the tab is
|
||||
/// filtered out, or when its tile is too close to the top to get there.
|
||||
double? _attachOffset(List<BarTab> tabs, double viewportHeight) {
|
||||
final tab = widget.selectedTab;
|
||||
if (tab == null || _focus == _PanelFocus.names) return null;
|
||||
|
||||
final index = tabs.indexWhere((candidate) => candidate.id == tab.id);
|
||||
final extent = _tabExtent(tabs.length);
|
||||
if (index == -1 || extent == null) return null;
|
||||
|
||||
final offset = (index + 1) * extent - _tabRowGap - viewportHeight;
|
||||
return offset < 0 ? null : offset;
|
||||
}
|
||||
|
||||
void _lineUpSelectedTab(double? offset) {
|
||||
if (offset == _attachedOffset) return;
|
||||
|
||||
_attachedOffset = offset;
|
||||
if (offset == null) return;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!_tabsScrollController.hasClients) return;
|
||||
|
||||
// A list that is still shrinking can't scroll that far yet; the end of
|
||||
// the collapse animation finishes the job
|
||||
final position = _tabsScrollController.position;
|
||||
unawaited(
|
||||
_tabsScrollController.animateTo(
|
||||
min(offset, position.maxScrollExtent),
|
||||
duration: _panelAnimation,
|
||||
curve: _panelCurve,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _finishLineUp() {
|
||||
final offset = _attachedOffset;
|
||||
if (offset == null || !_tabsScrollController.hasClients) return;
|
||||
if ((_tabsScrollController.offset - offset).abs() < 0.5) return;
|
||||
|
||||
unawaited(
|
||||
_tabsScrollController.animateTo(
|
||||
offset,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: _panelCurve,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(TabPanel oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
@@ -59,6 +152,15 @@ class _TabPanelState extends State<TabPanel> {
|
||||
_searchController.clear();
|
||||
_searchQuery = '';
|
||||
}
|
||||
|
||||
// Adding products means working on the selected tab
|
||||
final tab = widget.selectedTab;
|
||||
final oldTab = oldWidget.selectedTab;
|
||||
if (tab != null &&
|
||||
tab.id == oldTab?.id &&
|
||||
tab.itemCount != oldTab?.itemCount) {
|
||||
_focus = _PanelFocus.items;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -79,6 +181,7 @@ class _TabPanelState extends State<TabPanel> {
|
||||
/// Scrolls to the first tab at or after [letter], so a letter without names
|
||||
/// lands on the next one.
|
||||
void _jumpToLetter(String letter, List<BarTab> tabs) {
|
||||
_focusOn(_PanelFocus.names);
|
||||
if (letter != _touchedLetter) {
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
setState(() => _touchedLetter = letter);
|
||||
@@ -148,89 +251,181 @@ class _TabPanelState extends State<TabPanel> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Fixed height, so hiding the search bar gives its space
|
||||
// to the names
|
||||
SizedBox(
|
||||
height: 246,
|
||||
child: Column(
|
||||
children: [
|
||||
if (widget.showSearch) ...[
|
||||
TextField(
|
||||
autofocus: true,
|
||||
controller: _searchController,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_searchQuery = value;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.searchByName,
|
||||
prefixIcon: Icon(Icons.search_rounded, size: 20),
|
||||
suffixIcon: _searchQuery.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
_searchQuery = '';
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.clear_rounded),
|
||||
tooltip: l10n.clearSearch,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const dividerHeight = 28.0;
|
||||
const minDetailsHeight = 240.0;
|
||||
final available = constraints.maxHeight - dividerHeight;
|
||||
final collapsedNames = max(available * 0.2, 64.0);
|
||||
final collapsedItems = max(available * 0.1, 64.0);
|
||||
final focus =
|
||||
_focus == _PanelFocus.items && widget.selectedTab == null
|
||||
? _PanelFocus.balanced
|
||||
: _focus;
|
||||
final searchHeight = widget.showSearch ? _searchHeight : 0.0;
|
||||
final namesHeight = switch (focus) {
|
||||
_PanelFocus.balanced => min(246.0, available / 2),
|
||||
_PanelFocus.names => available - collapsedItems,
|
||||
_PanelFocus.items => collapsedNames + searchHeight,
|
||||
};
|
||||
|
||||
final attachOffset = _attachOffset(
|
||||
filteredTabs,
|
||||
namesHeight - searchHeight,
|
||||
);
|
||||
final attached = attachOffset != null;
|
||||
_lineUpSelectedTab(attachOffset);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: _panelAnimation,
|
||||
curve: _panelCurve,
|
||||
height: namesHeight,
|
||||
onEnd: _finishLineUp,
|
||||
child: Column(
|
||||
children: [
|
||||
if (widget.showSearch) ...[
|
||||
SizedBox(
|
||||
height: _searchHeight - 8,
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
controller: _searchController,
|
||||
onTap: () => _focusOn(_PanelFocus.names),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_searchQuery = value;
|
||||
_focus = _PanelFocus.names;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.searchByName,
|
||||
prefixIcon: Icon(
|
||||
Icons.search_rounded,
|
||||
size: 20,
|
||||
),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: OpenTabsList(
|
||||
controller: _tabsScrollController,
|
||||
tabs: filteredTabs,
|
||||
selectedTabId: widget.selectedTabId,
|
||||
onTabSelected: widget.onTabSelected,
|
||||
onTabEdited: widget.onTabEdited,
|
||||
onTabDeleted: widget.onTabDeleted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(height: 28),
|
||||
|
||||
Expanded(
|
||||
child: widget.selectedTab == null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.receipt_long_outlined,
|
||||
size: 36,
|
||||
color: scheme.onSurface.withValues(
|
||||
alpha: 0.25,
|
||||
suffixIcon: _searchQuery.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
_searchQuery = '';
|
||||
});
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.clear_rounded,
|
||||
),
|
||||
tooltip: l10n.clearSearch,
|
||||
),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
l10n.selectOrOpenTab,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
Expanded(
|
||||
child:
|
||||
NotificationListener<ScrollStartNotification>(
|
||||
onNotification: (notification) {
|
||||
if (notification.dragDetails != null) {
|
||||
_focusOn(_PanelFocus.names);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: OpenTabsList(
|
||||
controller: _tabsScrollController,
|
||||
tabs: filteredTabs,
|
||||
selectedTabId: widget.selectedTabId,
|
||||
attachedTabId: attached
|
||||
? widget.selectedTabId
|
||||
: null,
|
||||
onTabSelected: (id) {
|
||||
_focusOn(_PanelFocus.items);
|
||||
widget.onTabSelected(id);
|
||||
},
|
||||
onTabEdited: widget.onTabEdited,
|
||||
onTabDeleted: widget.onTabDeleted,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!attached) const Divider(height: dividerHeight),
|
||||
Expanded(
|
||||
child: Listener(
|
||||
onPointerDown: (_) => _focusOn(_PanelFocus.items),
|
||||
child: AnimatedContainer(
|
||||
duration: _panelAnimation,
|
||||
curve: _panelCurve,
|
||||
padding: EdgeInsets.all(attached ? 12 : 0),
|
||||
decoration: BoxDecoration(
|
||||
color: attached
|
||||
? scheme.primary.withValues(alpha: 0.06)
|
||||
: Colors.transparent,
|
||||
borderRadius: _attachedDetailsRadius,
|
||||
border: Border.all(
|
||||
color: attached
|
||||
? scheme.primary.withValues(alpha: 0.3)
|
||||
: Colors.transparent,
|
||||
),
|
||||
),
|
||||
// Collapsed, the details keep their layout and are
|
||||
// clipped to a peek of the first items
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final height = max(
|
||||
constraints.maxHeight,
|
||||
minDetailsHeight,
|
||||
);
|
||||
|
||||
return ClipRect(
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.topCenter,
|
||||
minHeight: height,
|
||||
maxHeight: height,
|
||||
child: widget.selectedTab == null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.receipt_long_outlined,
|
||||
size: 36,
|
||||
color: scheme.onSurface
|
||||
.withValues(alpha: 0.25),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
l10n.selectOrOpenTab,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: SelectedTabDetails(
|
||||
tab: widget.selectedTab!,
|
||||
stockByProductId:
|
||||
widget.stockByProductId,
|
||||
onItemQuantityChanged:
|
||||
widget.onItemQuantityChanged,
|
||||
onCloseTabPressed:
|
||||
widget.onCloseTabPressed,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: SelectedTabDetails(
|
||||
tab: widget.selectedTab!,
|
||||
stockByProductId: widget.stockByProductId,
|
||||
onItemQuantityChanged: widget.onItemQuantityChanged,
|
||||
onCloseTabPressed: widget.onCloseTabPressed,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -244,6 +439,9 @@ class OpenTabsList extends StatelessWidget {
|
||||
final ScrollController? controller;
|
||||
final List<BarTab> tabs;
|
||||
final String? selectedTabId;
|
||||
|
||||
/// The tab whose tile is lined up on top of its items.
|
||||
final String? attachedTabId;
|
||||
final ValueChanged<String> onTabSelected;
|
||||
final Future<void> Function(BarTab) onTabEdited;
|
||||
final Future<void> Function(BarTab) onTabDeleted;
|
||||
@@ -253,6 +451,7 @@ class OpenTabsList extends StatelessWidget {
|
||||
this.controller,
|
||||
required this.tabs,
|
||||
required this.selectedTabId,
|
||||
this.attachedTabId,
|
||||
required this.onTabSelected,
|
||||
required this.onTabEdited,
|
||||
required this.onTabDeleted,
|
||||
@@ -278,10 +477,13 @@ class OpenTabsList extends StatelessWidget {
|
||||
prototypeItem: _TabRow(child: _TabCard(tab: tabs.first, selected: true)),
|
||||
itemBuilder: (context, index) {
|
||||
final tab = tabs[index];
|
||||
final attached = tab.id == attachedTabId;
|
||||
|
||||
return _TabRow(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderRadius: attached
|
||||
? _attachedTileRadius
|
||||
: BorderRadius.circular(14),
|
||||
child: Slidable(
|
||||
key: ValueKey(tab.id),
|
||||
endActionPane: ActionPane(
|
||||
@@ -307,6 +509,7 @@ class OpenTabsList extends StatelessWidget {
|
||||
child: _TabCard(
|
||||
tab: tab,
|
||||
selected: tab.id == selectedTabId,
|
||||
attached: attached,
|
||||
onTap: () => onTabSelected(tab.id),
|
||||
),
|
||||
),
|
||||
@@ -324,22 +527,32 @@ class _TabRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(padding: const EdgeInsets.only(bottom: 8), child: child);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: _tabRowGap),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TabCard extends StatelessWidget {
|
||||
final BarTab tab;
|
||||
final bool selected;
|
||||
final bool attached;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _TabCard({required this.tab, this.selected = false, this.onTap});
|
||||
const _TabCard({
|
||||
required this.tab,
|
||||
this.selected = false,
|
||||
this.attached = false,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final primary = scheme.primary;
|
||||
final radius = attached ? _attachedTileRadius : BorderRadius.circular(14);
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
@@ -347,7 +560,7 @@ class _TabCard extends StatelessWidget {
|
||||
color: selected
|
||||
? primary.withValues(alpha: 0.14)
|
||||
: scheme.onSurface.withValues(alpha: 0.04),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderRadius: radius,
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? primary.withValues(alpha: 0.6)
|
||||
@@ -358,7 +571,7 @@ class _TabCard extends StatelessWidget {
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderRadius: radius,
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ name: kooltap
|
||||
description: "KoolTap: a tablet point-of-sale app for running bar tabs."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.2.7+0
|
||||
version: 1.2.10+0
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kooltap/l10n/app_localizations.dart';
|
||||
import 'package:kooltap/models/bar_tab.dart';
|
||||
import 'package:kooltap/utils/name_index.dart';
|
||||
import 'package:kooltap/views/bar/selected_tab_details.dart';
|
||||
import 'package:kooltap/views/bar/tab_panel.dart';
|
||||
|
||||
const _names = [
|
||||
@@ -43,6 +44,7 @@ void main() {
|
||||
Future<void> pumpPanel(
|
||||
WidgetTester tester, {
|
||||
bool showSearch = false,
|
||||
BarTab? selectedTab,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(500, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
@@ -55,8 +57,8 @@ void main() {
|
||||
home: Scaffold(
|
||||
body: TabPanel(
|
||||
tabs: tabs,
|
||||
selectedTab: null,
|
||||
selectedTabId: null,
|
||||
selectedTab: selectedTab,
|
||||
selectedTabId: selectedTab?.id,
|
||||
stockByProductId: const {},
|
||||
showSearch: showSearch,
|
||||
onTabSelected: (_) {},
|
||||
@@ -133,6 +135,92 @@ void main() {
|
||||
expect(find.text('anna'), findsOneWidget);
|
||||
});
|
||||
|
||||
group('collapsing', () {
|
||||
double namesHeight(WidgetTester tester) =>
|
||||
tester.getSize(find.byType(OpenTabsList)).height;
|
||||
|
||||
testWidgets('starts with the names at their usual height', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpPanel(tester, selectedTab: tabs.first);
|
||||
|
||||
expect(namesHeight(tester), 246);
|
||||
});
|
||||
|
||||
testWidgets('tapping a name collapses the names', (tester) async {
|
||||
await pumpPanel(tester, selectedTab: tabs.first);
|
||||
|
||||
await tester.tap(find.text('Bram'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(namesHeight(tester), lessThan(200));
|
||||
});
|
||||
|
||||
testWidgets('touching the items collapses the names', (tester) async {
|
||||
await pumpPanel(tester, selectedTab: tabs.first);
|
||||
|
||||
await tester.tap(find.byType(SelectedTabDetails), warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(namesHeight(tester), lessThan(200));
|
||||
});
|
||||
|
||||
testWidgets('lines the selected name up on top of its items', (
|
||||
tester,
|
||||
) async {
|
||||
final marc = tabs.firstWhere((tab) => tab.customerName == 'Marc');
|
||||
await pumpPanel(tester, selectedTab: marc);
|
||||
await tester.tap(find.byType(SelectedTabDetails), warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final list = tester.getRect(find.byType(OpenTabsList));
|
||||
final details = tester.getRect(find.byType(SelectedTabDetails));
|
||||
final tile = tester.getRect(
|
||||
find
|
||||
.ancestor(of: find.text('Marc'), matching: find.byType(ClipRRect))
|
||||
.first,
|
||||
);
|
||||
|
||||
expect(tile.bottom, closeTo(list.bottom, 1));
|
||||
expect(details.top, greaterThan(tile.bottom));
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(widget) => widget is Divider && widget.height == 28,
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('browsing the names shows the divider again', (tester) async {
|
||||
final marc = tabs.firstWhere((tab) => tab.customerName == 'Marc');
|
||||
await pumpPanel(tester, selectedTab: marc);
|
||||
await tester.tap(find.byType(SelectedTabDetails), warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.drag(find.byType(OpenTabsList), const Offset(0, -40));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(widget) => widget is Divider && widget.height == 28,
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('scrolling the names collapses the items', (tester) async {
|
||||
await pumpPanel(tester, selectedTab: tabs.first);
|
||||
await tester.tap(find.byType(SelectedTabDetails), warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.drag(find.byType(OpenTabsList), const Offset(0, -40));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final panel = tester.getSize(find.byType(TabPanel)).height;
|
||||
expect(namesHeight(tester), greaterThan(panel * 0.75));
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('a letter without names goes to the next one', (tester) async {
|
||||
await pumpPanel(tester);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user