From 47278354028f0baa33c064ea83a873c726eee08f Mon Sep 17 00:00:00 2001 From: kolo Date: Sun, 28 Jun 2026 18:33:46 +0300 Subject: [PATCH] step --- AGENTS.md | 62 ++++++++++++- lib/app/router.dart | 5 + lib/core/l10n/app_strings.dart | 22 +++++ lib/data/repositories/account_repository.dart | 15 ++- .../widgets/account_scope_chips.dart | 10 +- lib/features/dashboard/provider.dart | 4 +- lib/features/dashboard/screen.dart | 35 +++++-- .../account_editor_overlay.dart | 60 ++++++------ .../dashboard/widgets/balance_card.dart | 15 +-- .../widgets/balance_card_carousel.dart | 4 +- .../widgets/color_editor_overlay.dart | 6 +- lib/features/settings/screen.dart | 3 + .../settings/widgets/premium_section.dart | 85 +++++++++++++++++ lib/shared/feature_flags/feature_flags.dart | 6 ++ .../feature_flags/feature_flags_provider.dart | 12 +++ .../feature_flags/free_feature_flags.dart | 17 ++++ .../feature_flags/vip_feature_flags.dart | 17 ++++ lib/shared/models/user_model.dart | 9 ++ lib/shared/paywall/paywall_banner.dart | 55 +++++++++++ lib/shared/paywall/paywall_guard.dart | 22 +++++ lib/shared/paywall/paywall_screen.dart | 92 +++++++++++++++++++ .../providers/current_user_provider.dart | 32 +++++++ 22 files changed, 534 insertions(+), 54 deletions(-) create mode 100644 lib/features/settings/widgets/premium_section.dart create mode 100644 lib/shared/feature_flags/feature_flags.dart create mode 100644 lib/shared/feature_flags/feature_flags_provider.dart create mode 100644 lib/shared/feature_flags/free_feature_flags.dart create mode 100644 lib/shared/feature_flags/vip_feature_flags.dart create mode 100644 lib/shared/models/user_model.dart create mode 100644 lib/shared/paywall/paywall_banner.dart create mode 100644 lib/shared/paywall/paywall_guard.dart create mode 100644 lib/shared/paywall/paywall_screen.dart create mode 100644 lib/shared/providers/current_user_provider.dart diff --git a/AGENTS.md b/AGENTS.md index c0113c8..65315f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,15 @@ lib/ ├── data/ # Database schema, repositories ├── features/ # Feature modules (provider + screen + widgets) ├── shared/ # Cross-feature models, providers, services, widgets +│ ├── feature_flags/ +│ │ ├── feature_flags.dart # abstract class FeatureFlags +│ │ ├── free_feature_flags.dart # FreeFeatureFlags implements FeatureFlags +│ │ ├── vip_feature_flags.dart # VipFeatureFlags implements FeatureFlags +│ │ └── feature_flags_provider.dart # featureFlagsProvider +│ └── paywall/ +│ ├── paywall_guard.dart # PaywallGuard widget +│ ├── paywall_banner.dart # inline upsell banner +│ └── paywall_screen.dart # full-screen paywall └── main.dart ``` @@ -46,6 +55,8 @@ lib/ - `services/` — `ExchangeRateService`, `StorageService` - `utils/` — `CurrencyUtils` - `widgets/` — `BynSign`, `ErrorSnackbar` +- `feature_flags/` — `FeatureFlags` abstraction and plan-specific implementations +- `paywall/` — `PaywallGuard`, `PaywallBanner`, `PaywallScreen` ## Architecture Rules @@ -57,6 +68,14 @@ lib/ - Database queries are in repositories only — no raw Drift queries in providers or widgets - After any changes to Drift tables or DAOs, run `dart run build_runner build --delete-conflicting-outputs` +### Feature Gating Rules + +- Never check `user.isVip` or `plan == UserPlan.vip` directly in widgets or screens +- All access control goes through `featureFlagsProvider` — read the relevant flag, wrap with `PaywallGuard` +- Quantity limits (e.g. max accounts) are enforced inside repositories, not in widgets — throw `FeatureLimitException` on violation +- Routes that are entirely VIP-only use GoRouter `redirect` reading `featureFlagsProvider` +- Adding a new gated feature means: add a getter to `FeatureFlags`, implement in `FreeFeatureFlags` and `VipFeatureFlags`, then use in UI/repo + ## Code Style **No comments anywhere in the codebase.** No `//`, no `/* */`, no `///` doc comments. Code must be self-explanatory through naming. @@ -110,6 +129,44 @@ result.when( **Colors for accounts** — use `CardColorService`, not hardcoded colors. +**Feature gating** — wrap gated UI with `PaywallGuard`: +```dart +class ExportScreen extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final flags = ref.watch(featureFlagsProvider); + return PaywallGuard( + canAccess: flags.canExportCsv, + child: ExportContent(), + ); + } +} +``` + +**Quantity limits in repositories**: +```dart +Future> createAccount(Account account) async { + final flags = ref.read(featureFlagsProvider); + final count = await _db.countAccounts(); + if (flags.maxAccounts != -1 && count >= flags.maxAccounts) { + return Result.failure(FeatureLimitException()); + } + return Result.success(await _db.insertAccount(account)); +} +``` + +**VIP-only routes** — use GoRouter redirect, never guard inside the screen itself: +```dart +GoRoute( + path: '/analytics', + redirect: (context, state) { + final flags = ref.read(featureFlagsProvider); + return flags.canSeeAnalytics ? null : '/paywall'; + }, + builder: (context, state) => AnalyticsScreen(), +), +``` + ## Existing Features - **dashboard** — main screen with account carousel (`BalanceCard`), transaction list, search, filter chips, budget progress, account editor overlay @@ -125,4 +182,7 @@ result.when( - Do not create new providers in `shared/providers/` unless the provider is needed by 2+ features - Do not use `BuildContext` across async gaps without checking `mounted` - Do not hardcode user-facing strings — use `AppStrings` -- Do not format currency amounts manually — use `CurrencyUtils` \ No newline at end of file +- Do not format currency amounts manually — use `CurrencyUtils` +- Do not check `user.isVip` or `plan == UserPlan.vip` in widgets or screens — use `featureFlagsProvider` +- Do not enforce feature limits in widgets — put them in repositories and throw `FeatureLimitException` +- Do not add a new gated feature without adding a corresponding getter to `FeatureFlags` and implementing it in both `FreeFeatureFlags` and `VipFeatureFlags` \ No newline at end of file diff --git a/lib/app/router.dart b/lib/app/router.dart index 10b0b2d..9ddad0c 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -8,6 +8,7 @@ import '../features/categories/screen.dart'; import '../features/settings/screen.dart'; import '../features/settings/categories/category_manager_screen.dart'; import '../shared/models/transaction.dart'; +import '../shared/paywall/paywall_screen.dart'; final _shellKey = GlobalKey(); @@ -49,6 +50,10 @@ final appRouter = GoRouter( path: '/settings/categories', builder: (context, state) => const CategoryManagerScreen(), ), + GoRoute( + path: '/paywall', + builder: (context, state) => const PaywallScreen(), + ), ], ); diff --git a/lib/core/l10n/app_strings.dart b/lib/core/l10n/app_strings.dart index 26cae00..e493b0a 100644 --- a/lib/core/l10n/app_strings.dart +++ b/lib/core/l10n/app_strings.dart @@ -239,4 +239,26 @@ class AppStrings { String get categorySaved => _ru ? 'Категория сохранена' : 'Category saved'; String get dateLocale => _ru ? 'ru_RU' : 'en_US'; + + String get premium => _ru ? 'Премиум' : 'Premium'; + String get premiumStatus => _ru ? 'Статус премиум' : 'Premium status'; + String get premiumDescription => _ru + ? 'Разблокируйте цвета карточек, высоту и до 8 счетов' + : 'Unlock card colors, card height and up to 8 accounts'; + String get premiumEnabled => _ru ? 'Включён' : 'Enabled'; + String get premiumDisabled => _ru ? 'Выключен' : 'Disabled'; + String get premiumFeatureLocked => + _ru ? 'Доступно в премиум' : 'Premium feature'; + String get accountLimitReached => _ru + ? 'Достигнут лиммт счетов. Обновите до премиум для большего количества.' + : 'Account limit reached. Upgrade to premium for more accounts.'; + String accountsLimitLabel(int max) => + _ru ? 'Максимум $max счетов.' : 'Maximum $max accounts.'; + + String get premiumFeatureColors => + _ru ? 'Настройка цветов карточек' : 'Custom card colors'; + String get premiumFeatureHeight => + _ru ? 'Изменение высоты карточки' : 'Resizable card height'; + String get premiumFeatureAccounts => + _ru ? 'До 8 счетов' : 'Up to 8 accounts'; } diff --git a/lib/data/repositories/account_repository.dart b/lib/data/repositories/account_repository.dart index 2d04c6a..1bc5992 100644 --- a/lib/data/repositories/account_repository.dart +++ b/lib/data/repositories/account_repository.dart @@ -1,19 +1,21 @@ import 'package:drift/drift.dart'; import '../database/app_database.dart'; import '../../shared/models/account.dart' as model; +import '../../shared/feature_flags/feature_flags.dart'; -class AccountLimitException implements Exception { +class FeatureLimitException implements Exception { final String message; - AccountLimitException(this.message); + FeatureLimitException(this.message); @override - String toString() => 'AccountLimitException: $message'; + String toString() => 'FeatureLimitException: $message'; } class AccountRepository { final AppDatabase _db; + final FeatureFlags _featureFlags; - AccountRepository(this._db); + AccountRepository(this._db, this._featureFlags); Stream> watchAll() { return (_db.select(_db.accounts) @@ -163,6 +165,11 @@ class AccountRepository { } Future add(model.Account account) async { + final existing = await getAll(); + final nonMainCount = existing.where((a) => !a.isMain).length; + if (nonMainCount >= _featureFlags.maxAccounts) { + throw FeatureLimitException('Account limit reached (${_featureFlags.maxAccounts})'); + } return await _db.into(_db.accounts).insert( AccountsCompanion.insert( name: account.name, diff --git a/lib/features/categories/widgets/account_scope_chips.dart b/lib/features/categories/widgets/account_scope_chips.dart index 7849d8a..d4e6969 100644 --- a/lib/features/categories/widgets/account_scope_chips.dart +++ b/lib/features/categories/widgets/account_scope_chips.dart @@ -33,7 +33,15 @@ class AccountScopeChips extends ConsumerWidget { HapticService.selection(); }, ), - const SizedBox(width: 6), + if (accounts.isNotEmpty) ...[ + const SizedBox(width: 8), + Container( + width: 1, + height: 16, + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.15), + ), + const SizedBox(width: 8), + ], ...accounts.asMap().entries.map((entry) { final index = entry.key + 1; final account = entry.value; diff --git a/lib/features/dashboard/provider.dart b/lib/features/dashboard/provider.dart index 8e44995..56bda67 100644 --- a/lib/features/dashboard/provider.dart +++ b/lib/features/dashboard/provider.dart @@ -7,6 +7,7 @@ import '../../core/utils/result.dart'; import '../../data/database/app_database.dart' as db; import '../../data/repositories/transaction_repository.dart'; import '../../data/repositories/account_repository.dart'; +import '../../shared/feature_flags/feature_flags_provider.dart'; import '../../shared/models/transaction.dart'; import '../../shared/models/account.dart'; import '../../shared/services/storage_service.dart'; @@ -27,7 +28,8 @@ final transactionRepositoryProvider = Provider((ref) { final accountRepositoryProvider = Provider((ref) { final db = ref.watch(appDatabaseProvider); - return AccountRepository(db); + final flags = ref.watch(featureFlagsProvider); + return AccountRepository(db, flags); }); final storageServiceProvider = Provider((ref) { diff --git a/lib/features/dashboard/screen.dart b/lib/features/dashboard/screen.dart index 5358c07..fa1c8f5 100644 --- a/lib/features/dashboard/screen.dart +++ b/lib/features/dashboard/screen.dart @@ -5,7 +5,9 @@ import 'package:intl/intl.dart'; import '../../core/l10n/locale_provider.dart'; import '../../core/services/card_color_service.dart'; import '../../core/services/haptic_service.dart'; +import '../../data/repositories/account_repository.dart'; import '../../shared/models/account.dart'; +import '../../shared/feature_flags/feature_flags_provider.dart'; import '../settings/provider.dart'; import 'provider.dart'; import 'widgets/account_editor_overlay/account_editor_overlay.dart'; @@ -56,6 +58,7 @@ class _DashboardScreenState extends ConsumerState { bool isAddingAccount = false; void _onCardLongPress() { + if (!ref.read(featureFlagsProvider).canEditCardColors) return; final colors = ref.read(cardColorsProvider); savedPrimary = colors.primary; savedSecondary = colors.secondary; @@ -179,15 +182,25 @@ class _DashboardScreenState extends ConsumerState { createdAt: DateTime.now(), ); - final newId = await ref.read(accountRepositoryProvider).add(newAccount); + try { + final newId = await ref.read(accountRepositoryProvider).add(newAccount); - await CardColorService.save( - tempPrimary, - tempSecondary, - tempLightGradientType, - tempDarkGradientType, - accountId: newId, - ); + await CardColorService.save( + tempPrimary, + tempSecondary, + tempLightGradientType, + tempDarkGradientType, + accountId: newId, + ); + } on FeatureLimitException { + if (mounted) { + final s = ref.read(stringsProvider); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(s.accountLimitReached)), + ); + } + return; + } } else if (editingAccount != null) { await ref .read(accountCardColorsProvider(editingAccount!.id).notifier) @@ -279,8 +292,9 @@ class _DashboardScreenState extends ConsumerState { final activeIndex = ref.watch(activeAccountIndexProvider); final accountsAsync = ref.watch(accountsProvider); final accountCount = accountsAsync.value?.length ?? 0; + final maxAccounts = ref.watch(featureFlagsProvider).maxAccounts; final isOnAddAccountPage = - accountCount < 5 && activeIndex == accountCount + 1; + accountCount < maxAccounts && activeIndex == accountCount + 1; return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, @@ -438,6 +452,7 @@ class _AccountsInfoBlock extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final s = ref.watch(stringsProvider); + final maxAccounts = ref.watch(featureFlagsProvider).maxAccounts; final onSurface = Theme.of(context).colorScheme.onSurface; return Padding( padding: const EdgeInsets.only(bottom: 60), @@ -479,7 +494,7 @@ class _AccountsInfoBlock extends ConsumerWidget { const SizedBox(height: 8), _InfoRow( icon: Icons.lock_outline_rounded, - text: s.accountsInfoLimit, + text: s.accountsLimitLabel(maxAccounts), ), ], ), diff --git a/lib/features/dashboard/widgets/account_editor_overlay/account_editor_overlay.dart b/lib/features/dashboard/widgets/account_editor_overlay/account_editor_overlay.dart index d1aed00..d2e8624 100644 --- a/lib/features/dashboard/widgets/account_editor_overlay/account_editor_overlay.dart +++ b/lib/features/dashboard/widgets/account_editor_overlay/account_editor_overlay.dart @@ -5,6 +5,7 @@ import '../../../../core/constants.dart'; import '../../../../core/utils/card_layout.dart'; import '../../../../shared/models/account.dart'; import '../../../../shared/models/transaction.dart'; +import '../../../../shared/feature_flags/feature_flags_provider.dart'; import '../../../../shared/widgets/byn_sign.dart'; import '../../../settings/provider.dart'; import '../../provider.dart'; @@ -93,9 +94,10 @@ class _AccountEditorOverlayState extends State { builder: (context, ref, _) { final exchangeService = ref.watch(exchangeRateServiceProvider); final cardHeight = ref.watch(cardHeightProvider); + final isPremium = ref.watch(featureFlagsProvider).canEditCardColors; final editorPanelTop = cardTop + cardHeight + layout.sectionGap; final colorPanelTop = editorPanelTop + editorPanelHeight + layout.sectionGap; - final colorPanelHeight = layout.colorPanelHeight(mq, colorPanelTop); + final colorPanelHeight = isPremium ? layout.colorPanelHeight(mq, colorPanelTop) : 0.0; double previewBalance = 0.0; if (!dash.isAddingAccount) { @@ -249,36 +251,38 @@ class _AccountEditorOverlayState extends State { ), ), ), - Positioned( - top: colorPanelTop, - left: 20, - right: 20, - child: GestureDetector( - onTap: () { - if (_showCurrencyDropdown) { - setState(() { - _showCurrencyDropdown = false; - }); - } - }, - behavior: HitTestBehavior.opaque, - child: AccountColorPanel( - dashboardState: dash, - dashboardContext: widget.context, - panelHeight: colorPanelHeight, - layout: layout, - isDuplicateName: _isDuplicateName, - onDuplicateError: () { - setState(() => _showDuplicateError = true); - Future.delayed(const Duration(seconds: 3), () { - if (mounted) { - setState(() => _showDuplicateError = false); - } - }); + if (isPremium) ...[ + Positioned( + top: colorPanelTop, + left: 20, + right: 20, + child: GestureDetector( + onTap: () { + if (_showCurrencyDropdown) { + setState(() { + _showCurrencyDropdown = false; + }); + } }, + behavior: HitTestBehavior.opaque, + child: AccountColorPanel( + dashboardState: dash, + dashboardContext: widget.context, + panelHeight: colorPanelHeight, + layout: layout, + isDuplicateName: _isDuplicateName, + onDuplicateError: () { + setState(() => _showDuplicateError = true); + Future.delayed(const Duration(seconds: 3), () { + if (mounted) { + setState(() => _showDuplicateError = false); + } + }); + }, + ), ), ), - ), + ], if (_showCurrencyDropdown) Positioned( top: editorPanelTop + 62, diff --git a/lib/features/dashboard/widgets/balance_card.dart b/lib/features/dashboard/widgets/balance_card.dart index 6476e5a..9a17bca 100644 --- a/lib/features/dashboard/widgets/balance_card.dart +++ b/lib/features/dashboard/widgets/balance_card.dart @@ -9,6 +9,7 @@ import '../../../core/utils/card_layout.dart'; import '../../../shared/utils/card_gradient.dart'; import '../../../core/services/haptic_service.dart'; import '../../../shared/providers/amount_format_provider.dart'; +import '../../../shared/feature_flags/feature_flags_provider.dart'; import '../../../shared/widgets/byn_sign.dart'; import '../../settings/provider.dart'; import '../provider.dart'; @@ -110,6 +111,7 @@ class BalanceCardState extends ConsumerState .toList(); final textColorMode = ref.watch(cardTextColorProvider); + final canEditCardColors = ref.watch(featureFlagsProvider).canEditCardColors; final Color onCard = switch (textColorMode) { CardTextColorMode.white => Colors.white, CardTextColorMode.black => Colors.black, @@ -324,12 +326,13 @@ class BalanceCardState extends ConsumerState ], ), ), - Positioned( - bottom: 8, - left: 0, - right: 0, - child: Text( - s.tapAndHoldToEdit, + if (canEditCardColors) + Positioned( + bottom: 8, + left: 0, + right: 0, + child: Text( + s.tapAndHoldToEdit, textAlign: TextAlign.center, style: TextStyle( fontSize: 9, diff --git a/lib/features/dashboard/widgets/balance_card_carousel.dart b/lib/features/dashboard/widgets/balance_card_carousel.dart index a6cdf45..ba4ed75 100644 --- a/lib/features/dashboard/widgets/balance_card_carousel.dart +++ b/lib/features/dashboard/widgets/balance_card_carousel.dart @@ -5,6 +5,7 @@ import '../../../core/utils/card_layout.dart'; import '../../../core/services/haptic_service.dart'; import '../../../shared/models/account.dart'; import '../../../shared/models/transaction.dart'; +import '../../../shared/feature_flags/feature_flags_provider.dart'; import '../../settings/provider.dart'; import '../provider.dart'; import 'balance_card.dart'; @@ -60,10 +61,11 @@ class _BalanceCardCarouselState extends ConsumerState { final accountsAsync = ref.watch(accountsProvider); final activeIndex = ref.watch(activeAccountIndexProvider); final cardHeight = ref.watch(cardHeightProvider); + final maxAccounts = ref.watch(featureFlagsProvider).maxAccounts; return accountsAsync.when( data: (accounts) { - final totalPages = 1 + accounts.length + (accounts.length < 5 ? 1 : 0); + final totalPages = 1 + accounts.length + (accounts.length < maxAccounts ? 1 : 0); return Column( children: [ diff --git a/lib/features/dashboard/widgets/color_editor_overlay.dart b/lib/features/dashboard/widgets/color_editor_overlay.dart index b2453f6..7e344ed 100644 --- a/lib/features/dashboard/widgets/color_editor_overlay.dart +++ b/lib/features/dashboard/widgets/color_editor_overlay.dart @@ -7,6 +7,7 @@ import '../../../core/l10n/locale_provider.dart'; import '../../../core/services/card_color_service.dart'; import '../../../core/services/haptic_service.dart'; import '../../../core/utils/card_layout.dart'; +import '../../../shared/feature_flags/feature_flags_provider.dart'; import '../../settings/provider.dart'; import '../provider.dart'; import 'balance_card.dart'; @@ -42,6 +43,7 @@ class _FullScreenBlurOverlayState extends State { return Consumer( builder: (context, ref, _) { final cardHeight = ref.watch(cardHeightProvider); + final isPremium = ref.watch(featureFlagsProvider).canEditCardHeight; final heightDelta = (kBalanceCardHeight - cardHeight) / 2; final adjustedCardTop = cardTop + heightDelta; final panelTop = adjustedCardTop + cardHeight + layout.cardPreviewGap; @@ -90,7 +92,7 @@ class _FullScreenBlurOverlayState extends State { ? dash.tempDarkGradientType : dash.tempLightGradientType, cardHeight: cardHeight, - resizeHandle: _CornerResizeHandle( + resizeHandle: isPremium ? _CornerResizeHandle( cardHeight: cardHeight, onHeightChanged: (newHeight) { ref.read(cardHeightProvider.notifier).set(newHeight); @@ -98,7 +100,7 @@ class _FullScreenBlurOverlayState extends State { HapticService.selection(); } }, - ), + ) : null, ), ), ], diff --git a/lib/features/settings/screen.dart b/lib/features/settings/screen.dart index fdf2ac6..08b306f 100644 --- a/lib/features/settings/screen.dart +++ b/lib/features/settings/screen.dart @@ -14,6 +14,7 @@ import 'widgets/language_section.dart'; import 'widgets/currency_section.dart'; import 'widgets/amount_format_section.dart'; import 'widgets/categories_section.dart'; +import 'widgets/premium_section.dart'; class SettingsScreen extends ConsumerWidget { const SettingsScreen({super.key}); @@ -116,6 +117,8 @@ class SettingsScreen extends ConsumerWidget { physics: const ClampingScrollPhysics(), padding: const EdgeInsets.fromLTRB(20, 16, 20, 40), children: [ + const PremiumSection(), + const SizedBox(height: 16), const ThemeSection(), const SizedBox(height: 16), const CardTextColorSection(), diff --git a/lib/features/settings/widgets/premium_section.dart b/lib/features/settings/widgets/premium_section.dart new file mode 100644 index 0000000..76f1dad --- /dev/null +++ b/lib/features/settings/widgets/premium_section.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/constants.dart'; +import '../../../core/l10n/locale_provider.dart'; +import '../../../core/services/haptic_service.dart'; +import '../../../shared/providers/current_user_provider.dart'; +import '../../../shared/models/user_model.dart'; + +class PremiumSection extends ConsumerWidget { + const PremiumSection({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final s = ref.watch(stringsProvider); + final user = ref.watch(currentUserProvider); + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(16), + border: isDark + ? null + : Border.all(color: const Color(0xFFDDDDEE), width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: (user.isVip ? AppColors.accent : AppColors.warning) + .withOpacity(0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + user.isVip ? Icons.workspace_premium_rounded : Icons.lock_outline_rounded, + color: user.isVip ? AppColors.accent : AppColors.warning, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + s.premiumStatus, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + Text( + s.premiumDescription, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withOpacity(0.6), + ), + ), + ], + ), + ), + Switch( + value: user.isVip, + onChanged: (value) async { + HapticService.light(); + await ref.read(currentUserProvider.notifier).setPlan( + value ? UserPlan.vip : UserPlan.free, + ); + }, + activeThumbColor: const Color(0xFF7C6DED), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/shared/feature_flags/feature_flags.dart b/lib/shared/feature_flags/feature_flags.dart new file mode 100644 index 0000000..a1199a6 --- /dev/null +++ b/lib/shared/feature_flags/feature_flags.dart @@ -0,0 +1,6 @@ +abstract class FeatureFlags { + bool get canEditCardColors; + bool get canEditCardHeight; + bool get canEditCardTextColor; + int get maxAccounts; +} diff --git a/lib/shared/feature_flags/feature_flags_provider.dart b/lib/shared/feature_flags/feature_flags_provider.dart new file mode 100644 index 0000000..416ca67 --- /dev/null +++ b/lib/shared/feature_flags/feature_flags_provider.dart @@ -0,0 +1,12 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../providers/current_user_provider.dart'; +import 'feature_flags.dart'; +import 'free_feature_flags.dart'; +import 'vip_feature_flags.dart'; + +final featureFlagsProvider = Provider((ref) { + final user = ref.watch(currentUserProvider); + return user.isVip + ? const VipFeatureFlags() + : const FreeFeatureFlags(); +}); diff --git a/lib/shared/feature_flags/free_feature_flags.dart b/lib/shared/feature_flags/free_feature_flags.dart new file mode 100644 index 0000000..89fdbfd --- /dev/null +++ b/lib/shared/feature_flags/free_feature_flags.dart @@ -0,0 +1,17 @@ +import 'feature_flags.dart'; + +class FreeFeatureFlags implements FeatureFlags { + const FreeFeatureFlags(); + + @override + bool get canEditCardColors => false; + + @override + bool get canEditCardHeight => false; + + @override + bool get canEditCardTextColor => false; + + @override + int get maxAccounts => 3; +} diff --git a/lib/shared/feature_flags/vip_feature_flags.dart b/lib/shared/feature_flags/vip_feature_flags.dart new file mode 100644 index 0000000..81b738b --- /dev/null +++ b/lib/shared/feature_flags/vip_feature_flags.dart @@ -0,0 +1,17 @@ +import 'feature_flags.dart'; + +class VipFeatureFlags implements FeatureFlags { + const VipFeatureFlags(); + + @override + bool get canEditCardColors => true; + + @override + bool get canEditCardHeight => true; + + @override + bool get canEditCardTextColor => true; + + @override + int get maxAccounts => 8; +} diff --git a/lib/shared/models/user_model.dart b/lib/shared/models/user_model.dart new file mode 100644 index 0000000..df2debb --- /dev/null +++ b/lib/shared/models/user_model.dart @@ -0,0 +1,9 @@ +enum UserPlan { free, vip } + +class UserModel { + final UserPlan plan; + + const UserModel({this.plan = UserPlan.free}); + + bool get isVip => plan == UserPlan.vip; +} diff --git a/lib/shared/paywall/paywall_banner.dart b/lib/shared/paywall/paywall_banner.dart new file mode 100644 index 0000000..b26e240 --- /dev/null +++ b/lib/shared/paywall/paywall_banner.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/l10n/app_strings.dart'; +import '../../core/l10n/locale_provider.dart'; + +class PaywallBanner extends ConsumerWidget { + final String? message; + final VoidCallback? onTap; + + const PaywallBanner({ + super.key, + this.message, + this.onTap, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final s = AppStrings(ref.watch(localeProvider)); + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.15), + width: 1, + ), + ), + child: Row( + children: [ + Icon( + Icons.lock_outline_rounded, + size: 20, + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + message ?? s.premiumFeatureLocked, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withOpacity(0.6), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/shared/paywall/paywall_guard.dart b/lib/shared/paywall/paywall_guard.dart new file mode 100644 index 0000000..ec7a091 --- /dev/null +++ b/lib/shared/paywall/paywall_guard.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'paywall_banner.dart'; + +class PaywallGuard extends ConsumerWidget { + final bool canAccess; + final Widget child; + final Widget? fallback; + + const PaywallGuard({ + super.key, + required this.canAccess, + required this.child, + this.fallback, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (canAccess) return child; + return fallback ?? PaywallBanner(); + } +} diff --git a/lib/shared/paywall/paywall_screen.dart b/lib/shared/paywall/paywall_screen.dart new file mode 100644 index 0000000..caa1b06 --- /dev/null +++ b/lib/shared/paywall/paywall_screen.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../core/l10n/app_strings.dart'; +import '../../core/l10n/locale_provider.dart'; + +class PaywallScreen extends ConsumerWidget { + const PaywallScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final s = AppStrings(ref.watch(localeProvider)); + + return Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + appBar: AppBar( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + elevation: 0, + scrolledUnderElevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_rounded), + onPressed: () => context.pop(), + ), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: const Color(0xFF7C6DED).withOpacity(0.12), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.workspace_premium_rounded, + size: 64, + color: Color(0xFF7C6DED), + ), + ), + const SizedBox(height: 24), + Text( + s.premium, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + Text( + s.premiumDescription, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withOpacity(0.6), + ), + ), + const SizedBox(height: 32), + _FeatureItem(icon: Icons.palette_rounded, label: s.premiumFeatureColors), + _FeatureItem(icon: Icons.height_rounded, label: s.premiumFeatureHeight), + _FeatureItem(icon: Icons.account_balance_wallet_rounded, label: s.premiumFeatureAccounts), + ], + ), + ), + ), + ); + } +} + +class _FeatureItem extends StatelessWidget { + final IconData icon; + final String label; + + const _FeatureItem({required this.icon, required this.label}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Icon(icon, size: 20, color: const Color(0xFF7C6DED)), + const SizedBox(width: 12), + Text(label, style: Theme.of(context).textTheme.bodyMedium), + ], + ), + ); + } +} diff --git a/lib/shared/providers/current_user_provider.dart b/lib/shared/providers/current_user_provider.dart new file mode 100644 index 0000000..5ee36e4 --- /dev/null +++ b/lib/shared/providers/current_user_provider.dart @@ -0,0 +1,32 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../features/dashboard/provider.dart'; +import '../models/user_model.dart'; + +class CurrentUserNotifier extends Notifier { + static const _key = 'user_plan'; + + @override + UserModel build() { + final prefs = ref.watch(sharedPreferencesProvider); + final planName = prefs.getString(_key); + final plan = UserPlan.values.firstWhere( + (e) => e.name == planName, + orElse: () => UserPlan.free, + ); + return UserModel(plan: plan); + } + + Future setPlan(UserPlan plan) async { + final prefs = ref.read(sharedPreferencesProvider); + state = UserModel(plan: plan); + await prefs.setString(_key, plan.name); + } + + Future toggleVip() async { + await setPlan(state.isVip ? UserPlan.free : UserPlan.vip); + } +} + +final currentUserProvider = NotifierProvider( + CurrentUserNotifier.new, +);