diff --git a/lib/app/router.dart b/lib/app/router.dart index a06e5b0..6e82e64 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -12,6 +12,8 @@ import '../shared/models/transaction.dart'; import '../shared/paywall/paywall_screen.dart'; import '../shared/services/onboarding_service.dart'; import '../shared/widgets/pro_screen.dart'; +import '../shared/widgets/backup_screen.dart'; +import '../shared/providers/premium_provider.dart'; final _shellKey = GlobalKey(); @@ -72,6 +74,15 @@ final appRouter = GoRouter( path: '/pro', builder: (context, state) => const ProScreen(), ), + GoRoute( + path: '/backup', + redirect: (context, state) { + final container = ProviderScope.containerOf(context); + final isPremium = container.read(isPremiumProvider); + return isPremium ? null : '/pro'; + }, + builder: (context, state) => const BackupScreen(), + ), ], ); diff --git a/lib/core/l10n/app_strings.dart b/lib/core/l10n/app_strings.dart index 309cb37..e07751d 100644 --- a/lib/core/l10n/app_strings.dart +++ b/lib/core/l10n/app_strings.dart @@ -332,4 +332,41 @@ class AppStrings { _ru ? 'Предыдущие покупки не найдены' : 'No previous purchases found'; String get proTapToClose => _ru ? 'Нажмите чтобы закрыть' : 'Tap anywhere to close'; + String get backupTitle => _ru ? 'Резервная копия' : 'Backup'; + String get backupCreate => _ru ? 'Создать резервную копию' : 'Create Backup'; + String get backupRestore => _ru ? 'Восстановить из копии' : 'Restore Backup'; + String get backupCreating => _ru ? 'Создание копии...' : 'Creating backup...'; + String get backupRestoring => _ru ? 'Восстановление...' : 'Restoring...'; + String get backupSuccess => + _ru ? 'Резервная копия успешно создана!' : 'Backup created successfully!'; + String get backupRestoreSuccess => + _ru ? 'Данные успешно восстановлены!' : 'Data restored successfully!'; + String get backupRestoreFailed => + _ru ? 'Не удалось восстановить данные' : 'Failed to restore data'; + String get backupNoFileFound => + _ru ? 'Резервная копия не найдена' : 'No backup file found'; + String get backupTokenMismatch => + _ru + ? 'Этот файл резервной копии принадлежит другому покупателю Premium' + : 'This backup file belongs to another Premium purchaser'; + String get backupInvalidFormat => + _ru ? 'Неверный формат файла резервной копии' : 'Invalid backup file format'; + String get backupNoToken => + _ru + ? 'Файл резервной копии не содержит токен покупки' + : 'Backup file does not contain a purchase token'; + String get backupRequiresPremium => + _ru ? 'Резервная копия доступна только для Pro' : 'Backup is a Pro-only feature'; + String get backupRequiresSignIn => + _ru + ? 'Войдите в Google аккаунт для работы с резервными копиями' + : 'Sign in to Google to manage backups'; + String get backupLastBackup => _ru ? 'Последняя копия' : 'Last backup'; + String get backupNever => _ru ? 'Никогда' : 'Never'; + String get backupSyncWithDrive => + _ru ? 'Синхронизация с Google Диском' : 'Google Drive Sync'; + String get backupSyncDesc => + _ru + ? 'Резервное копирование данных на ваш Google Диск' + : 'Back up your data to your Google Drive'; } diff --git a/lib/features/onboarding/screen.dart b/lib/features/onboarding/screen.dart index 8638e07..fec4670 100644 --- a/lib/features/onboarding/screen.dart +++ b/lib/features/onboarding/screen.dart @@ -30,7 +30,7 @@ class _OnboardingScreenState extends ConsumerState { if (page == 4) { HapticService.medium(); ref.read(onboardingProvider.notifier).complete(); - Future.delayed(const Duration(milliseconds: 400), () { + Future.delayed(const Duration(milliseconds: 150), () { if (mounted) context.go('/dashboard'); }); } diff --git a/lib/features/settings/screen.dart b/lib/features/settings/screen.dart index 80401a5..9ee4736 100644 --- a/lib/features/settings/screen.dart +++ b/lib/features/settings/screen.dart @@ -117,22 +117,23 @@ class SettingsScreen extends ConsumerWidget { padding: const EdgeInsets.fromLTRB(20, 16, 20, 40), children: [ const ProSubscriptionCard(), - const SizedBox(height: 16), - const ThemeSection(), - const SizedBox(height: 16), - const CardTextColorSection(), - const SizedBox(height: 16), - const HapticSection(), - const SizedBox(height: 16), - const CurrencyConversionsSection(), - const SizedBox(height: 16), - const _BiometricSection(), - const LanguageSection(), - const SizedBox(height: 16), + const SizedBox(height: 12), const CurrencySection(), - const SizedBox(height: 16), + const SizedBox(height: 12), + const ThemeSection(), + const SizedBox(height: 12), + const LanguageSection(), + const SizedBox(height: 12), + const _BiometricSection(), + const SizedBox(height: 12), + const CardTextColorSection(), + const SizedBox(height: 12), + const HapticSection(), + const SizedBox(height: 12), const AmountFormatSection(), - const SizedBox(height: 16), + const SizedBox(height: 12), + const CurrencyConversionsSection(), + const SizedBox(height: 12), const CategoriesSection(), const SizedBox(height: 24), Text( diff --git a/lib/features/settings/widgets/card_text_color_section.dart b/lib/features/settings/widgets/card_text_color_section.dart index a243368..0bacbf6 100644 --- a/lib/features/settings/widgets/card_text_color_section.dart +++ b/lib/features/settings/widgets/card_text_color_section.dart @@ -14,7 +14,7 @@ class CardTextColorSection extends ConsumerWidget { final isDark = Theme.of(context).brightness == Brightness.dark; return Container( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(16), @@ -49,7 +49,7 @@ class CardTextColorSection extends ConsumerWidget { ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 12), Row( children: [ Expanded( @@ -113,7 +113,7 @@ class _CardTextColorOption extends StatelessWidget { onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(vertical: 12), + padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( color: isSelected ? AppColors.accent.withOpacity(0.15) diff --git a/lib/features/settings/widgets/currency_section.dart b/lib/features/settings/widgets/currency_section.dart index 54aaf97..2be0645 100644 --- a/lib/features/settings/widgets/currency_section.dart +++ b/lib/features/settings/widgets/currency_section.dart @@ -15,7 +15,7 @@ class CurrencySection extends ConsumerWidget { final isDark = Theme.of(context).brightness == Brightness.dark; return Container( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(16), @@ -52,7 +52,7 @@ class CurrencySection extends ConsumerWidget { ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 12), Row( children: ['USD', 'EUR', 'BYN', 'RUB'].map((code) { final info = currencyMap[code]!; @@ -65,7 +65,7 @@ class CurrencySection extends ConsumerWidget { ref.read(currencyProvider.notifier).setCurrency(code); }, child: Container( - padding: const EdgeInsets.symmetric(vertical: 12), + padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( color: isSelected ? AppColors.accent.withOpacity(0.2) @@ -83,12 +83,18 @@ class CurrencySection extends ConsumerWidget { child: Column( children: [ code == 'BYN' - ? BynSign( - fontSize: 28, - color: isSelected - ? AppColors.accent - : Theme.of(context).colorScheme.onSurface - .withOpacity(0.6), + ? SizedBox( + height: 28, + child: Align( + alignment: Alignment.center, + child: BynSign( + fontSize: 24, + color: isSelected + ? AppColors.accent + : Theme.of(context).colorScheme.onSurface + .withOpacity(0.6), + ), + ), ) : Text( info.symbol, diff --git a/lib/features/settings/widgets/theme_section.dart b/lib/features/settings/widgets/theme_section.dart index 7fe17d7..acc1089 100644 --- a/lib/features/settings/widgets/theme_section.dart +++ b/lib/features/settings/widgets/theme_section.dart @@ -14,7 +14,7 @@ class ThemeSection extends ConsumerWidget { final isDark = Theme.of(context).brightness == Brightness.dark; return Container( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(16), @@ -51,7 +51,7 @@ class ThemeSection extends ConsumerWidget { ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 12), Row( children: [ Expanded( @@ -109,7 +109,7 @@ class _ThemeOption extends StatelessWidget { onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(vertical: 12), + padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( color: isSelected ? AppColors.accent.withOpacity(0.15) diff --git a/lib/main.dart b/lib/main.dart index 770253b..022959b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,9 @@ import 'core/services/haptic_service.dart'; import 'data/database/app_database.dart'; import 'features/dashboard/provider.dart'; import 'shared/services/onboarding_service.dart'; +import 'shared/services/billing_service.dart'; +import 'shared/services/premium_manager.dart'; +import 'shared/providers/billing_provider.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -22,11 +25,16 @@ void main() async { final database = AppDatabase(); + final billing = PlayBillingService(); + final premiumManager = PremiumManager(prefs, billing); + await premiumManager.autoRestore(); + runApp( ProviderScope( overrides: [ sharedPreferencesProvider.overrideWithValue(prefs), appDatabaseProvider.overrideWithValue(database), + billingServiceProvider.overrideWithValue(billing), ], child: const App(), ), diff --git a/lib/shared/providers/backup_provider.dart b/lib/shared/providers/backup_provider.dart new file mode 100644 index 0000000..0589044 --- /dev/null +++ b/lib/shared/providers/backup_provider.dart @@ -0,0 +1,8 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../services/backup_service.dart'; +import 'premium_provider.dart'; + +final backupServiceProvider = Provider((ref) { + final token = ref.watch(purchaseTokenProvider) ?? ''; + return BackupService(token); +}); diff --git a/lib/shared/providers/billing_provider.dart b/lib/shared/providers/billing_provider.dart index 4361573..bed05e5 100644 --- a/lib/shared/providers/billing_provider.dart +++ b/lib/shared/providers/billing_provider.dart @@ -1,8 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../features/dashboard/provider.dart'; import '../services/billing_service.dart'; final billingServiceProvider = Provider((ref) { - final prefs = ref.watch(sharedPreferencesProvider); - return MockBillingService(prefs); + return PlayBillingService(); }); diff --git a/lib/shared/providers/current_user_provider.dart b/lib/shared/providers/current_user_provider.dart index 5ee36e4..888c622 100644 --- a/lib/shared/providers/current_user_provider.dart +++ b/lib/shared/providers/current_user_provider.dart @@ -1,29 +1,27 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../features/dashboard/provider.dart'; import '../models/user_model.dart'; +import '../services/premium_manager.dart'; +import 'billing_provider.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); + final billing = ref.watch(billingServiceProvider); + final manager = PremiumManager(prefs, billing); + return UserModel(plan: manager.currentPlan); } 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); + Future refreshFromPremium() async { + final prefs = ref.read(sharedPreferencesProvider); + final billing = ref.read(billingServiceProvider); + final manager = PremiumManager(prefs, billing); + state = UserModel(plan: manager.currentPlan); } } diff --git a/lib/shared/providers/google_drive_provider.dart b/lib/shared/providers/google_drive_provider.dart new file mode 100644 index 0000000..878151d --- /dev/null +++ b/lib/shared/providers/google_drive_provider.dart @@ -0,0 +1,18 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_sign_in/google_sign_in.dart'; +import '../services/google_drive_service.dart'; + +final googleDriveServiceProvider = Provider((ref) { + final signIn = GoogleSignIn( + scopes: [ + 'email', + 'https://www.googleapis.com/auth/drive.appdata', + ], + ); + return GoogleDriveService(signIn); +}); + +final googleDriveUserProvider = StreamProvider((ref) { + final service = ref.watch(googleDriveServiceProvider); + return service.onUserChanged; +}); diff --git a/lib/shared/providers/premium_provider.dart b/lib/shared/providers/premium_provider.dart new file mode 100644 index 0000000..4fcd57c --- /dev/null +++ b/lib/shared/providers/premium_provider.dart @@ -0,0 +1,20 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../features/dashboard/provider.dart'; +import '../services/premium_manager.dart'; +import 'billing_provider.dart'; + +final premiumManagerProvider = Provider((ref) { + final prefs = ref.watch(sharedPreferencesProvider); + final billing = ref.watch(billingServiceProvider); + return PremiumManager(prefs, billing); +}); + +final isPremiumProvider = Provider((ref) { + final manager = ref.watch(premiumManagerProvider); + return manager.isPremium; +}); + +final purchaseTokenProvider = Provider((ref) { + final manager = ref.watch(premiumManagerProvider); + return manager.purchaseToken; +}); diff --git a/lib/shared/services/backup_service.dart b/lib/shared/services/backup_service.dart new file mode 100644 index 0000000..194dea6 --- /dev/null +++ b/lib/shared/services/backup_service.dart @@ -0,0 +1,72 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +class BackupData { + final String ownerPurchaseToken; + final DateTime createdAt; + final Map payload; + + const BackupData({ + required this.ownerPurchaseToken, + required this.createdAt, + required this.payload, + }); + + Map toJson() => { + 'owner_purchase_token': ownerPurchaseToken, + 'created_at': createdAt.toIso8601String(), + 'payload': payload, + }; + + factory BackupData.fromJson(Map json) { + return BackupData( + ownerPurchaseToken: json['owner_purchase_token'] as String? ?? '', + createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ?? + DateTime.now(), + payload: json['payload'] as Map? ?? {}, + ); + } +} + +enum BackupVerifyResult { ok, tokenMismatch, noToken, invalidFormat } + +class BackupService { + String _currentPurchaseToken; + + BackupService(this._currentPurchaseToken); + + void updatePurchaseToken(String token) { + _currentPurchaseToken = token; + } + + String get currentPurchaseToken => _currentPurchaseToken; + + Uint8List createBackup(Map payload) { + final data = BackupData( + ownerPurchaseToken: _currentPurchaseToken, + createdAt: DateTime.now(), + payload: payload, + ); + final json = jsonEncode(data.toJson()); + return Uint8List.fromList(utf8.encode(json)); + } + + (BackupVerifyResult, BackupData?) verifyAndParse(Uint8List raw) { + try { + final json = jsonDecode(utf8.decode(raw)) as Map; + final data = BackupData.fromJson(json); + + if (data.ownerPurchaseToken.isEmpty) { + return (BackupVerifyResult.noToken, null); + } + + if (data.ownerPurchaseToken != _currentPurchaseToken) { + return (BackupVerifyResult.tokenMismatch, null); + } + + return (BackupVerifyResult.ok, data); + } catch (e) { + return (BackupVerifyResult.invalidFormat, null); + } + } +} diff --git a/lib/shared/services/billing_service.dart b/lib/shared/services/billing_service.dart index 0b982a7..3c337d0 100644 --- a/lib/shared/services/billing_service.dart +++ b/lib/shared/services/billing_service.dart @@ -1,34 +1,192 @@ -import 'package:shared_preferences/shared_preferences.dart'; -import '../models/user_model.dart'; +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; + +class PurchaseResult { + final bool success; + final String? purchaseToken; + final String? error; + + const PurchaseResult({this.success = false, this.purchaseToken, this.error}); + + factory PurchaseResult.ok(String token) => + PurchaseResult(success: true, purchaseToken: token); + + factory PurchaseResult.failed([String? error]) => + PurchaseResult(success: false, error: error); +} abstract class BillingService { - Future purchasePro(); - Future restorePurchases(); - Future getCurrentPlan(); + static const proProductId = 'casha_pro_lifetime'; + + Future purchasePro(); + Future restorePurchases(); + Future queryPastPurchase(); + Future completePurchase(String purchaseToken); + Stream> get purchaseStream; + Future dispose(); } -class MockBillingService implements BillingService { - static const _key = 'user_plan'; - final SharedPreferences _prefs; +class PlayBillingService implements BillingService { + final InAppPurchase _inAppPurchase = InAppPurchase.instance; + late final StreamSubscription> _sub; + final _controller = StreamController>.broadcast(); - MockBillingService(this._prefs); - - @override - Future purchasePro() async { - await Future.delayed(const Duration(seconds: 1)); - await _prefs.setString(_key, 'vip'); - return true; + PlayBillingService() { + _sub = _inAppPurchase.purchaseStream.listen((purchases) { + _controller.add(purchases); + }); } @override - Future restorePurchases() async { - await Future.delayed(const Duration(seconds: 1)); - return false; + Stream> get purchaseStream => _controller.stream; + + @override + Future purchasePro() async { + final available = await _inAppPurchase.isAvailable(); + if (!available) { + return PurchaseResult.failed('Billing not available'); + } + + final response = await _inAppPurchase.queryProductDetails( + {BillingService.proProductId}, + ); + if (response.productDetails.isEmpty) { + return PurchaseResult.failed('Product not found'); + } + + final product = response.productDetails.first; + final purchaseParam = PurchaseParam(productDetails: product); + + final started = await _inAppPurchase.buyNonConsumable( + purchaseParam: purchaseParam, + ); + if (!started) { + return PurchaseResult.failed('Could not start purchase'); + } + + final completer = Completer(); + late StreamSubscription sub; + sub = purchaseStream.timeout( + const Duration(seconds: 60), + onTimeout: (sink) { + if (!completer.isCompleted) { + completer.complete(PurchaseResult.failed('Purchase timed out')); + } + sub.cancel(); + }, + ).listen((purchases) { + for (final p in purchases) { + if (p.productID == BillingService.proProductId && + p.status == PurchaseStatus.purchased) { + if (!completer.isCompleted) { + completer.complete(PurchaseResult.ok(p.verificationData.serverVerificationData)); + } + sub.cancel(); + return; + } + if (p.status == PurchaseStatus.error) { + if (!completer.isCompleted) { + completer.complete(PurchaseResult.failed(p.error?.message)); + } + sub.cancel(); + return; + } + } + }); + + return completer.future; } @override - Future getCurrentPlan() async { - final value = _prefs.getString(_key); - return value == 'vip' ? UserPlan.vip : UserPlan.free; + Future restorePurchases() async { + final available = await _inAppPurchase.isAvailable(); + if (!available) { + return PurchaseResult.failed('Billing not available'); + } + + await _inAppPurchase.restorePurchases(); + + final completer = Completer(); + late StreamSubscription sub; + sub = purchaseStream.timeout( + const Duration(seconds: 15), + onTimeout: (sink) { + if (!completer.isCompleted) { + completer.complete(PurchaseResult.failed('Restore timed out')); + } + sub.cancel(); + }, + ).listen((purchases) { + for (final p in purchases) { + if (p.productID == BillingService.proProductId && + (p.status == PurchaseStatus.restored || + p.status == PurchaseStatus.purchased)) { + if (!completer.isCompleted) { + completer.complete(PurchaseResult.ok(p.verificationData.serverVerificationData)); + } + sub.cancel(); + return; + } + } + }); + + return completer.future; + } + + @override + Future queryPastPurchase() async { + final available = await _inAppPurchase.isAvailable(); + if (!available) { + return const PurchaseResult(); + } + + final response = await _inAppPurchase.queryProductDetails( + {BillingService.proProductId}, + ); + if (response.productDetails.isEmpty) { + return const PurchaseResult(); + } + + final completer = Completer(); + late StreamSubscription sub; + sub = purchaseStream.timeout( + const Duration(seconds: 10), + onTimeout: (sink) { + if (!completer.isCompleted) { + completer.complete(const PurchaseResult()); + } + sub.cancel(); + }, + ).listen((purchases) { + for (final p in purchases) { + if (p.productID == BillingService.proProductId && + (p.status == PurchaseStatus.restored || + p.status == PurchaseStatus.purchased)) { + if (!completer.isCompleted) { + completer.complete(PurchaseResult.ok(p.verificationData.serverVerificationData)); + } + sub.cancel(); + return; + } + } + }); + + await _inAppPurchase.restorePurchases(); + + return completer.future; + } + + @override + Future completePurchase(String purchaseToken) async { + if (kDebugMode) { + print('completePurchase: $purchaseToken'); + } + } + + @override + Future dispose() async { + await _sub.cancel(); + await _controller.close(); } } diff --git a/lib/shared/services/google_auth_service.dart b/lib/shared/services/google_auth_service.dart index 37760a1..7012131 100644 --- a/lib/shared/services/google_auth_service.dart +++ b/lib/shared/services/google_auth_service.dart @@ -4,7 +4,10 @@ import 'package:google_sign_in/google_sign_in.dart'; class GoogleAuthService { final GoogleSignIn _googleSignIn; - GoogleAuthService() : _googleSignIn = GoogleSignIn(scopes: ['email']); + GoogleAuthService() : _googleSignIn = GoogleSignIn(scopes: [ + 'email', + 'https://www.googleapis.com/auth/drive.appdata', + ]); GoogleSignInAccount? get currentUser => _googleSignIn.currentUser; diff --git a/lib/shared/services/google_drive_service.dart b/lib/shared/services/google_drive_service.dart new file mode 100644 index 0000000..001c788 --- /dev/null +++ b/lib/shared/services/google_drive_service.dart @@ -0,0 +1,147 @@ +import 'dart:typed_data'; +import 'package:googleapis/drive/v3.dart' as drive; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:extension_google_sign_in_as_googleapis_auth/extension_google_sign_in_as_googleapis_auth.dart'; + +class DriveBackupResult { + final bool success; + final String? error; + final String? fileId; + final DateTime? modifiedTime; + + const DriveBackupResult({this.success = false, this.error, this.fileId, this.modifiedTime}); + + factory DriveBackupResult.ok(String fileId, DateTime modifiedTime) => + DriveBackupResult(success: true, fileId: fileId, modifiedTime: modifiedTime); + + factory DriveBackupResult.failed(String error) => + DriveBackupResult(success: false, error: error); +} + +class GoogleDriveService { + static const _fileName = 'casha_backup.json'; + static const _appDataFolder = 'appDataFolder'; + + final GoogleSignIn _googleSignIn; + + GoogleDriveService(this._googleSignIn); + + GoogleSignInAccount? get currentUser => _googleSignIn.currentUser; + Stream get onUserChanged => _googleSignIn.onCurrentUserChanged; + + Future signIn() async { + await _googleSignIn.signIn(); + } + + Future signOut() async { + await _googleSignIn.signOut(); + } + + Future _getDriveApi() async { + if (_googleSignIn.currentUser == null) return null; + final httpClient = await _googleSignIn.authenticatedClient(); + if (httpClient == null) return null; + return drive.DriveApi(httpClient); + } + + Future _findExistingFile(drive.DriveApi api) async { + final list = await api.files.list( + spaces: _appDataFolder, + q: "name = '$_fileName' and trashed = false", + $fields: 'files(id, name, modifiedTime)', + ); + return list.files?.isNotEmpty == true ? list.files!.first.id : null; + } + + Future uploadBackup(Uint8List data) async { + final api = await _getDriveApi(); + if (api == null) { + return DriveBackupResult.failed('Not authenticated'); + } + + try { + final existingId = await _findExistingFile(api); + + final media = drive.Media( + Stream>.fromIterable([data.toList()]), + data.length, + ); + + if (existingId != null) { + final file = drive.File( + name: _fileName, + modifiedTime: DateTime.now(), + ); + final updated = await api.files.update( + file, + existingId, + uploadMedia: media, + $fields: 'id, modifiedTime', + ); + return DriveBackupResult.ok( + updated.id!, + updated.modifiedTime!, + ); + } else { + final file = drive.File( + name: _fileName, + parents: [_appDataFolder], + modifiedTime: DateTime.now(), + ); + final created = await api.files.create( + file, + uploadMedia: media, + $fields: 'id, modifiedTime', + ); + return DriveBackupResult.ok( + created.id!, + created.modifiedTime!, + ); + } + } catch (e) { + return DriveBackupResult.failed(e.toString()); + } + } + + Future downloadBackup() async { + final api = await _getDriveApi(); + if (api == null) return null; + + try { + final fileId = await _findExistingFile(api); + if (fileId == null) return null; + + final media = await api.files.get( + fileId, + downloadOptions: drive.DownloadOptions.fullMedia, + ) as drive.Media; + + final bytes = []; + await for (final chunk in media.stream) { + bytes.addAll(chunk); + } + return Uint8List.fromList(bytes); + } catch (e) { + return null; + } + } + + Future getLastBackupTime() async { + final api = await _getDriveApi(); + if (api == null) return null; + + try { + final list = await api.files.list( + spaces: _appDataFolder, + q: "name = '$_fileName' and trashed = false", + $fields: 'files(id, name, modifiedTime)', + ); + if (list.files?.isNotEmpty == true) { + return list.files!.first.modifiedTime; + } + return null; + } catch (e) { + return null; + } + } +} diff --git a/lib/shared/services/premium_manager.dart b/lib/shared/services/premium_manager.dart new file mode 100644 index 0000000..59ed7ca --- /dev/null +++ b/lib/shared/services/premium_manager.dart @@ -0,0 +1,58 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/user_model.dart'; +import 'billing_service.dart'; + +class PremiumManager { + static const _keyIsPremium = 'is_premium'; + static const _keyPurchaseToken = 'purchase_token'; + + final SharedPreferences _prefs; + final BillingService _billing; + + PremiumManager(this._prefs, this._billing); + + bool get isPremium => _prefs.getBool(_keyIsPremium) ?? false; + String? get purchaseToken => _prefs.getString(_keyPurchaseToken); + + Future _setPremium(bool value, String? token) async { + await _prefs.setBool(_keyIsPremium, value); + if (token != null) { + await _prefs.setString(_keyPurchaseToken, token); + } else if (!value) { + await _prefs.remove(_keyPurchaseToken); + } + } + + Future purchase() async { + final result = await _billing.purchasePro(); + if (result.success && result.purchaseToken != null) { + await _setPremium(true, result.purchaseToken); + await _billing.completePurchase(result.purchaseToken!); + } + return result; + } + + Future restore() async { + final result = await _billing.restorePurchases(); + if (result.success && result.purchaseToken != null) { + await _setPremium(true, result.purchaseToken); + await _billing.completePurchase(result.purchaseToken!); + } + return result; + } + + Future autoRestore() async { + if (isPremium) return; + final result = await _billing.queryPastPurchase(); + if (result.success && result.purchaseToken != null) { + await _setPremium(true, result.purchaseToken); + await _billing.completePurchase(result.purchaseToken!); + } + } + + UserPlan get currentPlan => isPremium ? UserPlan.vip : UserPlan.free; + + Future clear() async { + await _setPremium(false, null); + } +} diff --git a/lib/shared/widgets/backup_screen.dart b/lib/shared/widgets/backup_screen.dart new file mode 100644 index 0000000..41d70d9 --- /dev/null +++ b/lib/shared/widgets/backup_screen.dart @@ -0,0 +1,360 @@ +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'; +import '../../core/services/haptic_service.dart'; +import '../providers/backup_provider.dart'; +import '../providers/google_drive_provider.dart'; +import '../providers/premium_provider.dart'; +import 'error_snackbar.dart'; +import '../services/backup_service.dart'; + +class BackupScreen extends ConsumerStatefulWidget { + const BackupScreen({super.key}); + + @override + ConsumerState createState() => _BackupScreenState(); +} + +class _BackupScreenState extends ConsumerState { + bool _backingUp = false; + bool _restoring = false; + DateTime? _lastBackupTime; + + @override + void initState() { + super.initState(); + _loadLastBackupTime(); + } + + Future _loadLastBackupTime() async { + final service = ref.read(googleDriveServiceProvider); + final time = await service.getLastBackupTime(); + if (mounted) { + setState(() => _lastBackupTime = time); + } + } + + Future _handleBackup() async { + final s = ref.read(stringsProvider); + final driveService = ref.read(googleDriveServiceProvider); + + if (driveService.currentUser == null) { + showErrorSnackbar(context, s.backupRequiresSignIn); + return; + } + + HapticService.light(); + setState(() => _backingUp = true); + + try { + final backupService = ref.read(backupServiceProvider); + final payload = { + 'version': 1, + 'exported_at': DateTime.now().toIso8601String(), + }; + final data = backupService.createBackup(payload); + final result = await driveService.uploadBackup(data); + + if (result.success) { + setState(() => _lastBackupTime = result.modifiedTime); + HapticService.medium(); + if (mounted) { + showSuccessSnackbar(context, s.backupSuccess); + } + } else { + if (mounted) { + showErrorSnackbar(context, result.error ?? s.backupRestoreFailed); + } + } + } catch (e) { + if (mounted) { + showErrorSnackbar(context, e.toString()); + } + } finally { + if (mounted) setState(() => _backingUp = false); + } + } + + Future _handleRestore() async { + final s = ref.read(stringsProvider); + final driveService = ref.read(googleDriveServiceProvider); + + if (driveService.currentUser == null) { + showErrorSnackbar(context, s.backupRequiresSignIn); + return; + } + + HapticService.light(); + setState(() => _restoring = true); + + try { + final raw = await driveService.downloadBackup(); + if (raw == null) { + if (mounted) { + showWarningSnackbar(context, s.backupNoFileFound); + } + return; + } + + final backupService = ref.read(backupServiceProvider); + final (result, data) = backupService.verifyAndParse(raw); + + switch (result) { + case BackupVerifyResult.ok: + HapticService.medium(); + if (mounted) { + showSuccessSnackbar(context, s.backupRestoreSuccess); + } + case BackupVerifyResult.tokenMismatch: + if (mounted) { + showErrorSnackbar(context, s.backupTokenMismatch); + } + case BackupVerifyResult.noToken: + if (mounted) { + showErrorSnackbar(context, s.backupNoToken); + } + case BackupVerifyResult.invalidFormat: + if (mounted) { + showErrorSnackbar(context, s.backupInvalidFormat); + } + } + } catch (e) { + if (mounted) { + showErrorSnackbar(context, e.toString()); + } + } finally { + if (mounted) setState(() => _restoring = false); + } + } + + @override + Widget build(BuildContext context) { + final s = ref.watch(stringsProvider); + final isPremium = ref.watch(isPremiumProvider); + final driveUserAsync = ref.watch(googleDriveUserProvider); + final driveUser = driveUserAsync.value; + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + body: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 16, 0), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_rounded), + onPressed: () => context.pop(), + ), + Text( + s.backupTitle, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isPremium) ...[ + _buildLockedState(context, s, colorScheme), + ] else ...[ + _buildSyncStatus(context, s, colorScheme, driveUser), + const SizedBox(height: 20), + _buildBackupActions(context, s, colorScheme, driveUser), + const SizedBox(height: 20), + _buildLastBackup(context, s, colorScheme), + ], + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildLockedState( + BuildContext context, + AppStrings s, + ColorScheme colorScheme, + ) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Column( + children: [ + Icon(Icons.lock_outline_rounded, size: 48, color: colorScheme.primary), + const SizedBox(height: 16), + Text( + s.backupRequiresPremium, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () => context.push('/pro'), + child: Text(s.proBuy), + ), + ], + ), + ); + } + + Widget _buildSyncStatus( + BuildContext context, + AppStrings s, + ColorScheme colorScheme, + dynamic driveUser, + ) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + driveUser != null + ? Icons.cloud_done_rounded + : Icons.cloud_off_rounded, + color: driveUser != null ? colorScheme.primary : colorScheme.onSurface.withOpacity(0.4), + size: 24, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + driveUser != null + ? s.proSyncEnabled + : s.backupRequiresSignIn, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + if (driveUser != null) ...[ + const SizedBox(height: 8), + Text( + driveUser.email, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withOpacity(0.6), + ), + ), + ], + ], + ), + ); + } + + Widget _buildBackupActions( + BuildContext context, + AppStrings s, + ColorScheme colorScheme, + dynamic driveUser, + ) { + final disabled = driveUser == null; + return Column( + children: [ + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: (disabled || _backingUp) ? null : _handleBackup, + icon: _backingUp + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.onPrimary, + ), + ) + : const Icon(Icons.backup_outlined), + label: Text(_backingUp ? s.backupCreating : s.backupCreate), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: (disabled || _restoring) ? null : _handleRestore, + icon: _restoring + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, + ), + ) + : const Icon(Icons.restore_rounded), + label: Text(_restoring ? s.backupRestoring : s.backupRestore), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ); + } + + Widget _buildLastBackup( + BuildContext context, + AppStrings s, + ColorScheme colorScheme, + ) { + return Row( + children: [ + Icon( + Icons.schedule_rounded, + size: 18, + color: colorScheme.onSurface.withOpacity(0.5), + ), + const SizedBox(width: 8), + Text( + '${s.backupLastBackup}: ${_lastBackupTime != null ? _formatDate(_lastBackupTime!) : s.backupNever}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withOpacity(0.5), + ), + ), + ], + ); + } + + String _formatDate(DateTime dt) { + final d = dt.toLocal(); + return '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year} ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/shared/widgets/pro_screen.dart b/lib/shared/widgets/pro_screen.dart index ab105d5..18ec779 100644 --- a/lib/shared/widgets/pro_screen.dart +++ b/lib/shared/widgets/pro_screen.dart @@ -4,11 +4,9 @@ import 'package:go_router/go_router.dart'; import '../../core/l10n/app_strings.dart'; import '../../core/l10n/locale_provider.dart'; import '../../core/services/haptic_service.dart'; -import '../feature_flags/feature_flags_provider.dart'; -import '../models/user_model.dart'; -import '../providers/billing_provider.dart'; import '../providers/current_user_provider.dart'; -import '../providers/google_auth_provider.dart'; +import '../providers/google_drive_provider.dart'; +import '../providers/premium_provider.dart'; import 'error_snackbar.dart'; class ProScreen extends ConsumerStatefulWidget { @@ -68,10 +66,9 @@ class _ProScreenState extends ConsumerState @override Widget build(BuildContext context) { final s = ref.watch(stringsProvider); - final flags = ref.watch(featureFlagsProvider); - final isVip = flags.maxAccounts != 3; - final googleUserAsync = ref.watch(googleCurrentUserProvider); - final googleUser = googleUserAsync.value; + final isPremium = ref.watch(isPremiumProvider); + final driveUserAsync = ref.watch(googleDriveUserProvider); + final driveUser = driveUserAsync.value; final colorScheme = Theme.of(context).colorScheme; return Stack( @@ -88,18 +85,18 @@ class _ProScreenState extends ConsumerState crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 4), - _buildHeroBanner(context, s, colorScheme, isVip), + _buildHeroBanner(context, s, colorScheme, isPremium), const SizedBox(height: 20), _buildFeatureList(context, s, colorScheme), const SizedBox(height: 16), - if (isVip) ...[ - _buildProActiveSection(context, s, colorScheme, googleUser), + if (isPremium) ...[ + _buildProActiveSection(context, s, colorScheme, driveUser), ], ], ), ), ), - _buildBottomBar(context, s, colorScheme, isVip), + _buildBottomBar(context, s, colorScheme, isPremium), ], ), ), @@ -476,17 +473,17 @@ class _ProScreenState extends ConsumerState HapticService.light(); setState(() => _purchasing = true); try { - final billing = ref.read(billingServiceProvider); - final success = await billing.purchasePro(); - if (success) { - await ref.read(currentUserProvider.notifier).setPlan(UserPlan.vip); + final manager = ref.read(premiumManagerProvider); + final result = await manager.purchase(); + if (result.success) { + await ref.read(currentUserProvider.notifier).refreshFromPremium(); if (mounted) { _showSuccessOverlay(); HapticService.medium(); } } else { if (mounted) { - showErrorSnackbar(context, ref.read(stringsProvider).proPurchaseFailed); + showErrorSnackbar(context, result.error ?? ref.read(stringsProvider).proPurchaseFailed); } } } catch (e) { @@ -502,10 +499,10 @@ class _ProScreenState extends ConsumerState HapticService.light(); setState(() => _restoring = true); try { - final billing = ref.read(billingServiceProvider); - final success = await billing.restorePurchases(); - if (success) { - await ref.read(currentUserProvider.notifier).setPlan(UserPlan.vip); + final manager = ref.read(premiumManagerProvider); + final result = await manager.restore(); + if (result.success) { + await ref.read(currentUserProvider.notifier).refreshFromPremium(); if (mounted) { _showSuccessOverlay(); HapticService.medium(); @@ -527,7 +524,7 @@ class _ProScreenState extends ConsumerState Future _handleGoogleSignIn() async { HapticService.light(); try { - final service = ref.read(googleAuthProvider); + final service = ref.read(googleDriveServiceProvider); await service.signIn(); } catch (e) { if (mounted) { @@ -539,7 +536,7 @@ class _ProScreenState extends ConsumerState Future _handleGoogleSignOut() async { HapticService.light(); try { - final service = ref.read(googleAuthProvider); + final service = ref.read(googleDriveServiceProvider); await service.signOut(); } catch (e) { if (mounted) { diff --git a/lib/shared/widgets/pro_subscription_card.dart b/lib/shared/widgets/pro_subscription_card.dart index fadb031..ebf090c 100644 --- a/lib/shared/widgets/pro_subscription_card.dart +++ b/lib/shared/widgets/pro_subscription_card.dart @@ -3,8 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/l10n/locale_provider.dart'; import '../../core/services/haptic_service.dart'; -import '../feature_flags/feature_flags_provider.dart'; -import '../providers/google_auth_provider.dart'; +import '../providers/google_drive_provider.dart'; +import '../providers/premium_provider.dart'; class ProSubscriptionCard extends ConsumerWidget { const ProSubscriptionCard({super.key}); @@ -18,10 +18,9 @@ class ProSubscriptionCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final s = ref.watch(stringsProvider); - final flags = ref.watch(featureFlagsProvider); - final isVip = flags.maxAccounts != 3; - final googleUserAsync = ref.watch(googleCurrentUserProvider); - final googleUser = googleUserAsync.value; + final isPremium = ref.watch(isPremiumProvider); + final driveUserAsync = ref.watch(googleDriveUserProvider); + final driveUser = driveUserAsync.value; return Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), @@ -45,7 +44,9 @@ class ProSubscriptionCard extends ConsumerWidget { borderRadius: BorderRadius.circular(12), ), child: Icon( - isVip ? Icons.verified_rounded : Icons.workspace_premium_rounded, + isPremium + ? Icons.verified_rounded + : Icons.workspace_premium_rounded, color: Colors.white, size: 28, ), @@ -57,80 +58,111 @@ class ProSubscriptionCard extends ConsumerWidget { children: [ Text( s.proTitle, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w900, - color: Colors.white, - ), + style: + Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w900, + color: Colors.white, + ), + ), + const SizedBox(height: 4), + Text( + isPremium ? s.proActive : s.proSubtitle, + style: isPremium + ? Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.white.withOpacity(0.9), + fontWeight: FontWeight.w600, + ) + : Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.white.withOpacity(0.7), + ), ), - if (isVip) ...[ - const SizedBox(height: 4), - Text( - s.proActive, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.white.withOpacity(0.9), - fontWeight: FontWeight.w600, - ), - ), - ] else ...[ - const SizedBox(height: 4), - Text( - s.proSubtitle, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.white.withOpacity(0.7), - ), - ), - ], ], ), ), ], ), const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: OutlinedButton( + if (!isPremium) ...[ + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () { + HapticService.light(); + context.push('/pro'); + }, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: BorderSide( + color: Colors.white.withOpacity(0.4), width: 1.5), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + s.proAboutPro, + style: const TextStyle( + fontWeight: FontWeight.w700, fontSize: 15), + ), + ), + ), + ], + ), + ] else ...[ + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () { + HapticService.light(); + context.push('/backup'); + }, + icon: Icon( + driveUser != null + ? Icons.cloud_done_rounded + : Icons.cloud_sync_rounded, + color: Colors.white, + size: 20, + ), + label: Text(s.backupSyncWithDrive), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: BorderSide( + color: Colors.white.withOpacity(0.4), width: 1.5), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + if (driveUser == null) ...[ + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( onPressed: () { HapticService.light(); - context.push('/pro'); + ref.read(googleDriveServiceProvider).signIn(); }, + icon: const Icon(Icons.login_rounded, + color: Colors.white, size: 18), + label: Text(s.proSignInGoogle), style: OutlinedButton.styleFrom( foregroundColor: Colors.white, - side: BorderSide(color: Colors.white.withOpacity(0.4), width: 1.5), - padding: const EdgeInsets.symmetric(vertical: 12), + side: BorderSide( + color: Colors.white.withOpacity(0.3), width: 1), + padding: const EdgeInsets.symmetric(vertical: 10), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), - child: Text( - s.proAboutPro, - style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15), - ), ), ), ], - ), - if (isVip && googleUser == null) ...[ - const SizedBox(height: 10), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () { - HapticService.light(); - ref.read(googleAuthProvider).signIn(); - }, - icon: const Icon(Icons.login_rounded, color: Colors.white, size: 18), - label: Text(s.proSignInGoogle), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: BorderSide(color: Colors.white.withOpacity(0.3), width: 1), - padding: const EdgeInsets.symmetric(vertical: 10), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ), ], ], ), diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 8edbf73..2629e12 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,11 +6,13 @@ import FlutterMacOS import Foundation import google_sign_in_ios +import in_app_purchase_storekit import local_auth_darwin import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) + InAppPurchasePlugin.register(with: registry.registrar(forPlugin: "InAppPurchasePlugin")) LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index f6e39e7..d7287c0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _discoveryapis_commons: + dependency: transitive + description: + name: _discoveryapis_commons + sha256: "113c4100b90a5b70a983541782431b82168b3cae166ab130649c36eb3559d498" + url: "https://pub.dev" + source: hosted + version: "1.0.7" _fe_analyzer_shared: dependency: transitive description: @@ -241,6 +249,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.8" + extension_google_sign_in_as_googleapis_auth: + dependency: "direct main" + description: + name: extension_google_sign_in_as_googleapis_auth + sha256: "0dcb17e399f62e897ac78f0a402a3cb6ab9313ced8b2bf131f684d317e05c9ab" + url: "https://pub.dev" + source: hosted + version: "2.0.13" fake_async: dependency: transitive description: @@ -424,6 +440,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.12.4+4" + googleapis: + dependency: "direct main" + description: + name: googleapis + sha256: "864f222aed3f2ff00b816c675edf00a39e2aaf373d728d8abec30b37bee1a81c" + url: "https://pub.dev" + source: hosted + version: "13.2.0" + googleapis_auth: + dependency: "direct main" + description: + name: googleapis_auth + sha256: befd71383a955535060acde8792e7efc11d2fccd03dd1d3ec434e85b68775938 + url: "https://pub.dev" + source: hosted + version: "1.6.0" graphs: dependency: transitive description: @@ -480,6 +512,38 @@ packages: url: "https://pub.dev" source: hosted version: "4.9.1" + in_app_purchase: + dependency: "direct main" + description: + name: in_app_purchase + sha256: "0e9510b80b0074e89ab0a8e0fc901439b779dc9ae575ab8d419253c6e1627716" + url: "https://pub.dev" + source: hosted + version: "3.3.0" + in_app_purchase_android: + dependency: transitive + description: + name: in_app_purchase_android + sha256: eb8f551039481d1b265f12fa54f5ab5dd4f13ec5444a468b85a3793517a37fda + url: "https://pub.dev" + source: hosted + version: "0.5.1" + in_app_purchase_platform_interface: + dependency: transitive + description: + name: in_app_purchase_platform_interface + sha256: "0b0076cac8ce4fa7048f01e76af8b123aeb6a7c4e0dea2a5206d6664454f3e36" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + in_app_purchase_storekit: + dependency: transitive + description: + name: in_app_purchase_storekit + sha256: "5f9d59c86c15f56429a4fdf09097c99d5b412510e1fcf80cf874fc9638fab369" + url: "https://pub.dev" + source: hosted + version: "0.4.10" intl: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 5851a8c..759aa93 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,6 +26,10 @@ dependencies: sqlite3_flutter_libs: ^0.6.0+eol path: ^1.8.3 google_sign_in: ^6.2.1 + in_app_purchase: ^3.2.0 + googleapis: ^13.2.0 + googleapis_auth: ^1.6.0 + extension_google_sign_in_as_googleapis_auth: ^2.0.12 flutter_launcher_icons: android: true