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
9 changes: 9 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ You can always edit this file by hand instead — the helpers just save effort.

### Added

- **Every sale is celebrated, and it puts you back where the lock was.**
Buying from the sheet a lock raises used to leave you sitting on the sheet
with a line of text; it now closes and opens the same welcome the intro's
offer lands on. *Back to learning* returns to the screen the lock was raised
on — the Path row, the Today card, the dictionary entry, the Saved shelf,
the Studio door — now open, instead of dropping you on Today. Restoring
still closes the sheet in place: it recovers what you already own, and is
not a sale.

- **Help answers the four questions, and answers them for this app.** Settings
→ Help and support now opens each answer on its own row. Three are
re-grounded rather than ported: the streak keeps a day with any finished
Expand Down
4 changes: 3 additions & 1 deletion lib/app/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import 'package:brew_path/features/lessons/presentation/lesson_screen.dart';
import 'package:brew_path/features/mini_games/presentation/mini_game_intro_screen.dart';
import 'package:brew_path/features/mini_games/presentation/mini_game_player_screen.dart';
import 'package:brew_path/features/monetization/domain/course_entitlement.dart';
import 'package:brew_path/features/monetization/domain/purchase_welcome_return.dart';
import 'package:brew_path/features/monetization/presentation/purchase_welcome_route.dart';
import 'package:brew_path/features/onboarding/presentation/loading/loading_screen.dart';
import 'package:brew_path/features/onboarding/presentation/meet_roasty/meet_roasty_screen.dart';
Expand Down Expand Up @@ -163,7 +164,8 @@ GoRouter appRouter(Ref ref) {
GoRoute(
path: AppRoutes.purchaseWelcome.path,
name: AppRoutes.purchaseWelcome.name,
builder: (context, state) => const PurchaseWelcomeRoute(),
builder: (context, state) =>
PurchaseWelcomeRoute(returnTo: welcomeReturnIn(state.uri)),
),
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) => AppShell(navigationShell),
Expand Down
38 changes: 38 additions & 0 deletions lib/features/monetization/domain/purchase_exit.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import 'package:brew_path/features/monetization/domain/plus_purchase_controller.dart';
import 'package:flutter/foundation.dart';

/// Which door an arriving entitlement leaves by.
///
/// [PlusPurchase] reports only that Plus is owned, so the press that asked for
/// it is remembered here — and shared by every surface that sells (#576).
class PurchaseExit {
/// Creates an exit that runs [onPurchased] after a buy and [onRestored]
/// after a Restore.
PurchaseExit({required this.onPurchased, required this.onRestored});

/// Run once the store says the learner has just bought Plus.
final VoidCallback onPurchased;

/// Run once Restore recovers a purchase made earlier.
final VoidCallback onRestored;

bool _restoring = false;
bool _left = false;

/// Runs [controller]'s restore, marking what it recovers as a recovery.
Future<void> restore(PlusPurchase controller) async {
_restoring = true;
await controller.restore();
// Cleared once the restore has settled, whatever it found: a sale made
// after a restore that recovered nothing is still a sale.
_restoring = false;
}

/// Takes the exit [state] calls for, at most once — a celebration that
/// opened twice would leave the learner one back stack deeper than they came.
void settle(PlusPurchaseState state) {
if (state != PlusPurchaseState.owned || _left) return;
_left = true;
_restoring ? onRestored() : onPurchased();
}
}
24 changes: 24 additions & 0 deletions lib/features/monetization/domain/purchase_welcome_return.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/// The welcome's query parameter naming where *Back to learning* returns to.
///
/// Carried on the URL rather than in `extra`, because the celebration is a
/// top-level route go_router rebuilds from the location alone.
const String welcomeReturnParam = 'from';

/// The welcome's query, returning to [location] once the celebration is left.
Map<String, String> welcomeReturnTo(String location) => {
welcomeReturnParam: location,
};

