supporter sub banner

This commit is contained in:
Fabian Freund
2026-06-18 08:05:48 +02:00
parent 40afd2fc35
commit 2e54d22879
3 changed files with 292 additions and 32 deletions
@@ -33,7 +33,11 @@ enum PersistedBoolKey {
defaultValue: true,
),
infoboxExpanded(key: 'InfoboxExpanded', defaultValue: true),
tabSuggestions(key: 'TabSuggestions', defaultValue: false);
tabSuggestions(key: 'TabSuggestions', defaultValue: false),
supporterBannerDismissed(
key: 'SupporterBannerDismissed',
defaultValue: false,
);
const PersistedBoolKey({required this.key, required this.defaultValue});
@@ -0,0 +1,254 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/core/providers/persisted_bool.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/account/domain/repositories/account_auth.dart';
import 'package:weblibre/features/account/domain/repositories/subscription_repository.dart';
/// Promotional banner shown above the quote card on the browser home page,
/// inviting non-subscribers to become a WebLibre Supporter.
///
/// It hides itself when the user already has an active subscription or has
/// dismissed it. Includes its own bottom spacing so the host layout doesn't
/// leave a gap when the banner is absent.
class SupporterHomeBanner extends ConsumerWidget {
const SupporterHomeBanner({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final dismissed = ref.watch(
persistedBoolProvider(PersistedBoolKey.supporterBannerDismissed),
);
// Only nudge people who aren't already supporting, and only once we have a
// definitive answer. The subscription provider reports `inactive` while the
// account session is still being restored from the secure store, so gating
// on it alone would flash the banner at a subscriber until auth settles.
// We therefore wait for auth to resolve first:
// - still loading / signing in / error -> stay hidden
// - signed out -> definitely no subscription
// - signed in -> wait for subscription status
final authAsync = ref.watch(accountAuthRepositoryProvider);
final subscriptionAsync = ref.watch(subscriptionRepositoryProvider);
final shouldShow =
// In debug builds, bypass the subscriber/auth auto-hide so the banner
// is always available to work on regardless of account state.
kDebugMode ||
switch (authAsync) {
AsyncData(value: final auth) when auth.isSignedOut => true,
AsyncData(value: final auth) when auth.isSignedIn =>
subscriptionAsync.maybeWhen(
data: (status) => !status.isActive,
orElse: () => false,
),
_ => false,
};
if (dismissed || !shouldShow) {
return const SizedBox.shrink();
}
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
void dismiss() {
ref
.read(
persistedBoolProvider(
PersistedBoolKey.supporterBannerDismissed,
).notifier,
)
.set(true);
}
Future<void> openAccount() {
return AccountSettingsRoute().push(context);
}
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(20, 18, 20, 20),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color.alphaBlend(
AppColors.brandPurple.withValues(alpha: 0.16),
colorScheme.surfaceContainerHigh,
),
colorScheme.surfaceContainer.withValues(alpha: 0.9),
],
),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: AppColors.brandPurple.withValues(alpha: 0.45),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.brandPurple.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
MdiIcons.heartOutline,
size: 18,
color: AppColors.brandPurple,
),
),
const SizedBox(width: 12),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
'Support WebLibre, unlock premium features',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
height: 1.25,
),
),
),
),
const SizedBox(width: 8),
_DismissButton(onPressed: dismiss),
],
),
const SizedBox(height: 14),
Text(
'Help fund our independent browser by becoming a Supporter and '
'unlock:',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.45,
),
),
const SizedBox(height: 12),
const _FeatureBullet(
label: 'Anonymous Premium Search',
description:
'Cryptographically blinded tokens — with optional Tor '
'routing — keep your queries unlinkable to your account.',
),
const SizedBox(height: 8),
const _FeatureBullet(
label: 'Encrypted Sync',
description:
'Securely sync your settings and preferences across profiles '
'and devices.',
),
const SizedBox(height: 18),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: openAccount,
style: FilledButton.styleFrom(
backgroundColor: AppColors.brandPurple,
foregroundColor: colorScheme.surface,
),
icon: const Icon(MdiIcons.heart, size: 18),
label: const Text('Become a Supporter'),
),
),
],
),
),
);
}
}
class _FeatureBullet extends StatelessWidget {
final String label;
final String description;
const _FeatureBullet({required this.label, required this.description});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final baseStyle = theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.4,
);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(top: 7),
child: Icon(Icons.circle, size: 6, color: AppColors.brandPurple),
),
const SizedBox(width: 10),
Expanded(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: '$label: ',
style: baseStyle?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
),
TextSpan(text: description, style: baseStyle),
],
),
),
),
],
);
}
}
class _DismissButton extends StatelessWidget {
final VoidCallback onPressed;
const _DismissButton({required this.onPressed});
@override
Widget build(BuildContext context) {
return IconButton(
tooltip: 'Dismiss',
onPressed: onPressed,
visualDensity: VisualDensity.compact,
iconSize: 18,
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(width: 32, height: 32),
color: Theme.of(context).colorScheme.onSurfaceVariant,
icon: const Icon(Icons.close_rounded),
);
}
}
@@ -23,6 +23,7 @@ import 'package:flutter_svg/svg.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/account/presentation/widgets/supporter_home_banner.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/providers/browser_viewport_toolbar_insets.dart';
@@ -141,6 +142,38 @@ class BrowserHome extends ConsumerWidget {
),
const SizedBox(height: 28),
],
const SupporterHomeBanner(),
Wrap(
alignment: WrapAlignment.center,
spacing: 12,
runSpacing: 12,
children: [
if (hasTabs)
OutlinedButton.icon(
onPressed: viewTabs,
icon: const Icon(Icons.tab_rounded),
label: const Text('View tabs'),
),
FilledButton.icon(
onPressed: openNewTab,
icon: const Icon(Icons.add_rounded),
label: const Text('New tab'),
),
if (hasContainerTabs)
FilledButton.tonalIcon(
onPressed: resumeLatestContainerTab,
icon: const Icon(Icons.history_rounded),
label: const Text('Resume last tab'),
)
else if (hasTabs && containerData == null)
FilledButton.tonalIcon(
onPressed: resumeLatestTab,
icon: const Icon(Icons.history_rounded),
label: const Text('Resume last tab'),
),
],
),
const SizedBox(height: 24),
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
@@ -201,37 +234,6 @@ class BrowserHome extends ConsumerWidget {
],
),
),
const SizedBox(height: 24),
Wrap(
alignment: WrapAlignment.center,
spacing: 12,
runSpacing: 12,
children: [
if (hasTabs)
OutlinedButton.icon(
onPressed: viewTabs,
icon: const Icon(Icons.tab_rounded),
label: const Text('View tabs'),
),
FilledButton.icon(
onPressed: openNewTab,
icon: const Icon(Icons.add_rounded),
label: const Text('New tab'),
),
if (hasContainerTabs)
FilledButton.tonalIcon(
onPressed: resumeLatestContainerTab,
icon: const Icon(Icons.history_rounded),
label: const Text('Resume last tab'),
)
else if (hasTabs && containerData == null)
FilledButton.tonalIcon(
onPressed: resumeLatestTab,
icon: const Icon(Icons.history_rounded),
label: const Text('Resume last tab'),
),
],
),
],
),
),