@@ -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-
221229class _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) {
0 commit comments