Skip to content

Commit f2ff6f8

Browse files
committed
chore: sync main into develop
2 parents f9a33e2 + b3b1605 commit f2ff6f8

35 files changed

Lines changed: 1172 additions & 151 deletions
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/// The network a payment moves through: on-chain Bitcoin, Lightning, or
2+
/// Liquid. Shared across features (e.g. `swap`, `dca`) that need to name a
3+
/// network without depending on each other's internal types.
4+
enum PaymentNetwork { bitcoin, lightning, liquid }
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import 'package:bb_mobile/core/primitives/payment_network.dart';
2+
import 'package:bb_mobile/core/utils/build_context_x.dart';
3+
import 'package:flutter/widgets.dart';
4+
5+
/// Kept in its own file so [PaymentNetwork] itself stays Flutter-free.
6+
extension PaymentNetworkL10n on PaymentNetwork {
7+
String toTranslated(BuildContext context) => switch (this) {
8+
PaymentNetwork.bitcoin => context.loc.transactionNetworkBitcoin,
9+
PaymentNetwork.lightning => context.loc.transactionNetworkLightning,
10+
PaymentNetwork.liquid => context.loc.transactionNetworkLiquid,
11+
};
12+
}

lib/core/widgets/bb_pullable_body.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class BBPullableBody extends StatelessWidget {
2424
required this.onRefresh,
2525
required this.slivers,
2626
this.bottomChild,
27+
this.bottomInset = 0,
2728
});
2829

