fix(market): zone prompt before connection error; keep Save above the nav bar
- A new user's first step (set your area) works offline, so that empty state now wins over 'can't reach the servers' - The sharing-setup sheet wraps in SafeArea(top: false) so the Save button isn't clipped by the system navigation bar on edge-to-edge devices
This commit is contained in:
parent
8a7dc8d3dc
commit
13b6b4bfce
2 changed files with 318 additions and 214 deletions
|
|
@ -100,11 +100,11 @@ class _MarketScreenState extends State<MarketScreen> {
|
||||||
Future<void> _init() async {
|
Future<void> _init() async {
|
||||||
// Only needed to flush the outbox; read here (while mounted) to avoid using
|
// Only needed to flush the outbox; read here (while mounted) to avoid using
|
||||||
// context across the awaits below. Null when there's no outbox (e.g. tests).
|
// context across the awaits below. Null when there's no outbox (e.g. tests).
|
||||||
final repo =
|
final repo = widget.outbox != null
|
||||||
widget.outbox != null ? context.read<VarietyRepository>() : null;
|
? context.read<VarietyRepository>()
|
||||||
|
: null;
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
final cubit =
|
final cubit = await createOffersCubit(widget.connection, repository: repo);
|
||||||
await createOffersCubit(widget.connection, repository: repo);
|
|
||||||
cubit.setBlockedAuthors(await widget.settings.blockedPubkeys());
|
cubit.setBlockedAuthors(await widget.settings.blockedPubkeys());
|
||||||
cubit.setHiddenOffers(await widget.settings.hiddenOfferKeys());
|
cubit.setHiddenOffers(await widget.settings.hiddenOfferKeys());
|
||||||
final area = await widget.settings.areaGeohash();
|
final area = await widget.settings.areaGeohash();
|
||||||
|
|
@ -208,12 +208,18 @@ class _MarketScreenState extends State<MarketScreen> {
|
||||||
await widget.outbox?.remove(ids); // published now, so unpark any duplicates
|
await widget.outbox?.remove(ids); // published now, so unpark any duplicates
|
||||||
// count == 0 with lots to share means every relay refused — say so plainly
|
// count == 0 with lots to share means every relay refused — say so plainly
|
||||||
// rather than "shared 0", which reads like success.
|
// rather than "shared 0", which reads like success.
|
||||||
messenger.showSnackBar(SnackBar(
|
messenger.showSnackBar(
|
||||||
content: Text(count == 0 ? t.market.shareFailed : t.market.sharedCount(n: count)),
|
SnackBar(
|
||||||
));
|
content: Text(
|
||||||
|
count == 0 ? t.market.shareFailed : t.market.sharedCount(n: count),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
final precision = await widget.settings.searchPrecision();
|
final precision = await widget.settings.searchPrecision();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await cubit.discover(searchPrefix(area, precision)); // refresh incl. just-shared
|
await cubit.discover(
|
||||||
|
searchPrefix(area, precision),
|
||||||
|
); // refresh incl. just-shared
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -242,13 +248,13 @@ class _MarketScreenState extends State<MarketScreen> {
|
||||||
),
|
),
|
||||||
floatingActionButton:
|
floatingActionButton:
|
||||||
cubit != null && (cubit.isOnline || widget.outbox != null)
|
cubit != null && (cubit.isOnline || widget.outbox != null)
|
||||||
? FloatingActionButton.extended(
|
? FloatingActionButton.extended(
|
||||||
key: const Key('market.shareMine'),
|
key: const Key('market.shareMine'),
|
||||||
onPressed: _shareMine,
|
onPressed: _shareMine,
|
||||||
icon: const Icon(Icons.campaign_outlined),
|
icon: const Icon(Icons.campaign_outlined),
|
||||||
label: Text(t.market.shareMine),
|
label: Text(t.market.shareMine),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
body: _loading || cubit == null
|
body: _loading || cubit == null
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: BlocProvider.value(
|
: BlocProvider.value(
|
||||||
|
|
@ -300,6 +306,17 @@ class MarketBody extends StatelessWidget {
|
||||||
final t = context.t;
|
final t = context.t;
|
||||||
return BlocBuilder<OffersCubit, OffersState>(
|
return BlocBuilder<OffersCubit, OffersState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
|
// A new user's first step is setting a zone — which works offline — so
|
||||||
|
// that prompt wins over the connection error.
|
||||||
|
if (!hasArea) {
|
||||||
|
return _EmptyState(
|
||||||
|
icon: Icons.place_outlined,
|
||||||
|
title: t.market.setArea,
|
||||||
|
body: t.market.setAreaBody,
|
||||||
|
actionLabel: t.market.setUp,
|
||||||
|
onAction: onConfigure,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!context.read<OffersCubit>().isOnline) {
|
if (!context.read<OffersCubit>().isOnline) {
|
||||||
// Default community servers exist, so "offline" means unreachable —
|
// Default community servers exist, so "offline" means unreachable —
|
||||||
// offer a retry rather than "set up sharing".
|
// offer a retry rather than "set up sharing".
|
||||||
|
|
@ -311,15 +328,6 @@ class MarketBody extends StatelessWidget {
|
||||||
onAction: onRetry,
|
onAction: onRetry,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!hasArea) {
|
|
||||||
return _EmptyState(
|
|
||||||
icon: Icons.place_outlined,
|
|
||||||
title: t.market.setArea,
|
|
||||||
body: t.market.setAreaBody,
|
|
||||||
actionLabel: t.market.setUp,
|
|
||||||
onAction: onConfigure,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (state.searching && state.offers.isEmpty) {
|
if (state.searching && state.offers.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -327,8 +335,10 @@ class MarketBody extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
const CircularProgressIndicator(),
|
const CircularProgressIndicator(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(t.market.searching,
|
Text(
|
||||||
style: const TextStyle(color: seedMuted)),
|
t.market.searching,
|
||||||
|
style: const TextStyle(color: seedMuted),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -372,16 +382,22 @@ class MarketBody extends StatelessWidget {
|
||||||
prefixIcon: const Icon(Icons.search, color: seedMuted),
|
prefixIcon: const Icon(Icons.search, color: seedMuted),
|
||||||
// Offer to save the current search once it's worth alerting on
|
// Offer to save the current search once it's worth alerting on
|
||||||
// (some text typed or a chip active), never for the bare list.
|
// (some text typed or a chip active), never for the bare list.
|
||||||
suffixIcon: savedSearches != null &&
|
suffixIcon:
|
||||||
|
savedSearches != null &&
|
||||||
(state.query.trim().isNotEmpty ||
|
(state.query.trim().isNotEmpty ||
|
||||||
state.hasActiveFilter)
|
state.hasActiveFilter)
|
||||||
? IconButton(
|
? IconButton(
|
||||||
key: const Key('market.saveSearch'),
|
key: const Key('market.saveSearch'),
|
||||||
icon: const Icon(Icons.bookmark_add_outlined,
|
icon: const Icon(
|
||||||
color: seedGreen),
|
Icons.bookmark_add_outlined,
|
||||||
|
color: seedGreen,
|
||||||
|
),
|
||||||
tooltip: t.savedSearches.save,
|
tooltip: t.savedSearches.save,
|
||||||
onPressed: () =>
|
onPressed: () => _saveCurrentSearch(
|
||||||
_saveCurrentSearch(context, savedSearches!, state),
|
context,
|
||||||
|
savedSearches!,
|
||||||
|
state,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
hintStyle: const TextStyle(color: seedMuted),
|
hintStyle: const TextStyle(color: seedMuted),
|
||||||
|
|
@ -433,13 +449,17 @@ class MarketBody extends StatelessWidget {
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
// A trailing spinner row while the next page loads.
|
// A trailing spinner row while the next page loads.
|
||||||
itemCount: visible.length + (state.loadingMore ? 1 : 0),
|
itemCount:
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
visible.length + (state.loadingMore ? 1 : 0),
|
||||||
|
separatorBuilder: (_, _) =>
|
||||||
|
const SizedBox(height: 12),
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
if (i >= visible.length) {
|
if (i >= visible.length) {
|
||||||
return const Padding(
|
return const Padding(
|
||||||
padding: EdgeInsets.symmetric(vertical: 16),
|
padding: EdgeInsets.symmetric(vertical: 16),
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final o = visible[i];
|
final o = visible[i];
|
||||||
|
|
@ -576,7 +596,9 @@ class _OfferCard extends StatelessWidget {
|
||||||
if (mine) ...[
|
if (mine) ...[
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 8, vertical: 3),
|
horizontal: 8,
|
||||||
|
vertical: 3,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: seedGreen,
|
color: seedGreen,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
|
@ -600,8 +622,10 @@ class _OfferCard extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.place_outlined, size: 15, color: seedMuted),
|
const Icon(Icons.place_outlined, size: 15, color: seedMuted),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(t.market.near,
|
Text(
|
||||||
style: const TextStyle(color: seedMuted, fontSize: 13)),
|
t.market.near,
|
||||||
|
style: const TextStyle(color: seedMuted, fontSize: 13),
|
||||||
|
),
|
||||||
if (offer.isOrganic) ...[
|
if (offer.isOrganic) ...[
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
const Icon(Icons.eco, size: 15, color: seedGreen),
|
const Icon(Icons.eco, size: 15, color: seedGreen),
|
||||||
|
|
@ -609,7 +633,8 @@ class _OfferCard extends StatelessWidget {
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (offer.type == OfferType.sale && offer.priceAmount != null)
|
if (offer.type == OfferType.sale && offer.priceAmount != null)
|
||||||
Text(
|
Text(
|
||||||
'${offer.priceAmount} ${offer.priceCurrency ?? ''}'.trim(),
|
'${offer.priceAmount} ${offer.priceCurrency ?? ''}'
|
||||||
|
.trim(),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: seedOnSurface,
|
color: seedOnSurface,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
|
@ -908,194 +933,226 @@ class _ConfigSheetState extends State<_ConfigSheet> {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = context.t;
|
final t = context.t;
|
||||||
final hasArea = _area.text.trim().isNotEmpty;
|
final hasArea = _area.text.trim().isNotEmpty;
|
||||||
return Padding(
|
// SafeArea keeps the Save button above the system nav bar on edge-to-edge
|
||||||
padding: EdgeInsets.only(
|
// devices; the viewInsets padding still lifts it above the keyboard.
|
||||||
left: 20,
|
return SafeArea(
|
||||||
right: 20,
|
top: false,
|
||||||
top: 20,
|
child: Padding(
|
||||||
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
|
padding: EdgeInsets.only(
|
||||||
),
|
left: 20,
|
||||||
child: _loading
|
right: 20,
|
||||||
? const SizedBox(
|
top: 20,
|
||||||
height: 120, child: Center(child: CircularProgressIndicator()))
|
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
|
||||||
: SingleChildScrollView(
|
),
|
||||||
child: Column(
|
child: _loading
|
||||||
mainAxisSize: MainAxisSize.min,
|
? const SizedBox(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
height: 120,
|
||||||
children: [
|
child: Center(child: CircularProgressIndicator()),
|
||||||
Text(
|
)
|
||||||
t.market.configTitle,
|
: SingleChildScrollView(
|
||||||
style: const TextStyle(
|
child: Column(
|
||||||
fontSize: 18,
|
mainAxisSize: MainAxisSize.min,
|
||||||
fontWeight: FontWeight.w600,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
color: seedTitle,
|
children: [
|
||||||
),
|
Text(
|
||||||
),
|
t.market.configTitle,
|
||||||
const SizedBox(height: 8),
|
style: const TextStyle(
|
||||||
Text(
|
fontSize: 18,
|
||||||
t.market.setupIntro,
|
fontWeight: FontWeight.w600,
|
||||||
style: const TextStyle(
|
color: seedTitle,
|
||||||
color: seedMuted, fontSize: 13, height: 1.4),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
// Setting your area from device location is the human path;
|
|
||||||
// typing a code is the advanced fallback.
|
|
||||||
if (widget.location != null)
|
|
||||||
FilledButton.tonalIcon(
|
|
||||||
key: const Key('market.useLocation'),
|
|
||||||
onPressed: _locationBusy ? null : _useLocation,
|
|
||||||
icon: _locationBusy
|
|
||||||
? const SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.my_location, size: 18),
|
|
||||||
label: Text(t.market.useLocation),
|
|
||||||
),
|
|
||||||
if (_locationError != null)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 6),
|
|
||||||
child: Text(
|
|
||||||
_locationError!,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Color(0xFFB3261E), fontSize: 12),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Text(
|
||||||
children: [
|
t.market.setupIntro,
|
||||||
Icon(
|
style: const TextStyle(
|
||||||
hasArea
|
color: seedMuted,
|
||||||
? Icons.check_circle
|
fontSize: 13,
|
||||||
: Icons.pending_outlined,
|
height: 1.4,
|
||||||
size: 18,
|
|
||||||
color: hasArea ? seedGreen : seedMuted,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
),
|
||||||
Expanded(
|
const SizedBox(height: 18),
|
||||||
|
// Setting your area from device location is the human path;
|
||||||
|
// typing a code is the advanced fallback.
|
||||||
|
if (widget.location != null)
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
key: const Key('market.useLocation'),
|
||||||
|
onPressed: _locationBusy ? null : _useLocation,
|
||||||
|
icon: _locationBusy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.my_location, size: 18),
|
||||||
|
label: Text(t.market.useLocation),
|
||||||
|
),
|
||||||
|
if (_locationError != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
child: Text(
|
child: Text(
|
||||||
hasArea ? t.market.areaSet : t.market.areaNotSet,
|
_locationError!,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
color: hasArea ? seedOnSurface : seedMuted,
|
color: Color(0xFFB3261E),
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
const SizedBox(height: 12),
|
||||||
),
|
Row(
|
||||||
const SizedBox(height: 16),
|
|
||||||
Align(
|
|
||||||
alignment: AlignmentDirectional.centerStart,
|
|
||||||
child: Text(
|
|
||||||
t.market.rangeLabel,
|
|
||||||
style: const TextStyle(fontSize: 13, color: seedMuted),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
SegmentedButton<int>(
|
|
||||||
key: const Key('market.range'),
|
|
||||||
segments: [
|
|
||||||
ButtonSegment(value: 5, label: Text(t.market.rangeNear)),
|
|
||||||
ButtonSegment(value: 4, label: Text(t.market.rangeArea)),
|
|
||||||
ButtonSegment(value: 3, label: Text(t.market.rangeRegion)),
|
|
||||||
],
|
|
||||||
selected: {_precision},
|
|
||||||
showSelectedIcon: false,
|
|
||||||
onSelectionChanged: (s) =>
|
|
||||||
setState(() => _precision = s.first),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Theme(
|
|
||||||
data: Theme.of(context)
|
|
||||||
.copyWith(dividerColor: Colors.transparent),
|
|
||||||
child: ExpansionTile(
|
|
||||||
key: const Key('market.advanced'),
|
|
||||||
tilePadding: EdgeInsets.zero,
|
|
||||||
childrenPadding: const EdgeInsets.only(bottom: 8),
|
|
||||||
title: Text(
|
|
||||||
t.market.advanced,
|
|
||||||
style: const TextStyle(fontSize: 13, color: seedMuted),
|
|
||||||
),
|
|
||||||
children: [
|
children: [
|
||||||
TextField(
|
Icon(
|
||||||
key: const Key('market.area'),
|
hasArea ? Icons.check_circle : Icons.pending_outlined,
|
||||||
controller: _area,
|
size: 18,
|
||||||
onChanged: (_) => setState(() {}),
|
color: hasArea ? seedGreen : seedMuted,
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: t.market.areaCodeLabel,
|
|
||||||
helperText: t.market.areaCodeHint,
|
|
||||||
helperMaxLines: 2,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(width: 8),
|
||||||
Align(
|
Expanded(
|
||||||
alignment: AlignmentDirectional.centerStart,
|
|
||||||
child: Text(
|
child: Text(
|
||||||
t.market.serversLabel,
|
hasArea ? t.market.areaSet : t.market.areaNotSet,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13, color: seedMuted),
|
color: hasArea ? seedOnSurface : seedMuted,
|
||||||
),
|
fontSize: 13,
|
||||||
),
|
),
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsetsDirectional.only(
|
|
||||||
top: 2, bottom: 4),
|
|
||||||
child: Text(
|
|
||||||
t.market.serversHelp,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12, color: seedMuted, height: 1.3),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
for (final url in _knownRelays)
|
|
||||||
CheckboxListTile(
|
|
||||||
key: Key('market.server.$url'),
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
dense: true,
|
|
||||||
controlAffinity: ListTileControlAffinity.leading,
|
|
||||||
value: _selectedRelays.contains(url),
|
|
||||||
title: Text(_relayLabel(url)),
|
|
||||||
onChanged: (on) => setState(() {
|
|
||||||
if (on ?? false) {
|
|
||||||
_selectedRelays.add(url);
|
|
||||||
} else {
|
|
||||||
_selectedRelays.remove(url);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Align(
|
|
||||||
alignment: AlignmentDirectional.centerStart,
|
|
||||||
child: TextButton.icon(
|
|
||||||
key: const Key('market.addServer'),
|
|
||||||
onPressed: _addServer,
|
|
||||||
icon: const Icon(Icons.add, size: 18),
|
|
||||||
label: Text(t.market.serversAdvanced),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
key: const Key('market.blockedPeople'),
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
leading: const Icon(Icons.block, color: seedMuted),
|
|
||||||
title: Text(t.block.manageTitle),
|
|
||||||
trailing: const Icon(Icons.chevron_right),
|
|
||||||
onTap: () => showModalBottomSheet<void>(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
builder: (_) =>
|
|
||||||
BlockedPeopleSheet(settings: widget.settings),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 20),
|
Align(
|
||||||
FilledButton(
|
alignment: AlignmentDirectional.centerStart,
|
||||||
key: const Key('market.save'),
|
child: Text(
|
||||||
onPressed: _save,
|
t.market.rangeLabel,
|
||||||
child: Text(t.market.save),
|
style: const TextStyle(fontSize: 13, color: seedMuted),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SegmentedButton<int>(
|
||||||
|
key: const Key('market.range'),
|
||||||
|
segments: [
|
||||||
|
ButtonSegment(
|
||||||
|
value: 5,
|
||||||
|
label: Text(t.market.rangeNear),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: 4,
|
||||||
|
label: Text(t.market.rangeArea),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: 3,
|
||||||
|
label: Text(t.market.rangeRegion),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {_precision},
|
||||||
|
showSelectedIcon: false,
|
||||||
|
onSelectionChanged: (s) =>
|
||||||
|
setState(() => _precision = s.first),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Theme(
|
||||||
|
data: Theme.of(
|
||||||
|
context,
|
||||||
|
).copyWith(dividerColor: Colors.transparent),
|
||||||
|
child: ExpansionTile(
|
||||||
|
key: const Key('market.advanced'),
|
||||||
|
tilePadding: EdgeInsets.zero,
|
||||||
|
childrenPadding: const EdgeInsets.only(bottom: 8),
|
||||||
|
title: Text(
|
||||||
|
t.market.advanced,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: seedMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
key: const Key('market.area'),
|
||||||
|
controller: _area,
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: t.market.areaCodeLabel,
|
||||||
|
helperText: t.market.areaCodeHint,
|
||||||
|
helperMaxLines: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Align(
|
||||||
|
alignment: AlignmentDirectional.centerStart,
|
||||||
|
child: Text(
|
||||||
|
t.market.serversLabel,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: seedMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsetsDirectional.only(
|
||||||
|
top: 2,
|
||||||
|
bottom: 4,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
t.market.serversHelp,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: seedMuted,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final url in _knownRelays)
|
||||||
|
CheckboxListTile(
|
||||||
|
key: Key('market.server.$url'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
dense: true,
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
value: _selectedRelays.contains(url),
|
||||||
|
title: Text(_relayLabel(url)),
|
||||||
|
onChanged: (on) => setState(() {
|
||||||
|
if (on ?? false) {
|
||||||
|
_selectedRelays.add(url);
|
||||||
|
} else {
|
||||||
|
_selectedRelays.remove(url);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: AlignmentDirectional.centerStart,
|
||||||
|
child: TextButton.icon(
|
||||||
|
key: const Key('market.addServer'),
|
||||||
|
onPressed: _addServer,
|
||||||
|
icon: const Icon(Icons.add, size: 18),
|
||||||
|
label: Text(t.market.serversAdvanced),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
key: const Key('market.blockedPeople'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.block, color: seedMuted),
|
||||||
|
title: Text(t.block.manageTitle),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (_) =>
|
||||||
|
BlockedPeopleSheet(settings: widget.settings),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
FilledButton(
|
||||||
|
key: const Key('market.save'),
|
||||||
|
onPressed: _save,
|
||||||
|
child: Text(t.market.save),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,8 +84,9 @@ void main() {
|
||||||
findsOneWidget);
|
findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('MarketScreen with no relays configured degrades to offline',
|
testWidgets(
|
||||||
(tester) async {
|
'a fresh install (no area, unreachable) asks for the area first, '
|
||||||
|
'not the connection', (tester) async {
|
||||||
final social = await SocialService.fromRootSeedHex('00' * 32);
|
final social = await SocialService.fromRootSeedHex('00' * 32);
|
||||||
final settings = SocialSettings(InMemorySecretStore());
|
final settings = SocialSettings(InMemorySecretStore());
|
||||||
await settings.setRelayUrls(const []); // offline: don't hit the network
|
await settings.setRelayUrls(const []); // offline: don't hit the network
|
||||||
|
|
@ -94,7 +95,23 @@ void main() {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Seeds near you'), findsOneWidget); // app bar title
|
expect(find.text('Seeds near you'), findsOneWidget); // app bar title
|
||||||
expect(find.text('Retry'), findsOneWidget); // can't-reach state offers retry
|
// Setting a zone works offline and is the first step — the connection
|
||||||
|
// error must not bury it.
|
||||||
|
expect(find.text('Set your area'), findsOneWidget);
|
||||||
|
expect(find.text('Retry'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('with an area set, unreachable servers degrade to retry',
|
||||||
|
(tester) async {
|
||||||
|
final social = await SocialService.fromRootSeedHex('00' * 32);
|
||||||
|
final settings = SocialSettings(InMemorySecretStore());
|
||||||
|
await settings.setRelayUrls(const []); // offline: don't hit the network
|
||||||
|
await settings.setAreaGeohash('ezsn9');
|
||||||
|
|
||||||
|
await tester.pumpWidget(_wrapMarket(social, settings));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Retry'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('the range selector persists the chosen search precision',
|
testWidgets('the range selector persists the chosen search precision',
|
||||||
|
|
@ -148,6 +165,36 @@ void main() {
|
||||||
expect(tester.widget<CheckboxListTile>(find.byKey(firstKey)).value, isTrue);
|
expect(tester.widget<CheckboxListTile>(find.byKey(firstKey)).value, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('the config sheet Save button stays above the system nav bar',
|
||||||
|
(tester) async {
|
||||||
|
// Edge-to-edge Android: a 50-logical-px gesture/nav bar at the bottom.
|
||||||
|
tester.view.padding = const FakeViewPadding(bottom: 150); // physical px
|
||||||
|
tester.view.viewPadding = const FakeViewPadding(bottom: 150);
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
final social = await SocialService.fromRootSeedHex('00' * 32);
|
||||||
|
final settings = SocialSettings(InMemorySecretStore());
|
||||||
|
await settings.setRelayUrls(const []); // offline: don't hit the network
|
||||||
|
|
||||||
|
await tester.pumpWidget(_wrapMarket(social, settings));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('market.config')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final save = find.byKey(const Key('market.save'));
|
||||||
|
await tester.ensureVisible(save);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final screenHeight = tester.view.physicalSize.height /
|
||||||
|
tester.view.devicePixelRatio; // 600 on the default test surface
|
||||||
|
const navBarLogical = 150 / 3.0; // FakeViewPadding is physical px
|
||||||
|
expect(
|
||||||
|
tester.getRect(save).bottom,
|
||||||
|
lessThanOrEqualTo(screenHeight - navBarLogical + 0.1),
|
||||||
|
reason: 'Save must not sit under the system navigation bar',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('"use my location" fills the area with a coarse geohash',
|
testWidgets('"use my location" fills the area with a coarse geohash',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
final social = await SocialService.fromRootSeedHex('00' * 32);
|
final social = await SocialService.fromRootSeedHex('00' * 32);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue