Skip to content

Commit 42f5b4b

Browse files
craftedbynikclaude
andcommitted
Add reusable mobile bottom-sheet template and migrate swap modals
Introduce showMobileSheet + MobileSheetScaffold + MobileSheetFormBody as the standard mobile modal: a top-rounded, edge-to-edge sheet with a grabber, a centered title flanked by absolute back/close buttons, drag-to-dismiss plus a rubber-band over-pull spring, and an adaptive, content-driven body. The formBody template sizes itself across three levels — rest at the min-height floor with the body centered, grow to hug the content, or cap below the top gap and scroll — always with full-width action buttons pinned to the bottom edge. fillBody keeps a fixed-min Expanded slot for list bodies (contacts), and expand fills the screen for long scroll lists (asset selector). Migrate the swap asset selector, slippage, recipient/refund address, and address-book contact-picker modals onto the template. All sheet styling is mobile-only (kAppFormFactor); desktop layouts are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a9b6171 commit 42f5b4b

13 files changed

Lines changed: 1972 additions & 749 deletions

lib/src/core/layout/mobile/mobile_sheet.dart

Lines changed: 622 additions & 0 deletions
Large diffs are not rendered by default.

lib/src/features/address_book/widgets/address_book_contact_picker_modal.dart

Lines changed: 106 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,15 @@ class _AddressBookContactPickerModalState
6262
super.dispose();
6363
}
6464

65+
/// Whether the address book has any contacts in the picker's networks
66+
/// (ignoring the search query) — drives hiding the search field when there
67+
/// is nothing to search.
68+
bool _hasContactsForNetworks(AddressBookState state) {
69+
final networks = widget.networks.toSet();
70+
if (networks.isEmpty) return false;
71+
return state.contacts.any((c) => networks.contains(c.network));
72+
}
73+
6574
List<AddressBookContact> _filteredContacts(AddressBookState state) {
6675
final networks = widget.networks.toSet();
6776
if (networks.isEmpty) return const [];
@@ -78,29 +87,41 @@ class _AddressBookContactPickerModalState
7887
];
7988
}
8089