/// The location the welcome at [welcome] returns to, or null when it names
/// none.
///
/// Only an in-app location is taken — a hand-written link naming anywhere else
/// returns to Learn, like a welcome that named nothing.
String? welcomeReturnIn(Uri welcome) {
final from = welcome.queryParameters[welcomeReturnParam];
// `//host` is a URL without its scheme, not a path.
if (from == null || !from.startsWith('/') || from.startsWith('//')) {
return null;
}
return from;
}
24 changes: 8 additions & 16 deletions lib/features/monetization/presentation/paywall_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import 'package:brew_path/features/monetization/domain/paywall_benefits_provider
import 'package:brew_path/features/monetization/domain/paywall_view.dart';
import 'package:brew_path/features/monetization/domain/paywall_view_provider.dart';
import 'package:brew_path/features/monetization/domain/plus_purchase_controller.dart';
import 'package:brew_path/features/monetization/domain/purchase_exit.dart';
import 'package:brew_path/features/monetization/presentation/plan_picker.dart';
import 'package:brew_path/features/monetization/presentation/purchase_outcome_line.dart';
import 'package:brew_path/services/links/open_link.dart';
Expand Down Expand Up @@ -67,20 +68,16 @@ class PaywallScreen extends ConsumerStatefulWidget {
class _PaywallScreenState extends ConsumerState<PaywallScreen> {
PlusTerm? _picked;

/// Whether the entitlement now on the way was asked for by Restore.
///
/// The controller reports only that Plus is owned, so which door to leave by
/// is remembered here, at the press that started it.
bool _restoring = false;
late final PurchaseExit _exit = PurchaseExit(
onPurchased: () => widget.onPurchased(),
onRestored: () => widget.onRestored(),
);

@override
Widget build(BuildContext context) {
final view = ref.watch(paywallViewProvider);

ref.listen(plusPurchaseProvider, (_, next) {
if (next != PlusPurchaseState.owned) return;
_restoring ? widget.onRestored() : widget.onPurchased();
});
ref.listen(plusPurchaseProvider, (_, next) => _exit.settle(next));

return Scaffold(
backgroundColor: context.mood.bg,
Expand All @@ -98,13 +95,8 @@ class _PaywallScreenState extends ConsumerState<PaywallScreen> {
);
}

Future<void> _restore() async {
_restoring = true;
await ref.read(plusPurchaseProvider.notifier).restore();
// Cleared once the restore has settled, whatever it found: a sale made
// after a restore that recovered nothing is still a sale.
_restoring = false;
}
Future<void> _restore() =>
_exit.restore(ref.read(plusPurchaseProvider.notifier));
}

class _Offer extends ConsumerWidget {
Expand Down
76 changes: 63 additions & 13 deletions lib/features/monetization/presentation/plus_gate_sheet.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:brew_path/core/config/app_links_provider.dart';
import 'package:brew_path/core/constants/app_routes.dart';
import 'package:brew_path/core/widgets/app_sheet.dart';
import 'package:brew_path/core/widgets/ghost_button.dart';
import 'package:brew_path/core/widgets/link_button.dart';
Expand All @@ -10,6 +11,8 @@ import 'package:brew_path/features/monetization/domain/paywall_view_provider.dar
import 'package:brew_path/features/monetization/domain/plus_gate_trigger.dart';
import 'package:brew_path/features/monetization/domain/plus_pitch_provider.dart';
import 'package:brew_path/features/monetization/domain/plus_purchase_controller.dart';
import 'package:brew_path/features/monetization/domain/purchase_exit.dart';
import 'package:brew_path/features/monetization/domain/purchase_welcome_return.dart';
import 'package:brew_path/features/monetization/presentation/plus_pitch_list.dart';
import 'package:brew_path/features/monetization/presentation/purchase_outcome_line.dart';
import 'package:brew_path/services/links/open_link.dart';
Expand All @@ -19,36 +22,83 @@ import 'package:brew_path/shared/theme/app_text.dart';
import 'package:brew_path/shared/theme/mood_colors.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';

/// The one sheet every lock raises.
///
/// Opens with **what was just hit** — the trigger's own header — then the
/// ranked bullets, then one action: ADR-0003 sells a single non-consumable, so
/// no trial and no plan chooser, and **no ad path** (v1 ships no ads, so the
/// design's watch-an-ad route is dead). *Not now* writes nothing.
Future<void> showPlusGate(BuildContext context, PlusGateTrigger trigger) =>
showAppSheet<void>(
context: context,
title: PaywallCopy.gateTitle,
builder: (_) => _PlusGateBody(trigger: trigger),
);
/// no trial and no plan chooser, and **no ad path** (v1 ships no ads). *Not
/// now* writes nothing; a sale lands on the welcome, which comes back here.
Future<void> showPlusGate(BuildContext context, PlusGateTrigger trigger) {
// Held rather than looked up again on the way out: closing the sheet leaves
// its own context behind. Absent only where a single screen is pumped on its
// own, which is a test, and where there is nowhere to celebrate anyway.
final router = GoRouter.maybeOf(context);
final raisedAt = router?.state.uri.toString();

return showAppSheet<void>(
context: context,
title: PaywallCopy.gateTitle,
builder: (sheetContext) => _PlusGateBody(
trigger: trigger,
onPurchased: () {
_close(sheetContext);
if (router == null || raisedAt == null) return;
router.goNamed(
AppRoutes.purchaseWelcome.name,
queryParameters: welcomeReturnTo(raisedAt),
);
},
// A recovery, not a sale: the lock behind the sheet is open now, and
// that is the whole of what the learner asked for.
onRestored: () => _close(sheetContext),
),
);
}

/// Closes the sheet, unless it is already leaving — dragged away while the
/// store was still answering, when a pop would take the screen under it.
void _close(BuildContext sheetContext) {
if (ModalRoute.of(sheetContext)?.isCurrent ?? false) {
Navigator.of(sheetContext).pop();
}
}

class _PlusGateBody extends ConsumerWidget {
const _PlusGateBody({required this.trigger});
class _PlusGateBody extends ConsumerStatefulWidget {
const _PlusGateBody({
required this.trigger,
required this.onPurchased,
required this.onRestored,
});

final PlusGateTrigger trigger;
final VoidCallback onPurchased;
final VoidCallback onRestored;

@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<_PlusGateBody> createState() => _PlusGateBodyState();
}

class _PlusGateBodyState extends ConsumerState<_PlusGateBody> {
late final PurchaseExit _exit = PurchaseExit(
onPurchased: () => widget.onPurchased(),
onRestored: () => widget.onRestored(),
);

@override
Widget build(BuildContext context) {
final mood = context.mood;
final pitch = ref.watch(plusPitchProvider);
final purchase = ref.watch(plusPurchaseProvider);

ref.listen(plusPurchaseProvider, (_, next) => _exit.settle(next));

return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(trigger.header, style: AppText.lead(mood: mood)),
Text(widget.trigger.header, style: AppText.lead(mood: mood)),
const SizedBox(height: AppSpacing.md),
// The pitch waits for its counts rather than showing a number it is
// about to correct. Nothing here is written down, so there is nothing
Expand All @@ -70,7 +120,7 @@ class _PlusGateBody extends ConsumerWidget {
label: PaywallCopy.restore,
onPressed: purchase == PlusPurchaseState.working
? null
: () => ref.read(plusPurchaseProvider.notifier).restore(),
: () => _exit.restore(ref.read(plusPurchaseProvider.notifier)),
),
),
const _LegalLinks(),
Expand Down
14 changes: 11 additions & 3 deletions lib/features/monetization/presentation/purchase_welcome_route.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,25 @@ import 'package:go_router/go_router.dart';
/// recorded what was bought. A celebration reached with nothing recorded —
/// a deep link an owner opens — reads as the one-time purchase v1 sells.
class PurchaseWelcomeRoute extends ConsumerWidget {
/// Creates the route host.
const PurchaseWelcomeRoute({super.key});
/// Creates the route host, leaving for [returnTo] when the celebration is
/// done.
const PurchaseWelcomeRoute({this.returnTo, super.key});

/// Where *Back to learning* goes — the screen the offer was raised on, or
/// Learn when the sale happened somewhere with nothing to go back to.
final String? returnTo;

@override
Widget build(BuildContext context, WidgetRef ref) {
final term = ref.watch(purchasedTermProvider) ?? PlusTerm.lifetime;
final back = returnTo;

return PurchaseWelcomeScreen(
plan: paywallPlans[term]!,
onOpenStudio: () => context.goNamed(AppRoutes.studio.name),
onContinue: () => context.goNamed(AppRoutes.learn.name),
onContinue: back == null
? () => context.goNamed(AppRoutes.learn.name)
: () => context.go(back),
);
}
}
60 changes: 60 additions & 0 deletions test/support/selling_payments_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import 'package:brew_path/services/payments/payments_service.dart';
import 'package:brew_path/services/payments/store_product.dart';
import 'package:brew_path/shared/models/monetization/plus_offering.dart';

/// A store that starts a learner outside the course and lets them in.
///
/// The shipped stub cancels every purchase and the granted one owns the course
/// before the app boots, so neither can reach what happens after a sale.
class SellingPaymentsService implements PaymentsService {
/// Creates a store on [model] whose Restore recovers a purchase only when
/// [restorable].
SellingPaymentsService({
this.model = MonetizationModel.oneTime,
this.restorable = false,
});

/// The arm this store puts every learner on.
final MonetizationModel model;

/// Whether Restore finds a purchase made earlier on another device.
final bool restorable;

bool _owned = false;

@override
Future<bool> hasActiveEntitlement() async => _owned;

@override
Future<PlusOffering> currentOffering() async => offeringFor(model);

@override
Future<List<StoreProduct>> getProducts(List<String> productIds) async => [
for (final id in productIds)
StoreProduct(
id: id,
title: 'Foundations',
description: 'The full course',
price: r'$49.99',
amount: 49.99,
currencyCode: 'USD',
),
];

@override
Future<PurchaseStatus> purchase(StoreProduct product) async {
_owned = true;
return PurchaseStatus.purchased;
}

@override
Future<void> restorePurchases() async {
if (restorable) _owned = true;
}

@override
Stream<PurchaseStatus> get purchaseUpdates => const Stream.empty();

@override
void dispose() {}
}
12 changes: 12 additions & 0 deletions test/support/widget_harness.dart
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,18 @@ Future<void> settleLoaders(WidgetTester tester) async {
await tester.pumpAndSettle(const Duration(milliseconds: 50));
}

/// Pumps a bounded run of frames instead of settling, for a screen that never
/// stops animating: Roasty idles forever, so `pumpAndSettle` does not return
/// once he is drawn. `runAsync` lets drift and the store answer in between.
Future<void> pumpWithoutSettling(WidgetTester tester) async {
for (var i = 0; i < 15; i++) {
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 20)),
);
await tester.pump(const Duration(milliseconds: 60));
}
}

/// Pumps [child] under a real container.
///
/// Pass a [container] built with overrides to stand somewhere the app cannot
Expand Down
Loading
Loading