Skip to content

Commit b651098

Browse files
committed
feat(ui): optimize layout for narrow mobile screens
1 parent 8b2d913 commit b651098

10 files changed

Lines changed: 309 additions & 128 deletions

lib/main.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import 'package:fluent_ui/fluent_ui.dart';
22
import 'package:flutter_riverpod/flutter_riverpod.dart' show ProviderScope;
3-
import 'package:nat_tester/pages/app_shell_page.dart';
3+
import 'package:nat_tester/pages/nat_diagnostics_page.dart';
44

55
void main() {
66
runApp(const ProviderScope(child: NatTesterApp()));
@@ -15,7 +15,7 @@ class NatTesterApp extends StatelessWidget {
1515
debugShowCheckedModeBanner: false,
1616
title: 'NAT Tester',
1717
theme: FluentThemeData(brightness: Brightness.dark),
18-
home: const AppShellPage(),
18+
home: const NavigationView(content: NatDiagnosticsPage()),
1919
);
2020
}
2121
}

lib/pages/app_shell_page.dart

Lines changed: 0 additions & 11 deletions
This file was deleted.

lib/widgets/action_button_tile.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'package:fluent_ui/fluent_ui.dart';
2+
import 'package:nat_tester/widgets/adapter_list_tile.dart';
23