90+
Widget _buildContactsList(List<AddressBookContact> contacts) {
91+
// Both the desktop card and the mobile sheet give the list a bounded
92+
// height (Expanded), so it fills and scrolls internally.
93+
return _ContactPickerList(
94+
contacts: contacts,
95+
showNetwork: widget.networks.length > 1,
96+
onSelected: widget.onSelected,
97+
);
98+
}
99+
81100
@override
82101
Widget build(BuildContext context) {
83102
final colors = context.colors;
84103
final contactsAsync = ref.watch(addressBookProvider);
85104

86-
// On mobile the swap modal route wraps this in the shared
87-
// MobileModalCard (base surface, radius 32, bottom-anchored), so the
88-
// surface is full-width, draws no card, and the list scrolls within a
89-
// bounded height that the card hugs. Desktop keeps the fixed card.
105+
// On mobile this is hosted in a fill-body MobileSheetScaffold which
106+
// supplies the grabber, title and close button, so the mobile branch is
107+
// chromeless and full-width and fills the sheet body (centering the empty
108+
// message, scrolling a populated list). Desktop keeps the fixed centered
109+
// card with its own header.
90110
final isMobile = kAppFormFactor == AppFormFactor.mobile;
91111

92112
return Container(
93113
key: const ValueKey('address_book_contact_picker_modal'),
94114
width: isMobile ? double.infinity : 312,
95115
height: isMobile ? null : 440,
96-
// Container requires a decoration to clip; the mobile card (with no
97-
// decoration here) is clipped by the MobileModalCard surface.
116+
// Container requires a decoration to clip; the mobile sheet (with no
117+
// decoration here) is clipped by the sheet surface.
98118
clipBehavior: isMobile ? Clip.none : Clip.antiAlias,
99-
// Desktop fills to the card edge (bottom 0); mobile hugs, so the
100-
// content needs its own bottom breathing room.
119+
// The mobile sheet header is supplied by the scaffold, so no top inset
120+
// there; mobile hugs, so the content needs its own bottom breathing
121+
// room. Desktop fills to the card edge (bottom 0).
101122
padding: EdgeInsets.fromLTRB(
102123
AppSpacing.sm,
103-
AppSpacing.md,
124+
isMobile ? 0 : AppSpacing.md,
104125
AppSpacing.sm,
105126
isMobile ? AppSpacing.md : 0,
106127
),
@@ -112,98 +133,91 @@ class _AddressBookContactPickerModalState
112133
boxShadow: _modalSurfaceShadows,
113134
),
114135
child: Column(
115-
mainAxisSize: isMobile ? MainAxisSize.min : MainAxisSize.max,
136+
mainAxisSize: MainAxisSize.max,
116137
crossAxisAlignment: CrossAxisAlignment.stretch,
117138
children: [
118-
Row(
119-
children: [
120-
Expanded(
121-
child: Text(
122-
widget.title,
123-
maxLines: 1,
124-
overflow: TextOverflow.ellipsis,
125-
style: AppTypography.bodyLarge.copyWith(
126-
color: colors.text.accent,
127-
fontWeight: FontWeight.w600,
139+
if (!isMobile)
140+
Row(
141+
children: [
142+
Expanded(
143+
child: Text(
144+
widget.title,
145+
maxLines: 1,
146+
overflow: TextOverflow.ellipsis,
147+
style: AppTypography.bodyLarge.copyWith(
148+
color: colors.text.accent,
149+
fontWeight: FontWeight.w600,
150+
),
128151
),
129152
),
153+
const SizedBox(width: AppSpacing.xs),
154+
_ContactPickerIconButton(
155+
semanticLabel: 'Close contacts',
156+
iconName: AppIcons.cross,
157+
onTap: widget.onCancel,
158+
),
159+
],
160+
),
161+
Expanded(
162+
child: contactsAsync.when(
163+
loading: () => const Center(
164+
child: SizedBox(
165+
width: 18,
166+
height: 18,
167+
child: AppIcon(AppIcons.loader, size: 18),
168+
),
130169
),
131-
const SizedBox(width: AppSpacing.xs),
132-
_ContactPickerIconButton(
133-
semanticLabel: 'Close contacts',
134-
iconName: AppIcons.cross,
135-
onTap: widget.onCancel,
170+
error: (_, _) => const _ContactPickerEmptyResult(
171+
title: "Couldn't load contacts. Try again.",
136172
),
137-
],
138-
),
139-
_pickerExpandOrBound(
140-
isMobile,
141-
child: Padding(
142-
padding: const EdgeInsets.only(top: AppSpacing.sm),
143-
child: Column(
144-
mainAxisSize: isMobile ? MainAxisSize.min : MainAxisSize.max,
145-
crossAxisAlignment: CrossAxisAlignment.stretch,
146-
children: [
147-
Padding(
148-
padding: const EdgeInsets.only(top: AppSpacing.xs),
149-
child: AppTextField(
150-
key: const ValueKey('address_book_contact_picker_search'),
151-
label: 'Search',
152-
showLabel: false,
153-
controller: _queryController,
154-
focusNode: _queryFocusNode,
155-
hintText: widget.searchHint,
156-
leading: const AppIcon(AppIcons.search),
157-
leadingSlotWidth: AppInputSizing.iconWrapWidth,
158-
trailingSlotWidth: 40,
159-
inputHorizontalPadding: AppSpacing.s,
160-
showClearButton: true,
161-
onChanged: (_) => setState(() {}),
162-
onClear: () => setState(() {}),
163-
),
164-
),
165-
const SizedBox(height: AppSpacing.md),
166-
_pickerExpandOrBound(
167-
isMobile,
168-
child: contactsAsync.when(
169-
loading: () => const Center(
170-
child: SizedBox(
171-
width: 18,
172-
height: 18,
173-
child: AppIcon(AppIcons.loader, size: 18),
173+
data: (state) {
174+
// No saved contacts for these networks → just the empty
175+
// message (centered in the filled body); a search field would
176+
// have nothing to search.
177+
if (!_hasContactsForNetworks(state)) {
178+
return _ContactPickerEmptyResult(title: widget.emptyTitle);
179+
}
180+
final contacts = _filteredContacts(state);
181+
return Padding(
182+
padding: const EdgeInsets.only(top: AppSpacing.sm),
183+
child: Column(
184+
mainAxisSize: MainAxisSize.max,
185+
crossAxisAlignment: CrossAxisAlignment.stretch,
186+
children: [
187+
Padding(
188+
padding: const EdgeInsets.only(top: AppSpacing.xs),
189+
child: AppTextField(
190+
key: const ValueKey(
191+
'address_book_contact_picker_search',
192+
),
193+
label: 'Search',
194+
showLabel: false,
195+
controller: _queryController,
196+
focusNode: _queryFocusNode,
197+
hintText: widget.searchHint,
198+
leading: const AppIcon(AppIcons.search),
199+
leadingSlotWidth: AppInputSizing.iconWrapWidth,
200+
trailingSlotWidth: 40,
201+
inputHorizontalPadding: AppSpacing.s,
202+
showClearButton: true,
203+
onChanged: (_) => setState(() {}),
204+
onClear: () => setState(() {}),
174205
),
175206
),
176-
error: (_, _) => const _ContactPickerEmptyResult(
177-
title: "Couldn't load contacts. Try again.",
178-
),
179-
data: (state) {
180-
final contacts = _filteredContacts(state);
181-
if (contacts.isEmpty) {
182-
return _ContactPickerEmptyResult(
183-
title: widget.emptyTitle,
184-
);
185-
}
186-
final list = _ContactPickerList(
187-
contacts: contacts,
188-
showNetwork: widget.networks.length > 1,
189-
shrinkWrap: isMobile,
190-
onSelected: widget.onSelected,
191-
);
192-
// Mobile hugs the card to the list within a bound;
193-
// desktop fills the fixed-height card.
194-
return isMobile
195-
? ConstrainedBox(
196-
constraints: const BoxConstraints(
197-
maxHeight: 420,
198-
),
199-
child: list,
207+
const SizedBox(height: AppSpacing.md),
208+
// Search yielded nothing but contacts exist → keep the
209+
// field so the query can be cleared.
210+
Expanded(
211+
child: contacts.isEmpty
212+
? _ContactPickerEmptyResult(
213+
title: widget.emptyTitle,
200214
)
201-
: list;
202-
},
203-
),
215+
: _buildContactsList(contacts),
216+
),
217+
],
204218
),
205-
],
206-
),
219+
);
220+
},
207221
),
208222
),
209223
],
@@ -212,28 +226,17 @@ class _AddressBookContactPickerModalState
212226
}
213227
}
214228

215-
/// Desktop fills the fixed-height card ([Expanded]); mobile lets the
216-
/// search/list area flow so the card hugs its content (the populated list
217-
/// bounds its own height separately).
218-
Widget _pickerExpandOrBound(bool mobile, {required Widget child}) =>
219-
mobile ? child : Expanded(child: child);
220-
221229
class _ContactPickerList extends StatefulWidget {
222230
const _ContactPickerList({
223231
required this.contacts,
224232
required this.showNetwork,
225233
required this.onSelected,
226-
this.shrinkWrap = false,
227234
});
228235

229236
final List<AddressBookContact> contacts;
230237
final bool showNetwork;
231238
final ValueChanged<AddressBookContact> onSelected;
232239

233-
/// Mobile hugs the list to its content within a bounded height; desktop
234-
/// fills the fixed-height card.
235-
final bool shrinkWrap;
236-
237240
@override
238241
State<_ContactPickerList> createState() => _ContactPickerListState();
239242
}
@@ -273,7 +276,7 @@ class _ContactPickerListState extends State<_ContactPickerList> {
273276
child: ListView.separated(
274277
controller: _scrollController,
275278
padding: EdgeInsets.zero,
276-
shrinkWrap: widget.shrinkWrap,
279+
shrinkWrap: false,
277280
itemCount: widget.contacts.length,
278281
separatorBuilder: (_, _) => const SizedBox(height: AppSpacing.xs),
279282
itemBuilder: (context, index) {

lib/src/features/swap/models/swap_state.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ class SwapState {
5353
this.receiveFiatText = '',
5454
this.slippageBps = defaultSwapSlippageBps,
5555
this.supportedExternalAssets = swapExternalAssets,
56+
this.externalAssetsLoading = true,
5657
this.indicativeExternalPerZec = const {},
5758
this.indicativeUsdPrices = const {},
5859
this.reviewQuote,
@@ -85,6 +86,11 @@ class SwapState {
8586
final String receiveFiatText;
8687
final int slippageBps;
8788
final List<SwapAsset> supportedExternalAssets;
89+
90+
/// True until the live (multi-chain) external asset list has been fetched
91+
/// for the first time. While true the asset picker shows skeleton rows
92+
/// instead of the static fallback list.
93+
final bool externalAssetsLoading;
8894
final Map<SwapAsset, double> indicativeExternalPerZec;
8995
final Map<SwapAsset, double> indicativeUsdPrices;
9096
final SwapQuote? reviewQuote;
@@ -257,6 +263,7 @@ class SwapState {
257263
String? receiveFiatText,
258264
int? slippageBps,
259265
List<SwapAsset>? supportedExternalAssets,
266+
bool? externalAssetsLoading,
260267
Map<SwapAsset, double>? indicativeExternalPerZec,
261268
Map<SwapAsset, double>? indicativeUsdPrices,
262269
SwapQuote? reviewQuote,
@@ -296,6 +303,8 @@ class SwapState {
296303
slippageBps: slippageBps ?? this.slippageBps,
297304
supportedExternalAssets:
298305
supportedExternalAssets ?? this.supportedExternalAssets,
306+
externalAssetsLoading:
307+
externalAssetsLoading ?? this.externalAssetsLoading,
299308
indicativeExternalPerZec:
300309
indicativeExternalPerZec ?? this.indicativeExternalPerZec,
301310
indicativeUsdPrices: indicativeUsdPrices ?? this.indicativeUsdPrices,

lib/src/features/swap/providers/swap_state_provider.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,12 +368,16 @@ class SwapNotifier extends Notifier<SwapState> {
368368
for (final asset in liveAssets)
369369
if (asset != SwapAsset.zec) asset,
370370
];
371-
if (supported.isEmpty) return;
371+
if (supported.isEmpty) {
372+
state = state.copyWith(externalAssetsLoading: false);
373+
return;
374+
}
372375
final selected =
373376
_supportedAssetFor(state.externalAsset, supported) ?? supported.first;
374377
final selectedChanged = selected != state.externalAsset;
375378
var nextState = state.copyWith(
376379
supportedExternalAssets: supported,
380+
externalAssetsLoading: false,
377381
indicativeExternalPerZec:
378382
pricing?.externalPerZec ?? state.indicativeExternalPerZec,
379383
indicativeUsdPrices: pricing?.usdPrices ?? state.indicativeUsdPrices,
@@ -395,6 +399,7 @@ class SwapNotifier extends Notifier<SwapState> {
395399
);
396400
} catch (_) {
397401
// Keep the static fallback so the swap flow remains usable offline.
402+
state = state.copyWith(externalAssetsLoading: false);
398403
}
399404
}
400405

0 commit comments

Comments
 (0)