supporter sub banner
This commit is contained in:
@@ -33,7 +33,11 @@ enum PersistedBoolKey {
|
|||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
),
|
),
|
||||||
infoboxExpanded(key: 'InfoboxExpanded', 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});
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-31
@@ -23,6 +23,7 @@ import 'package:flutter_svg/svg.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:weblibre/core/design/app_colors.dart';
|
import 'package:weblibre/core/design/app_colors.dart';
|
||||||
import 'package:weblibre/core/routing/routes.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/providers/tab_list.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/presentation/providers/browser_viewport_toolbar_insets.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 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(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(20),
|
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user