/* * 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 . */ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:intl/intl.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:weblibre/features/account/data/models/subscription_status.dart'; import 'package:weblibre/features/account/data/supabase_config.dart'; import 'package:weblibre/features/account/domain/repositories/subscription_repository.dart'; /// Visual presentation of one subscription state. All branches of the /// subscription UI render through the same ListTile + badge + note + manage /// button structure — the differences boil down to these fields, which the /// state machine selects in `_resolvePresentation`. class _SubscriptionPresentation { final IconData leadingIcon; final Color? leadingIconColor; final String planTitle; final String badgeLabel; final Color badgeColor; final Color badgeTextColor; final String? note; final Color? noteColor; final String manageLabel; final String? subtitle; final DateTime? expiryHint; const _SubscriptionPresentation({ required this.leadingIcon, required this.planTitle, required this.badgeLabel, required this.badgeColor, required this.badgeTextColor, required this.manageLabel, this.leadingIconColor, this.note, this.noteColor, this.subtitle, this.expiryHint, }); } class SubscriptionCard extends HookConsumerWidget { const SubscriptionCard({super.key, required this.subscriptionAsync}); final AsyncValue subscriptionAsync; @override Widget build(BuildContext context, WidgetRef ref) { // Refresh on resume regardless of which branch is currently rendered. // Keeping the hook at the top level means a stale error tile (e.g. the // user opened the screen offline, then reconnected and returned to the // app) still gets a fresh fetch — previously the hook only ran in the // `data` branch and never fired from the error state. useOnAppLifecycleStateChange((previous, current) async { if (current == AppLifecycleState.resumed) { await ref.read(subscriptionRepositoryProvider.notifier).refresh(); } }); return subscriptionAsync.when( loading: () => const Padding( padding: EdgeInsets.symmetric(vertical: 24.0), child: Center(child: CircularProgressIndicator()), ), // Render a dedicated error tile so the user can tell "fetch failed" // apart from "no subscription" — they have different fixes (retry // vs. subscribe). error: (_, _) => _SubscriptionErrorTile( onRetry: () => ref.read(subscriptionRepositoryProvider.notifier).refresh(), ), data: (status) { final presentation = _resolvePresentation(context, status); return _SubscriptionStateBody(presentation: presentation); }, ); } } class _SubscriptionErrorTile extends StatelessWidget { const _SubscriptionErrorTile({required this.onRetry}); final Future Function() onRetry; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return ListTile( leading: Icon(Icons.error_outline, color: scheme.error), title: const Text('Could not load subscription'), subtitle: const Text('Check your connection and try again.'), trailing: IconButton( icon: const Icon(Icons.refresh), tooltip: 'Retry', onPressed: onRetry, ), ); } } _SubscriptionPresentation _resolvePresentation( BuildContext context, SubscriptionStatus status, ) { final theme = Theme.of(context); final scheme = theme.colorScheme; final planTitle = status.planLabel ?? 'Supporter'; if (status.isActive) { final isWindingDown = status.isWindingDown; final endDate = status.currentPeriodEnd ?? status.entitledUntil; return _SubscriptionPresentation( leadingIcon: Icons.verified, leadingIconColor: scheme.primary, planTitle: planTitle, badgeLabel: isWindingDown ? 'Will not renew' : 'Active', badgeColor: isWindingDown ? scheme.surfaceContainerHighest : scheme.primaryContainer, badgeTextColor: isWindingDown ? scheme.onSurface : scheme.onPrimaryContainer, subtitle: status.entitledUntil != null ? 'Until ${_formatDate(status.entitledUntil!)}' : null, expiryHint: isWindingDown ? endDate : null, manageLabel: 'Manage Subscription', ); } if (status.isPaused) { return _SubscriptionPresentation( leadingIcon: Icons.pause_circle_outline, leadingIconColor: scheme.onSurfaceVariant, planTitle: planTitle, badgeLabel: 'Paused', badgeColor: scheme.tertiaryContainer, badgeTextColor: scheme.onTertiaryContainer, note: 'Your subscription is paused. Resume it from the customer ' 'portal to restore access.', noteColor: scheme.onSurfaceVariant, manageLabel: 'Manage Subscription', ); } if (status.isPastDue) { return _SubscriptionPresentation( leadingIcon: Icons.error_outline, leadingIconColor: scheme.onSurfaceVariant, planTitle: planTitle, badgeLabel: 'Past due', badgeColor: scheme.errorContainer, badgeTextColor: scheme.onErrorContainer, note: 'Payment failed. Update your payment method to keep your ' 'subscription active.', noteColor: scheme.error, manageLabel: 'Update Payment Method', ); } if (status.isWindingDown) { // No active entitlement remains (isActive was false above) — the // grace period has ended or there never was one. Offer renewal. return _SubscriptionPresentation( leadingIcon: Icons.history_toggle_off, leadingIconColor: scheme.onSurfaceVariant, planTitle: planTitle, badgeLabel: 'Will not renew', badgeColor: scheme.surfaceContainerHighest, badgeTextColor: scheme.onSurface, note: 'Your subscription has ended. Renew from the customer ' 'portal to continue.', noteColor: scheme.onSurfaceVariant, manageLabel: 'Renew Subscription', ); } return _inactivePresentation(context); } _SubscriptionPresentation _inactivePresentation(BuildContext context) { final scheme = Theme.of(context).colorScheme; return _SubscriptionPresentation( leadingIcon: Icons.card_membership, leadingIconColor: scheme.onSurfaceVariant, planTitle: 'Supporter Subscription', subtitle: 'Subscribe to unlock sync features', badgeLabel: 'Inactive', badgeColor: scheme.surfaceContainerHighest, badgeTextColor: scheme.onSurface, manageLabel: 'Subscribe', ); } String _formatDate(DateTime date) => DateFormat.yMMMd().format(date); class _SubscriptionStateBody extends ConsumerWidget { const _SubscriptionStateBody({required this.presentation}); final _SubscriptionPresentation presentation; Future _openPortal() async { await launchUrl( Uri.parse(SupabaseConfig.accountWebUrl), mode: LaunchMode.inAppBrowserView, ); } @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final scheme = theme.colorScheme; return Column( children: [ ListTile( leading: Icon( presentation.leadingIcon, color: presentation.leadingIconColor, ), title: Row( children: [ Text(presentation.planTitle), const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: presentation.badgeColor, borderRadius: BorderRadius.circular(12), ), child: Text( presentation.badgeLabel, style: theme.textTheme.labelSmall?.copyWith( color: presentation.badgeTextColor, ), ), ), ], ), subtitle: presentation.subtitle != null ? Text(presentation.subtitle!) : null, trailing: IconButton( icon: const Icon(Icons.refresh), tooltip: 'Refresh status', onPressed: () async { await ref.read(subscriptionRepositoryProvider.notifier).refresh(); }, ), ), if (presentation.expiryHint != null) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Row( children: [ Icon(Icons.warning_amber, size: 16, color: scheme.error), const SizedBox(width: 4), Expanded( child: Text( 'Your subscription will end on ' '${_formatDate(presentation.expiryHint!)}', style: theme.textTheme.bodySmall?.copyWith( color: scheme.error, ), ), ), ], ), ), if (presentation.note != null) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Text( presentation.note!, style: theme.textTheme.bodySmall?.copyWith( color: presentation.noteColor, ), ), ), const Divider(height: 1), ListTile( leading: const Icon(Icons.open_in_new), title: Text(presentation.manageLabel), contentPadding: const EdgeInsets.symmetric(horizontal: 16.0), onTap: _openPortal, ), ], ); } }