Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions lib/core/widgets/bb_pullable_body.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class BBPullableBody extends StatelessWidget {
required this.onRefresh,
required this.slivers,
this.bottomChild,
this.bottomInset = 0,
});

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

/// Space reserved at the end of the scroll content.
final double bottomInset;

@override
Widget build(BuildContext context) {
return BBRefreshIndicator(
Expand All @@ -42,6 +46,8 @@ class BBPullableBody extends StatelessWidget {
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
...slivers,
if (bottomInset > 0)
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
SliverFillRemaining(
hasScrollBody: false,
child: bottomChild == null
Expand Down
12 changes: 11 additions & 1 deletion lib/features/wallet/ui/screens/wallet_home_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ class WalletHomeScreen extends StatefulWidget {
}

class _WalletHomeScreenState extends State<WalletHomeScreen> {
/// Height the pinned Receive/Send bar occupies
static const double _bottomBarHeight = 52.0 + 16.0 * 2;

final GlobalKey<RefreshIndicatorState> _indicatorKey =
GlobalKey<RefreshIndicatorState>();

Expand Down Expand Up @@ -116,8 +119,15 @@ class _WalletHomeScreenState extends State<WalletHomeScreen> {
BBPullableBody(
indicatorKey: _indicatorKey,
onRefresh: () => context.read<WalletBloc>().refresh(),
// Clearance for the bar pinned at the bottom of this Stack, so
// the last wallet card can be scrolled out from under it.
bottomInset:
_bottomBarHeight + MediaQuery.paddingOf(context).bottom,
slivers: [
const SliverToBoxAdapter(child: WalletHomeTopSection()),
// Pinned rather than scrolled away: the balance and the
// Buy/Sell/Pay/Transfer actions stay put while the wallet cards
// scroll underneath.
const PinnedHeaderSliver(child: WalletHomeTopSection()),
const SliverToBoxAdapter(child: AnnouncementCarousel()),
const SliverToBoxAdapter(child: HomeWarnings()),
const SliverToBoxAdapter(child: HomeConsolidationBanner()),
Expand Down
4 changes: 2 additions & 2 deletions lib/features/wallet/ui/widgets/wallet_cards.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ class WalletCards extends StatelessWidget {
child: Column(
crossAxisAlignment: .stretch,
children: [
for (final w in wallets) ...[
for (final (index, w) in wallets.indexed) ...[
if (index > 0) const Gap(8),
WalletCard(
tagColor: cardDetails(context, w),
title: w.displayLabel(context),
Expand All @@ -62,7 +63,6 @@ class WalletCards extends StatelessWidget {
fiatCurrency: fiatCurrency,
onTap: () => onTap?.call(w),
),
const Gap(8),
],
],
),
Expand Down
27 changes: 16 additions & 11 deletions lib/features/wallet/ui/widgets/wallet_home_top_section.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,28 @@ import 'package:shimmer/shimmer.dart';
class WalletHomeTopSection extends StatelessWidget {
const WalletHomeTopSection({super.key});

/// Height of the dark area behind the balance (and the price chart, when it
/// is toggled on).
static const double _balanceAreaHeight = 264;

/// Distance from the top of the dark area down to the balance.
static const double _balanceTopSpacing = 123;

/// How far the [ActionCard] hangs below the dark area.
static const double _actionCardOverhang = 76;

/// Fixed height of the section.
static const double _height = _balanceAreaHeight + _actionCardOverhang;

@override
Widget build(BuildContext context) {
return const SizedBox(
height: 264 + 78 + 46,
height: _height,
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 264 + 78,
// color: Colors.red,
child: _UI(),
),
// const Gap(40),
],
children: [SizedBox(height: _balanceAreaHeight, child: _UI())],
),
Positioned(
bottom: 0,
Expand Down Expand Up @@ -111,9 +117,8 @@ class _Amounts extends StatelessWidget {
@override
Widget build(BuildContext context) {
return const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Gap(32),
Gap(WalletHomeTopSection._balanceTopSpacing),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [Spacer(), _BtcTotalAmt(), Gap(16), EyeToggle(), Spacer()],
Expand Down
104 changes: 104 additions & 0 deletions test/core_test/widgets/bb_pullable_body_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import 'package:bb_mobile/core/widgets/bb_pullable_body.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
const lastItemKey = Key('last-item');
const lastItemHeight = 40.0;
// Comfortably taller than the 600dp test viewport, so the content overflows
// and the scroll extent is what decides whether the last item is reachable.
const contentHeight = 1200.0;

Future<void> pumpBody(
WidgetTester tester, {
required double bottomInset,
}) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: BBPullableBody(
onRefresh: () async {},
bottomInset: bottomInset,
slivers: const [
SliverToBoxAdapter(
child: SizedBox(height: contentHeight - lastItemHeight),
),
SliverToBoxAdapter(
child: SizedBox(key: lastItemKey, height: lastItemHeight),
),
],
),
),
),
);
}

/// Distance from the bottom of the last item to the bottom of the viewport,
/// once scrolled as far as the body allows. Zero means the item is flush with
/// the viewport edge — where a pinned footer would cover it.
Future<double> clearanceAtEndOfScroll(WidgetTester tester) async {
await tester.drag(find.byType(CustomScrollView), const Offset(0, -2000));
await tester.pumpAndSettle();

final viewportBottom = tester.getRect(find.byType(CustomScrollView)).bottom;
final lastItemBottom = tester.getRect(find.byKey(lastItemKey)).bottom;

return viewportBottom - lastItemBottom;
}

testWidgets('leaves the last item flush with the viewport by default', (
tester,
) async {
await pumpBody(tester, bottomInset: 0);

expect(await clearanceAtEndOfScroll(tester), moreOrLessEquals(0));
});

testWidgets('scrolls the last item clear of the reserved bottom inset', (
tester,
) async {
await pumpBody(tester, bottomInset: 84);

// Without the reservation this is 0 and a footer pinned over the body hides
// the last item for good — the wallet home regression this guards against.
expect(await clearanceAtEndOfScroll(tester), moreOrLessEquals(84));
});

testWidgets('adds the bottom inset to the scrollable extent', (tester) async {
await pumpBody(tester, bottomInset: 84);
final withInset = tester
.state<ScrollableState>(find.byType(Scrollable))
.position
.maxScrollExtent;

await pumpBody(tester, bottomInset: 0);
final withoutInset = tester
.state<ScrollableState>(find.byType(Scrollable))
.position
.maxScrollExtent;

expect(withInset - withoutInset, moreOrLessEquals(84));
});

testWidgets('keeps short content unscrollable when an inset is reserved', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: BBPullableBody(
onRefresh: () async {},
bottomInset: 84,
slivers: const [SliverToBoxAdapter(child: SizedBox(height: 100))],
),
),
),
);

final position = tester
.state<ScrollableState>(find.byType(Scrollable))
.position;

expect(position.maxScrollExtent, 0);
});
}
Loading