34
class ActionButtonTile extends StatelessWidget {
45
const ActionButtonTile({
@@ -16,7 +17,7 @@ class ActionButtonTile extends StatelessWidget {
1617

1718
@override
1819
Widget build(BuildContext context) {
19-
return ListTile(
20+
return AdapterListTile(
2021
leading: Icon(icon),
2122
title: Text(title),
2223
trailing: FilledButton(onPressed: onPressed, child: Text(buttonText)),

lib/widgets/adapter_list_tile.dart

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import 'package:fluent_ui/fluent_ui.dart';
2+
3+
typedef AdapterTrailingBuilder =
4+
Widget Function(BuildContext context, bool isCompact);
5+
6+
class AdapterListTile extends StatelessWidget {
7+
const AdapterListTile({
8+
super.key,
9+
this.tileColor,
10+
this.shape = kDefaultListTileShape,
11+
this.leading,
12+
this.title,
13+
this.subtitle,
14+
this.trailing,
15+
this.trailingBuilder,
16+
this.onPressed,
17+
this.focusNode,
18+
this.autofocus = false,
19+
this.semanticLabel,
20+
this.cursor,
21+
this.contentAlignment = CrossAxisAlignment.center,
22+
this.contentPadding = kDefaultListTilePadding,
23+
this.margin = kDefaultListTileMargin,
24+
this.compactBreakpoint = 560,
25+
this.compactSpacing = 8,
26+
this.stretchTrailingOnCompact = true,
27+
this.compactTrailingAlignment = Alignment.centerRight,
28+
}) : assert(
29+
!(subtitle != null) || title != null,
30+
'To have a subtitle, there must be a title',
31+
);
32+
33+
final WidgetStateColor? tileColor;
34+
final ShapeBorder shape;
35+
final Widget? leading;
36+
final Widget? title;
37+
final Widget? subtitle;
38+
final Widget? trailing;
39+
final AdapterTrailingBuilder? trailingBuilder;
40+
final VoidCallback? onPressed;
41+
final FocusNode? focusNode;
42+
final bool autofocus;
43+
final String? semanticLabel;
44+
final MouseCursor? cursor;
45+
final CrossAxisAlignment contentAlignment;
46+
final EdgeInsetsGeometry contentPadding;
47+
final EdgeInsetsGeometry? margin;
48+
final double compactBreakpoint;
49+
final double compactSpacing;
50+
final bool stretchTrailingOnCompact;
51+
final Alignment compactTrailingAlignment;
52+
53+
@override
54+
Widget build(BuildContext context) {
55+
return LayoutBuilder(
56+
builder: (BuildContext context, BoxConstraints constraints) {
57+
final bool isCompact = constraints.maxWidth < compactBreakpoint;
58+
final Widget? resolvedTrailing =
59+
trailingBuilder?.call(context, isCompact) ?? trailing;
60+
61+
if (!isCompact || resolvedTrailing == null) {
62+
return ListTile(
63+
tileColor: tileColor,
64+
shape: shape,
65+
leading: leading,
66+
title: title,
67+
subtitle: subtitle,
68+
trailing: resolvedTrailing,
69+
onPressed: onPressed,
70+
focusNode: focusNode,
71+
autofocus: autofocus,
72+
semanticLabel: semanticLabel,
73+
cursor: cursor,
74+
contentAlignment: contentAlignment,
75+
contentPadding: contentPadding,
76+
margin: margin,
77+
);
78+
}
79+
80+
Widget compactTrailing = Align(
81+
alignment: compactTrailingAlignment,
82+
child: resolvedTrailing,
83+
);
84+
if (stretchTrailingOnCompact) {
85+
compactTrailing = SizedBox(
86+
width: double.infinity,
87+
child: compactTrailing,
88+
);
89+
}
90+
91+
return ListTile(
92+
tileColor: tileColor,
93+
shape: shape,
94+
leading: leading,
95+
title: _CompactTileContent(
96+
title: title,
97+
subtitle: subtitle,
98+
trailing: compactTrailing,
99+
compactSpacing: compactSpacing,
100+
),
101+
onPressed: onPressed,
102+
focusNode: focusNode,
103+
autofocus: autofocus,
104+
semanticLabel: semanticLabel,
105+
cursor: cursor,
106+
contentAlignment: contentAlignment,
107+
contentPadding: contentPadding,
108+
margin: margin,
109+
);
110+
},
111+
);
112+
}
113+
}
114+
115+
class _CompactTileContent extends StatelessWidget {
116+
const _CompactTileContent({
117+
required this.trailing,
118+
required this.compactSpacing,
119+
this.title,
120+
this.subtitle,
121+
});
122+
123+
final Widget? title;
124+
final Widget? subtitle;
125+
final Widget trailing;
126+
final double compactSpacing;
127+
128+
@override
129+
Widget build(BuildContext context) {
130+
final TextStyle subtitleStyle =
131+
FluentTheme.of(context).typography.caption ?? const TextStyle();
132+
133+
return Column(
134+
mainAxisSize: MainAxisSize.min,
135+
crossAxisAlignment: CrossAxisAlignment.start,
136+
children: <Widget>[
137+
if (title case final Widget titleWidget) titleWidget,
138+
if (subtitle case final Widget subtitleWidget)
139+
Padding(
140+
padding: EdgeInsets.only(top: title == null ? 0 : 4),
141+
child: DefaultTextStyle.merge(
142+
style: subtitleStyle,
143+
child: subtitleWidget,
144+
),
145+
),
146+
SizedBox(height: compactSpacing),
147+
trailing,
148+
],
149+
);
150+
}
151+
}
Lines changed: 63 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:fluent_ui/fluent_ui.dart';
22
import 'package:flutter_hooks/flutter_hooks.dart';
3+
import 'package:nat_tester/widgets/adapter_list_tile.dart';
34

45
class EditableComboActionTile extends HookWidget {
56
const EditableComboActionTile({
@@ -26,40 +27,72 @@ class EditableComboActionTile extends HookWidget {
2627
@override
2728
Widget build(BuildContext context) {
2829
final controller = useTextEditingController(text: value);
29-
return ListTile(
30+
useEffect(() {
31+
if (controller.text != value) {
32+
controller.value = controller.value.copyWith(
33+
text: value,
34+
selection: TextSelection.collapsed(offset: value.length),
35+
composing: TextRange.empty,
36+
);
37+
}
38+
return null;
39+
}, <Object>[value]);
40+
41+
return AdapterListTile(
3042
leading: Icon(icon),
3143
title: Text(title),
3244
subtitle: description == null ? null : Text(description!),
33-
trailing: Expanded(
34-
child: Row(
35-
children: <Widget>[
36-
Expanded(
37-
child: EditableComboBox<String>(
38-
value: value,
39-
textController: controller,
40-
onTextChanged: onChanged,
41-
onChanged: (String? selected) {
42-
if (selected != null) {
43-
onChanged(selected);
44-
}
45-
},
46-
items: options.map((String entry) {
47-
return ComboBoxItem<String>(
48-
value: entry,
49-
child: Text(entry, overflow: TextOverflow.ellipsis),
50-
);
51-
}).toList(),
52-
onFieldSubmitted: (String text) {
53-
onChanged(text);
54-
return text;
55-
},
45+
trailingBuilder: (BuildContext context, bool isCompact) {
46+
final Widget comboBox = EditableComboBox<String>(
47+
value: value,
48+
textController: controller,
49+
onTextChanged: onChanged,
50+
onChanged: (String? selected) {
51+
if (selected != null) {
52+
onChanged(selected);
53+
}
54+
},
55+
items: options.map((String entry) {
56+
return ComboBoxItem<String>(
57+
value: entry,
58+
child: Text(entry, overflow: TextOverflow.ellipsis),
59+
);
60+
}).toList(),
61+
onFieldSubmitted: (String text) {
62+
onChanged(text);
63+
return text;
64+
},
65+
);
66+
67+
if (isCompact) {
68+
return Column(
69+
mainAxisSize: MainAxisSize.min,
70+
crossAxisAlignment: CrossAxisAlignment.stretch,
71+
children: <Widget>[
72+
SizedBox(width: double.infinity, child: comboBox),
73+
const SizedBox(height: 8),
74+
Align(
75+
alignment: Alignment.centerRight,
76+
child: FilledButton(
77+
onPressed: onPressed,
78+
child: Text(buttonText),
79+
),
5680
),
57-
),
58-
const SizedBox(width: 8),
59-
FilledButton(onPressed: onPressed, child: Text(buttonText)),
60-
],
61-
),
62-
),
81+
],
82+
);
83+
}
84+
85+
return SizedBox(
86+
width: 360,
87+
child: Row(
88+
children: <Widget>[
89+
Expanded(child: comboBox),
90+
const SizedBox(width: 8),
91+
FilledButton(onPressed: onPressed, child: Text(buttonText)),
92+
],
93+
),
94+
);
95+
},
6396
);
6497
}
6598
}

lib/widgets/number_field_tile.dart

Lines changed: 23 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'package:fluent_ui/fluent_ui.dart';
2+
import 'package:nat_tester/widgets/adapter_list_tile.dart';
23

34
class NumberFieldTile extends StatelessWidget {
45
const NumberFieldTile({
@@ -20,36 +21,29 @@ class NumberFieldTile extends StatelessWidget {
2021

2122
@override
2223
Widget build(BuildContext context) {
23-
return ListTile(
24+
return AdapterListTile(
2425
leading: Icon(icon),
25-
title: Column(
26-
crossAxisAlignment: CrossAxisAlignment.start,
27-
children: <Widget>[
28-
Text(title),
29-
if (description != null)
30-
Text(
31-
description!,
32-
style: FluentTheme.of(context).typography.caption,
33-
),
34-
],
35-
),
36-
trailing: SizedBox(
37-
width: 160,
38-
child: NumberBox(
39-
mode: SpinButtonPlacementMode.none,
40-
placeholder: placeholder.toString(),
41-
value: value,
42-
onChanged: (num? value) {
43-
if (value == null) {
44-
onChanged(placeholder);
45-
} else if (value is int) {
46-
onChanged(value);
47-
} else {
48-
onChanged(value.round());
49-
}
50-
},
51-
),
52-
),
26+
title: Text(title),
27+
subtitle: description == null ? null : Text(description!),
28+
trailingBuilder: (BuildContext context, bool isCompact) {
29+
return SizedBox(
30+
width: isCompact ? double.infinity : 160,
31+
child: NumberBox(
32+
mode: SpinButtonPlacementMode.none,
33+
placeholder: placeholder.toString(),
34+
value: value,
35+
onChanged: (num? value) {
36+
if (value == null) {
37+
onChanged(placeholder);
38+
} else if (value is int) {
39+
onChanged(value);
40+
} else {
41+
onChanged(value.round());
42+
}
43+
},
44+
),
45+
);
46+
},
5347
);
5448
}
5549
}

0 commit comments

Comments
 (0)