2930
/// Forwarded to the inner [BBRefreshIndicator]. Use a
@@ -33,6 +34,9 @@ class BBPullableBody extends StatelessWidget {
3334
final List<Widget> slivers;
3435
final Widget? bottomChild;
3536

37+
/// Space reserved at the end of the scroll content.
38+
final double bottomInset;
39+
3640
@override
3741
Widget build(BuildContext context) {
3842
return BBRefreshIndicator(
@@ -42,6 +46,8 @@ class BBPullableBody extends StatelessWidget {
4246
physics: const AlwaysScrollableScrollPhysics(),
4347
slivers: [
4448
...slivers,
49+
if (bottomInset > 0)
50+
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
4551
SliverFillRemaining(
4652
hasScrollBody: false,
4753
child: bottomChild == null

lib/features/announcements/ui/widgets/announcement_card.dart

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,19 @@ class AnnouncementCard extends StatelessWidget {
2929
};
3030

3131
return Stack(
32+
// Fill the height handed down by the carousel, which sizes every page to
33+
// its tallest card: a shorter announcement then still reaches the bottom
34+
// of the row, keeping the page indicator attached to it.
35+
fit: StackFit.expand,
3236
children: [
3337
BullInfoCard(
3438
title: announcement.title(context),
3539
description: announcement.description(context),
3640
tagColor: tone,
37-
bgColor: tone.withValues(alpha: 0.12),
41+
bgColor: Color.alphaBlend(
42+
tone.withValues(alpha: 0.12),
43+
colors.background,
44+
),
3845
onTap: onTap,
3946
),
4047
Positioned(

lib/features/announcements/ui/widgets/announcement_carousel.dart

Lines changed: 107 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import 'package:bb_mobile/features/announcements/ui/announcement_navigation.dart
66
import 'package:bb_mobile/features/announcements/ui/widgets/announcement_card.dart';
77
import 'package:bb_mobile/features/announcements/ui/widgets/announcement_dismiss_dialog.dart';
88
import 'package:bull_ui/bull_ui.dart';
9-
import 'package:flutter/widgets.dart' show MediaQuery;
109
import 'package:flutter_bloc/flutter_bloc.dart';
1110
import 'package:go_router/go_router.dart';
1211

@@ -67,33 +66,78 @@ class _CarouselBody extends StatefulWidget {
6766
State<_CarouselBody> createState() => _CarouselBodyState();
6867
}
6968

70-
class _CarouselBodyState extends State<_CarouselBody> {
71-
/// Base height (at textScale 1.0) that fits a two-line title+description
72-
/// card. Scales with the user's text size so larger accessibility settings
73-
/// never overflow.
74-
static const double _baseCardHeight = 90;
75-
static const double _longCardHeight = 170;
76-
69+
class _CarouselBodyState extends State<_CarouselBody>
70+
with WidgetsBindingObserver {
7771
/// Extra height reserved for the page-indicator dots strip, only when more
7872
/// than one announcement is shown — reserving it for a single card renders
7973
/// as dead space between the card and the content below it.
80-
static const double _dotsStripHeight = 22;
74+
static const double _dotsStripHeight = 26;
8175

82-
late final PageController _controller;
76+
late final ScrollController _controller;
8377
int _page = 0;
78+
double? _lastPageWidth;
8479

8580
@override
8681
void initState() {
8782
super.initState();
88-
_controller = PageController();
83+
_controller = ScrollController()..addListener(_onScroll);
84+
WidgetsBinding.instance.addObserver(this);
85+
// Seed the baseline once the controller has a position, so the first
86+
// real width change afterwards has something to compare against.
87+
WidgetsBinding.instance.addPostFrameCallback((_) {
88+
if (!mounted || !_controller.hasClients) return;
89+
_lastPageWidth = _controller.position.viewportDimension;
90+
});
8991
}
9092

9193
@override
9294
void dispose() {
95+
WidgetsBinding.instance.removeObserver(this);
9396
_controller.dispose();
9497
super.dispose();
9598
}
9699

100+
/// Keeps the same card on screen when the window resizes (split-screen,
101+
/// a foldable unfolding, DeX).
102+
///
103+
/// The offset is stored in pixels, not in pages: after a width change it
104+
/// still points at the old pixel, which the page physics then snap to
105+
/// whichever page is now nearest — usually a different card. Re-align on the
106+
/// page we were showing, captured before the new layout runs.
107+
///
108+
/// `didChangeMetrics` fires for any window-metric change, not only width:
109+
/// a keyboard inset appearing on a route above, system-UI insets, a display
110+
/// change. `jumpTo` goes through `goIdle`, cancelling an in-flight drag or
111+
/// snap animation, so we only realign when the viewport width — the thing
112+
/// that actually moves the page boundaries — has changed.
113+
@override
114+
void didChangeMetrics() {
115+
super.didChangeMetrics();
116+
final targetPage = _page;
117+
WidgetsBinding.instance.addPostFrameCallback((_) {
118+
if (!mounted || !_controller.hasClients) return;
119+
final pageWidth = _controller.position.viewportDimension;
120+
if (pageWidth <= 0) return;
121+
if (pageWidth == _lastPageWidth) return;
122+
_lastPageWidth = pageWidth;
123+
_controller.jumpTo(
124+
(targetPage * pageWidth).clamp(0, _controller.position.maxScrollExtent),
125+
);
126+
});
127+
}
128+
129+
/// Derives the active page from the scroll offset
130+
void _onScroll() {
131+
if (!_controller.hasClients) return;
132+
final pageWidth = _controller.position.viewportDimension;
133+
if (pageWidth <= 0) return;
134+
final page = (_controller.offset / pageWidth).round().clamp(
135+
0,
136+
widget.announcements.length - 1,
137+
);
138+
if (page != _page) setState(() => _page = page);
139+
}
140+
97141
void _onTap(Announcement announcement) {
98142
switch (announcement.action) {
99143
case NavigateAction():
@@ -125,53 +169,60 @@ class _CarouselBodyState extends State<_CarouselBody> {
125169
final activePage = _page.clamp(0, announcements.length - 1);
126170
final showDots = announcements.length > 1;
127171

128-
// Adapt to the user's text-scale setting so the card grows with larger
129-
// accessibility font sizes instead of overflowing.
130-
final textScale = MediaQuery.textScalerOf(context).scale(1);
131-
final baseHeight =
132-
announcements.any(
133-
(announcement) => announcement.id == AnnouncementId.appUpdateRequired,
134-
)
135-
? _longCardHeight
136-
: _baseCardHeight;
137-
final cardHeight =
138-
(baseHeight + (showDots ? _dotsStripHeight : 0)) * textScale;
139-
140-
return SizedBox(
141-
height: cardHeight,
142-
child: Stack(
143-
children: [
144-
PageView.builder(
145-
controller: _controller,
146-
itemCount: announcements.length,
147-
onPageChanged: (i) => setState(() => _page = i),
148-
itemBuilder: (context, index) {
149-
final announcement = announcements[index];
150-
// Reserve the dots strip at the bottom so the card's centered
151-
// content never collides with the indicator.
152-
return Padding(
153-
padding: EdgeInsets.only(
154-
bottom: showDots ? _dotsStripHeight : 0,
155-
),
156-
child: AnnouncementCard(
157-
announcement: announcement,
158-
onTap: () => _onTap(announcement),
159-
onDismiss: () => _onDismiss(announcement),
172+
// No fixed height: a horizontally paged scroll view takes its height from
173+
// its child row, which in turn takes the height of the tallest card at
174+
// this width and text scale.
175+
return LayoutBuilder(
176+
builder: (context, constraints) {
177+
return Stack(
178+
children: [
179+
SingleChildScrollView(
180+
controller: _controller,
181+
scrollDirection: Axis.horizontal,
182+
physics: const PageScrollPhysics(),
183+
// The row is only as tall as its tallest card, and every card
184+
// fills that height, so the indicator stays visually attached to
185+
// whichever card is on screen instead of floating below a short
186+
// one. `stretch` needs a bounded height, which the intrinsic
187+
// pass supplies — inside a sliver the row would otherwise be
188+
// laid out against an infinite height.
189+
child: IntrinsicHeight(
190+
child: Row(
191+
crossAxisAlignment: CrossAxisAlignment.stretch,
192+
children: [
193+
for (final announcement in announcements)
194+
SizedBox(
195+
width: constraints.maxWidth,
196+
// Reserve the dots strip at the bottom so the card
197+
// content never collides with the indicator.
198+
child: Padding(
199+
padding: EdgeInsets.only(
200+
bottom: showDots ? _dotsStripHeight : 0,
201+
),
202+
child: AnnouncementCard(
203+
announcement: announcement,
204+
onTap: () => _onTap(announcement),
205+
onDismiss: () => _onDismiss(announcement),
206+
),
207+
),
208+
),
209+
],
160210
),
161-
);
162-
},
163-
),
164-
// Dots sit inside the card, bottom-centered, so they clearly belong
165-
// to the carousel rather than floating below it.
166-
if (showDots)
167-
Positioned(
168-
left: 0,
169-
right: 0,
170-
bottom: 8,
171-
child: _Dots(count: announcements.length, active: activePage),
211+
),
172212
),
173-
],
174-
),
213+
// Dots sit in the strip reserved below the cards, bottom-centered,
214+
// so they clearly belong to the carousel rather than floating
215+
// below it.
216+
if (showDots)
217+
Positioned(
218+
left: 0,
219+
right: 0,
220+
bottom: 4,
221+
child: _Dots(count: announcements.length, active: activePage),
222+
),
223+
],
224+
);
225+
},
175226
);
176227
}
177228
}

lib/features/autoswap/autoswap_watcher.dart

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,43 @@ class AutoswapWatcher {
2828

2929
Future<void> _run() async {
3030
try {
31-
final result = await _executeAutoswap.execute();
32-
if (result case Err<String, AutoswapFailure>(:final failure)) {
33-
if (failure is AutoswapExecutionFailure ||
34-
failure is AutoswapProviderFailure) {
35-
log.warning('Autoswap failed (${failure.runtimeType})');
36-
}
31+
switch (await _executeAutoswap.execute()) {
32+
case Ok():
33+
log.info('[Autoswap] transfer under way');
34+
case Err(:final failure):
35+
final line = '[Autoswap] no transfer: ${_describe(failure)}';
36+
// Being switched off, or sitting below the trigger, is the steady
37+
// state rather than a problem — and this runs on every liquid sync.
38+
// Those stay at info, which the console shows but the log file
39+
// skips; warnings are reserved for something worth acting on.
40+
if (failure is AutoswapDisabledFailure ||
41+
failure is AutoswapInsufficientBalanceFailure) {
42+
log.info(line);
43+
} else {
44+
log.warning(line);
45+
}
3746
}
3847
} catch (error) {
39-
log.warning('Autoswap watcher failed (${error.runtimeType})');
48+
log.warning('[Autoswap] watcher failed (${error.runtimeType})');
4049
}
4150
}
51+
52+
String _describe(AutoswapFailure failure) => switch (failure) {
53+
AutoswapFeeLimitExceededFailure(
54+
:final feePercent,
55+
:final thresholdPercent,
56+
) =>
57+
'fee ${feePercent.toStringAsFixed(2)}% is above the '
58+
'${thresholdPercent.toStringAsFixed(2)}% ceiling',
59+
AutoswapInsufficientBalanceFailure(:final requiredThresholdSats?) =>
60+
'balance is below the trigger of $requiredThresholdSats sats',
61+
// Raised without a threshold when fees eat the whole excess.
62+
AutoswapInsufficientBalanceFailure() =>
63+
'nothing left to transfer once fees are covered',
64+
AutoswapInvalidSettingsFailure(:final violation) =>
65+
'invalid settings (${violation.name})',
66+
AutoswapProviderFailure() || AutoswapExecutionFailure() =>
67+
'${failure.runtimeType} (${failure.logMessage})',
68+
_ => failure.runtimeType.toString(),
69+
};
4270
}

0 commit comments

Comments
 (0)