1443 lines
		
	
	
		
			49 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			1443 lines
		
	
	
		
			49 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| import 'dart:io';
 | |
| 
 | |
| import 'package:dio/dio.dart';
 | |
| import 'package:flutter/services.dart';
 | |
| import 'package:flutter/foundation.dart';
 | |
| import 'package:flutter/material.dart';
 | |
| import 'package:flutter_hooks/flutter_hooks.dart';
 | |
| import 'package:gap/gap.dart';
 | |
| import 'package:hooks_riverpod/hooks_riverpod.dart';
 | |
| import 'package:island/models/account.dart';
 | |
| import 'package:island/models/wallet.dart';
 | |
| import 'package:island/pods/network.dart';
 | |
| import 'package:island/pods/userinfo.dart';
 | |
| import 'package:island/services/time.dart';
 | |
| import 'package:island/widgets/account/account_pfc.dart';
 | |
| import 'package:island/widgets/account/account_picker.dart';
 | |
| import 'package:island/widgets/account/restore_purchase_sheet.dart';
 | |
| import 'package:island/widgets/alert.dart';
 | |
| import 'package:island/widgets/content/cloud_files.dart';
 | |
| import 'package:island/widgets/content/sheet.dart';
 | |
| import 'package:island/widgets/payment/payment_overlay.dart';
 | |
| import 'package:easy_localization/easy_localization.dart';
 | |
| import 'package:material_symbols_icons/symbols.dart';
 | |
| import 'package:riverpod_annotation/riverpod_annotation.dart';
 | |
| import 'package:styled_widget/styled_widget.dart';
 | |
| 
 | |
| part 'stellar_program_tab.g.dart';
 | |
| 
 | |
| @riverpod
 | |
| Future<SnWalletSubscription?> accountStellarSubscription(Ref ref) async {
 | |
|   try {
 | |
|     final client = ref.watch(apiClientProvider);
 | |
|     final resp = await client.get('/pass/subscriptions/fuzzy/solian.stellar');
 | |
|     return SnWalletSubscription.fromJson(resp.data);
 | |
|   } catch (err) {
 | |
|     if (err is DioException && err.response?.statusCode == 404) return null;
 | |
|     rethrow;
 | |
|   }
 | |
| }
 | |
| 
 | |
| @riverpod
 | |
| Future<List<SnWalletGift>> accountSentGifts(
 | |
|   Ref ref, {
 | |
|   int offset = 0,
 | |
|   int take = 20,
 | |
| }) async {
 | |
|   final client = ref.watch(apiClientProvider);
 | |
|   final resp = await client.get(
 | |
|     '/pass/subscriptions/gifts/sent?offset=$offset&take=$take',
 | |
|   );
 | |
|   return (resp.data as List).map((e) => SnWalletGift.fromJson(e)).toList();
 | |
| }
 | |
| 
 | |
| @riverpod
 | |
| Future<List<SnWalletGift>> accountReceivedGifts(
 | |
|   Ref ref, {
 | |
|   int offset = 0,
 | |
|   int take = 20,
 | |
| }) async {
 | |
|   final client = ref.watch(apiClientProvider);
 | |
|   final resp = await client.get(
 | |
|     '/pass/subscriptions/gifts/received?offset=$offset&take=$take',
 | |
|   );
 | |
|   return (resp.data as List).map((e) => SnWalletGift.fromJson(e)).toList();
 | |
| }
 | |
| 
 | |
| @riverpod
 | |
| Future<SnWalletGift> accountGift(Ref ref, String giftId) async {
 | |
|   final client = ref.watch(apiClientProvider);
 | |
|   final resp = await client.get('/pass/subscriptions/gifts/$giftId');
 | |
|   return SnWalletGift.fromJson(resp.data);
 | |
| }
 | |
| 
 | |
| class PurchaseGiftSheet extends StatefulWidget {
 | |
|   const PurchaseGiftSheet({super.key});
 | |
| 
 | |
|   @override
 | |
|   State<PurchaseGiftSheet> createState() => _PurchaseGiftSheetState();
 | |
| }
 | |
| 
 | |
| class _PurchaseGiftSheetState extends State<PurchaseGiftSheet> {
 | |
|   SnAccount? selectedRecipient;
 | |
|   final messageController = TextEditingController();
 | |
| 
 | |
|   @override
 | |
|   void dispose() {
 | |
|     messageController.dispose();
 | |
|     super.dispose();
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     return SheetScaffold(
 | |
|       titleText: 'purchaseGift'.tr(),
 | |
|       child: Column(
 | |
|         children: [
 | |
|           Expanded(
 | |
|             child: SingleChildScrollView(
 | |
|               padding: const EdgeInsets.all(16),
 | |
|               child: Column(
 | |
|                 crossAxisAlignment: CrossAxisAlignment.stretch,
 | |
|                 children: [
 | |
|                   // Recipient Selection Section
 | |
|                   Text(
 | |
|                     'selectRecipient'.tr(),
 | |
|                     style: TextStyle(
 | |
|                       fontSize: 16,
 | |
|                       fontWeight: FontWeight.w600,
 | |
|                       color: Theme.of(context).colorScheme.primary,
 | |
|                     ),
 | |
|                   ),
 | |
|                   const Gap(8),
 | |
|                   Container(
 | |
|                     decoration: BoxDecoration(
 | |
|                       color: Theme.of(context).colorScheme.surface,
 | |
|                       borderRadius: BorderRadius.circular(8),
 | |
|                       border: Border.all(
 | |
|                         color: Theme.of(
 | |
|                           context,
 | |
|                         ).colorScheme.outline.withOpacity(0.2),
 | |
|                       ),
 | |
|                     ),
 | |
|                     child:
 | |
|                         selectedRecipient != null
 | |
|                             ? ListTile(
 | |
|                               contentPadding: const EdgeInsets.only(
 | |
|                                 left: 20,
 | |
|                                 right: 12,
 | |
|                               ),
 | |
|                               leading: ProfilePictureWidget(
 | |
|                                 file: selectedRecipient!.profile.picture,
 | |
|                               ),
 | |
|                               title: Text(
 | |
|                                 selectedRecipient!.nick,
 | |
|                                 style: const TextStyle(
 | |
|                                   fontSize: 16,
 | |
|                                   fontWeight: FontWeight.w600,
 | |
|                                 ),
 | |
|                               ),
 | |
|                               subtitle: Text(
 | |
|                                 'selectedRecipient'.tr(),
 | |
|                                 style: Theme.of(
 | |
|                                   context,
 | |
|                                 ).textTheme.bodySmall?.copyWith(
 | |
|                                   color:
 | |
|                                       Theme.of(
 | |
|                                         context,
 | |
|                                       ).colorScheme.onSurfaceVariant,
 | |
|                                 ),
 | |
|                               ),
 | |
|                               trailing: IconButton(
 | |
|                                 onPressed:
 | |
|                                     () => setState(
 | |
|                                       () => selectedRecipient = null,
 | |
|                                     ),
 | |
|                                 icon: Icon(
 | |
|                                   Icons.clear,
 | |
|                                   color: Theme.of(context).colorScheme.error,
 | |
|                                 ),
 | |
|                                 tooltip: 'Clear selection',
 | |
|                               ),
 | |
|                             )
 | |
|                             : Column(
 | |
|                               mainAxisAlignment: MainAxisAlignment.center,
 | |
|                               children: [
 | |
|                                 Icon(
 | |
|                                   Icons.person_add_outlined,
 | |
|                                   size: 48,
 | |
|                                   color:
 | |
|                                       Theme.of(
 | |
|                                         context,
 | |
|                                       ).colorScheme.onSurfaceVariant,
 | |
|                                 ),
 | |
|                                 const Gap(8),
 | |
|                                 Text(
 | |
|                                   'noRecipientSelected'.tr(),
 | |
|                                   style: Theme.of(
 | |
|                                     context,
 | |
|                                   ).textTheme.bodyMedium?.copyWith(
 | |
|                                     color:
 | |
|                                         Theme.of(
 | |
|                                           context,
 | |
|                                         ).colorScheme.onSurfaceVariant,
 | |
|                                   ),
 | |
|                                 ),
 | |
|                                 const Gap(4),
 | |
|                                 Text(
 | |
|                                   'thisWillBeAnOpenGift'.tr(),
 | |
|                                   style: Theme.of(
 | |
|                                     context,
 | |
|                                   ).textTheme.bodySmall?.copyWith(
 | |
|                                     color:
 | |
|                                         Theme.of(
 | |
|                                           context,
 | |
|                                         ).colorScheme.onSurfaceVariant,
 | |
|                                   ),
 | |
|                                 ),
 | |
|                               ],
 | |
|                             ).padding(vertical: 32),
 | |
|                   ),
 | |
|                   const Gap(12),
 | |
|                   OutlinedButton.icon(
 | |
|                     onPressed: () async {
 | |
|                       final recipient = await showModalBottomSheet<SnAccount>(
 | |
|                         context: context,
 | |
|                         useRootNavigator: true,
 | |
|                         isScrollControlled: true,
 | |
|                         builder: (context) => const AccountPickerSheet(),
 | |
|                       );
 | |
|                       if (recipient != null) {
 | |
|                         setState(() => selectedRecipient = recipient);
 | |
|                       }
 | |
|                     },
 | |
|                     icon: const Icon(Icons.person_search),
 | |
|                     label: Text(
 | |
|                       selectedRecipient != null
 | |
|                           ? 'changeRecipient'.tr()
 | |
|                           : 'selectRecipient'.tr(),
 | |
|                     ),
 | |
|                     style: OutlinedButton.styleFrom(
 | |
|                       minimumSize: const Size(double.infinity, 48),
 | |
|                     ),
 | |
|                   ),
 | |
| 
 | |
|                   const Gap(24),
 | |
| 
 | |
|                   // Message Section
 | |
|                   Text(
 | |
|                     'addMessage'.tr(),
 | |
|                     style: TextStyle(
 | |
|                       fontSize: 16,
 | |
|                       fontWeight: FontWeight.w600,
 | |
|                       color: Theme.of(context).colorScheme.primary,
 | |
|                     ),
 | |
|                   ),
 | |
|                   const Gap(8),
 | |
|                   TextField(
 | |
|                     controller: messageController,
 | |
|                     decoration: InputDecoration(
 | |
|                       labelText: 'personalMessage'.tr(),
 | |
|                       hintText: 'addPersonalMessageForRecipient'.tr(),
 | |
|                       alignLabelWithHint: true,
 | |
|                       border: OutlineInputBorder(),
 | |
|                       enabledBorder: OutlineInputBorder(
 | |
|                         borderSide: BorderSide(
 | |
|                           color: Theme.of(
 | |
|                             context,
 | |
|                           ).colorScheme.outline.withOpacity(0.2),
 | |
|                         ),
 | |
|                       ),
 | |
|                       focusedBorder: OutlineInputBorder(
 | |
|                         borderSide: BorderSide(
 | |
|                           color: Theme.of(context).colorScheme.primary,
 | |
|                         ),
 | |
|                       ),
 | |
|                     ),
 | |
|                     maxLines: 3,
 | |
|                     onTapOutside:
 | |
|                         (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | |
|                   ),
 | |
|                 ],
 | |
|               ),
 | |
|             ),
 | |
|           ),
 | |
| 
 | |
|           // Action Buttons
 | |
|           Padding(
 | |
|             padding: const EdgeInsets.all(16),
 | |
|             child: Row(
 | |
|               children: [
 | |
|                 Expanded(
 | |
|                   child: OutlinedButton(
 | |
|                     onPressed:
 | |
|                         () => Navigator.of(context).pop(<String, dynamic>{
 | |
|                           'recipient': null,
 | |
|                           'message':
 | |
|                               messageController.text.trim().isEmpty
 | |
|                                   ? null
 | |
|                                   : messageController.text.trim(),
 | |
|                         }),
 | |
|                     child: Text('skipRecipient'.tr()),
 | |
|                   ),
 | |
|                 ),
 | |
|                 const Gap(8),
 | |
|                 Expanded(
 | |
|                   child: FilledButton(
 | |
|                     onPressed:
 | |
|                         () => Navigator.of(context).pop(<String, dynamic>{
 | |
|                           'recipient': selectedRecipient,
 | |
|                           'message':
 | |
|                               messageController.text.trim().isEmpty
 | |
|                                   ? null
 | |
|                                   : messageController.text.trim(),
 | |
|                         }),
 | |
|                     child: Text('purchaseGift'.tr()),
 | |
|                   ),
 | |
|                 ),
 | |
|               ],
 | |
|             ),
 | |
|           ),
 | |
|         ],
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| class StellarProgramTab extends HookConsumerWidget {
 | |
|   const StellarProgramTab({super.key});
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context, WidgetRef ref) {
 | |
|     final stellarSubscription = ref.watch(accountStellarSubscriptionProvider);
 | |
| 
 | |
|     return SingleChildScrollView(
 | |
|       padding: EdgeInsets.symmetric(horizontal: 16, vertical: 20),
 | |
|       child: Column(
 | |
|         crossAxisAlignment: CrossAxisAlignment.stretch,
 | |
|         children: [
 | |
|           _buildMembershipSection(context, ref, stellarSubscription),
 | |
|           const Gap(16),
 | |
|           _buildGiftingSection(context, ref),
 | |
|           const Gap(16),
 | |
|         ],
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Widget _buildMembershipSection(
 | |
|     BuildContext context,
 | |
|     WidgetRef ref,
 | |
|     AsyncValue<SnWalletSubscription?> stellarSubscriptionAsync,
 | |
|   ) {
 | |
|     return stellarSubscriptionAsync.when(
 | |
|       data: (membership) => _buildMembershipContent(context, ref, membership),
 | |
|       loading:
 | |
|           () => Container(
 | |
|             width: double.infinity,
 | |
|             padding: const EdgeInsets.all(16),
 | |
|             decoration: BoxDecoration(
 | |
|               gradient: LinearGradient(
 | |
|                 colors: [
 | |
|                   Theme.of(context).colorScheme.primaryContainer,
 | |
|                   Theme.of(context).colorScheme.secondaryContainer,
 | |
|                 ],
 | |
|                 begin: Alignment.topLeft,
 | |
|                 end: Alignment.bottomRight,
 | |
|               ),
 | |
|               borderRadius: BorderRadius.circular(12),
 | |
|             ),
 | |
|             child: const Center(child: CircularProgressIndicator()),
 | |
|           ),
 | |
|       error:
 | |
|           (error, stack) => Container(
 | |
|             width: double.infinity,
 | |
|             padding: const EdgeInsets.all(16),
 | |
|             decoration: BoxDecoration(
 | |
|               gradient: LinearGradient(
 | |
|                 colors: [
 | |
|                   Theme.of(context).colorScheme.primaryContainer,
 | |
|                   Theme.of(context).colorScheme.secondaryContainer,
 | |
|                 ],
 | |
|                 begin: Alignment.topLeft,
 | |
|                 end: Alignment.bottomRight,
 | |
|               ),
 | |
|               borderRadius: BorderRadius.circular(12),
 | |
|             ),
 | |
|             child: Text('Error loading membership: $error'),
 | |
|           ),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Widget _buildMembershipContent(
 | |
|     BuildContext context,
 | |
|     WidgetRef ref,
 | |
|     SnWalletSubscription? membership,
 | |
|   ) {
 | |
|     final isActive = membership?.isActive ?? false;
 | |
| 
 | |
|     Future<void> membershipCancel() async {
 | |
|       if (!isActive || membership == null) return;
 | |
| 
 | |
|       final confirm = await showConfirmAlert(
 | |
|         'membershipCancelHint'.tr(),
 | |
|         'membershipCancelConfirm'.tr(),
 | |
|       );
 | |
|       if (!confirm || !context.mounted) return;
 | |
| 
 | |
|       try {
 | |
|         showLoadingModal(context);
 | |
|         final client = ref.watch(apiClientProvider);
 | |
|         await client.post(
 | |
|           '/pass/subscriptions/${membership.identifier}/cancel',
 | |
|         );
 | |
|         ref.invalidate(accountStellarSubscriptionProvider);
 | |
|         ref.read(userInfoProvider.notifier).fetchUser();
 | |
|         if (context.mounted) {
 | |
|           hideLoadingModal(context);
 | |
|           showSnackBar('membershipCancelSuccess'.tr());
 | |
|         }
 | |
|       } catch (err) {
 | |
|         if (context.mounted) hideLoadingModal(context);
 | |
|         showErrorAlert(err);
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     return Card(
 | |
|       child: Column(
 | |
|         crossAxisAlignment: CrossAxisAlignment.stretch,
 | |
|         children: [
 | |
|           Row(
 | |
|             children: [
 | |
|               Icon(
 | |
|                 isActive ? Icons.star_rounded : Icons.star_border_rounded,
 | |
|                 color: Theme.of(context).colorScheme.primary,
 | |
|                 size: 24,
 | |
|               ),
 | |
|               const Gap(8),
 | |
|               Text(
 | |
|                 'stellarMembership'.tr(),
 | |
|                 style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
 | |
|               ),
 | |
|             ],
 | |
|           ),
 | |
|           const Gap(12),
 | |
| 
 | |
|           if (isActive) ...[
 | |
|             _buildCurrentMembershipCard(context, membership!),
 | |
|             const Gap(12),
 | |
|             FilledButton.icon(
 | |
|               style: ButtonStyle(
 | |
|                 backgroundColor: WidgetStateProperty.all(
 | |
|                   Theme.of(context).colorScheme.error,
 | |
|                 ),
 | |
|                 foregroundColor: WidgetStateProperty.all(
 | |
|                   Theme.of(context).colorScheme.onError,
 | |
|                 ),
 | |
|               ),
 | |
|               onPressed: membershipCancel,
 | |
|               icon: const Icon(Symbols.cancel),
 | |
|               label: Text('membershipCancel'.tr()),
 | |
|             ),
 | |
|           ],
 | |
| 
 | |
|           if (!isActive) ...[
 | |
|             Text(
 | |
|               'chooseYourPlan'.tr(),
 | |
|               style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
 | |
|             ),
 | |
|             const Gap(12),
 | |
|             _buildMembershipTiers(context, ref, membership),
 | |
|           ],
 | |
| 
 | |
|           // Restore Purchase Button
 | |
|           // As you know Apple platform need IAP
 | |
|           if (kIsWeb || !(Platform.isIOS || Platform.isMacOS))
 | |
|             OutlinedButton.icon(
 | |
|               onPressed: () => _showRestorePurchaseSheet(context, ref),
 | |
|               icon: const Icon(Icons.restore),
 | |
|               label: Text('restorePurchase'.tr()),
 | |
|               style: OutlinedButton.styleFrom(
 | |
|                 minimumSize: const Size(double.infinity, 48),
 | |
|               ),
 | |
|             ).padding(top: 12),
 | |
|         ],
 | |
|       ).padding(all: 16),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Widget _buildCurrentMembershipCard(
 | |
|     BuildContext context,
 | |
|     SnWalletSubscription membership,
 | |
|   ) {
 | |
|     final tierName = _getMembershipTierName(membership.identifier);
 | |
|     final tierColor = _getMembershipTierColor(context, membership.identifier);
 | |
| 
 | |
|     return Container(
 | |
|       padding: const EdgeInsets.all(12),
 | |
|       decoration: BoxDecoration(
 | |
|         color: tierColor.withOpacity(0.1),
 | |
|         borderRadius: BorderRadius.circular(8),
 | |
|         border: Border.all(color: tierColor, width: 1),
 | |
|       ),
 | |
|       child: Row(
 | |
|         children: [
 | |
|           Icon(Icons.verified, color: tierColor, size: 20),
 | |
|           const Gap(8),
 | |
|           Expanded(
 | |
|             child: Column(
 | |
|               crossAxisAlignment: CrossAxisAlignment.start,
 | |
|               children: [
 | |
|                 Text(
 | |
|                   'currentMembership'.tr(args: [tierName]),
 | |
|                   style: TextStyle(
 | |
|                     fontSize: 14,
 | |
|                     fontWeight: FontWeight.w600,
 | |
|                     color: tierColor,
 | |
|                   ),
 | |
|                 ),
 | |
|                 if (membership.endedAt != null)
 | |
|                   Text(
 | |
|                     'membershipExpires'.tr(
 | |
|                       args: [membership.endedAt!.formatSystem()],
 | |
|                     ),
 | |
|                     style: Theme.of(context).textTheme.bodySmall?.copyWith(
 | |
|                       color: Theme.of(context).colorScheme.onSurfaceVariant,
 | |
|                     ),
 | |
|                   ),
 | |
|               ],
 | |
|             ),
 | |
|           ),
 | |
|         ],
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Widget _buildMembershipTiers(
 | |
|     BuildContext context,
 | |
|     WidgetRef ref,
 | |
|     SnWalletSubscription? currentMembership,
 | |
|   ) {
 | |
|     final tiers = [
 | |
|       {
 | |
|         'id': 'solian.stellar.primary',
 | |
|         'name': 'membershipTierStellar'.tr(),
 | |
|         'price': 'membershipPriceStellar'.tr(),
 | |
|         'color': Colors.blue,
 | |
|       },
 | |
|       {
 | |
|         'id': 'solian.stellar.nova',
 | |
|         'name': 'membershipTierNova'.tr(),
 | |
|         'price': 'membershipPriceNova'.tr(),
 | |
|         'color': Color.fromRGBO(57, 197, 187, 1),
 | |
|       },
 | |
|       {
 | |
|         'id': 'solian.stellar.supernova',
 | |
|         'name': 'membershipTierSupernova'.tr(),
 | |
|         'price': 'membershipPriceSupernova'.tr(),
 | |
|         'color': Colors.orange,
 | |
|       },
 | |
|     ];
 | |
| 
 | |
|     return Column(
 | |
|       children:
 | |
|           tiers.map((tier) {
 | |
|             final isCurrentTier = currentMembership?.identifier == tier['id'];
 | |
|             final tierColor = tier['color'] as Color;
 | |
| 
 | |
|             return Container(
 | |
|               margin: const EdgeInsets.only(bottom: 8),
 | |
|               child: Material(
 | |
|                 color: Colors.transparent,
 | |
|                 child: InkWell(
 | |
|                   onTap:
 | |
|                       isCurrentTier
 | |
|                           ? null
 | |
|                           : () => _purchaseMembership(
 | |
|                             context,
 | |
|                             ref,
 | |
|                             tier['id'] as String,
 | |
|                           ),
 | |
|                   borderRadius: BorderRadius.circular(8),
 | |
|                   child: Container(
 | |
|                     padding: const EdgeInsets.all(12),
 | |
|                     decoration: BoxDecoration(
 | |
|                       color:
 | |
|                           isCurrentTier
 | |
|                               ? tierColor.withOpacity(0.1)
 | |
|                               : Theme.of(context).colorScheme.surface,
 | |
|                       borderRadius: BorderRadius.circular(8),
 | |
|                       border: Border.all(
 | |
|                         color:
 | |
|                             isCurrentTier
 | |
|                                 ? tierColor
 | |
|                                 : Theme.of(
 | |
|                                   context,
 | |
|                                 ).colorScheme.outline.withOpacity(0.2),
 | |
|                         width: isCurrentTier ? 2 : 1,
 | |
|                       ),
 | |
|                     ),
 | |
|                     child: Row(
 | |
|                       children: [
 | |
|                         Container(
 | |
|                           width: 4,
 | |
|                           height: 40,
 | |
|                           decoration: BoxDecoration(
 | |
|                             color: tierColor,
 | |
|                             borderRadius: BorderRadius.circular(2),
 | |
|                           ),
 | |
|                         ),
 | |
|                         const Gap(12),
 | |
|                         Expanded(
 | |
|                           child: Column(
 | |
|                             crossAxisAlignment: CrossAxisAlignment.start,
 | |
|                             children: [
 | |
|                               Row(
 | |
|                                 children: [
 | |
|                                   Text(
 | |
|                                     tier['name'] as String,
 | |
|                                     style: TextStyle(
 | |
|                                       fontSize: 16,
 | |
|                                       fontWeight: FontWeight.w600,
 | |
|                                       color: isCurrentTier ? tierColor : null,
 | |
|                                     ),
 | |
|                                   ),
 | |
|                                   const Gap(8),
 | |
|                                   if (isCurrentTier)
 | |
|                                     Container(
 | |
|                                       padding: const EdgeInsets.symmetric(
 | |
|                                         horizontal: 6,
 | |
|                                         vertical: 2,
 | |
|                                       ),
 | |
|                                       decoration: BoxDecoration(
 | |
|                                         color: tierColor,
 | |
|                                         borderRadius: BorderRadius.circular(4),
 | |
|                                       ),
 | |
|                                       child: Text(
 | |
|                                         'membershipCurrentBadge'.tr(),
 | |
|                                         style: TextStyle(
 | |
|                                           fontSize: 10,
 | |
|                                           fontWeight: FontWeight.bold,
 | |
|                                           color: Colors.white,
 | |
|                                         ),
 | |
|                                       ),
 | |
|                                     ),
 | |
|                                 ],
 | |
|                               ),
 | |
|                               Text(
 | |
|                                 tier['price'] as String,
 | |
|                                 style: Theme.of(
 | |
|                                   context,
 | |
|                                 ).textTheme.bodyMedium?.copyWith(
 | |
|                                   color:
 | |
|                                       Theme.of(
 | |
|                                         context,
 | |
|                                       ).colorScheme.onSurfaceVariant,
 | |
|                                 ),
 | |
|                               ),
 | |
|                             ],
 | |
|                           ),
 | |
|                         ),
 | |
|                         if (!isCurrentTier)
 | |
|                           Icon(
 | |
|                             Icons.arrow_forward_ios,
 | |
|                             size: 16,
 | |
|                             color:
 | |
|                                 Theme.of(context).colorScheme.onSurfaceVariant,
 | |
|                           ),
 | |
|                       ],
 | |
|                     ),
 | |
|                   ),
 | |
|                 ),
 | |
|               ),
 | |
|             );
 | |
|           }).toList(),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   String _getMembershipTierName(String identifier) {
 | |
|     switch (identifier) {
 | |
|       case 'solian.stellar.primary':
 | |
|         return 'membershipTierStellar'.tr();
 | |
|       case 'solian.stellar.nova':
 | |
|         return 'membershipTierNova'.tr();
 | |
|       case 'solian.stellar.supernova':
 | |
|         return 'membershipTierSupernova'.tr();
 | |
|       default:
 | |
|         return 'membershipTierUnknown'.tr();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   Color _getMembershipTierColor(BuildContext context, String identifier) {
 | |
|     switch (identifier) {
 | |
|       case 'solian.stellar.primary':
 | |
|         return Colors.blue;
 | |
|       case 'solian.stellar.nova':
 | |
|         return Colors.purple;
 | |
|       case 'solian.stellar.supernova':
 | |
|         return Colors.orange;
 | |
|       default:
 | |
|         return Theme.of(context).colorScheme.primary;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   Future<void> _showRestorePurchaseSheet(
 | |
|     BuildContext context,
 | |
|     WidgetRef ref,
 | |
|   ) async {
 | |
|     await showModalBottomSheet(
 | |
|       context: context,
 | |
|       builder: (context) => const RestorePurchaseSheet(),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Future<void> _purchaseMembership(
 | |
|     BuildContext context,
 | |
|     WidgetRef ref,
 | |
|     String tierId,
 | |
|   ) async {
 | |
|     final client = ref.watch(apiClientProvider);
 | |
|     try {
 | |
|       showLoadingModal(context);
 | |
|       final resp = await client.post(
 | |
|         '/pass/subscriptions',
 | |
|         data: {
 | |
|           'identifier': tierId,
 | |
|           'payment_method': 'solian.wallet',
 | |
|           'payment_details': {'currency': 'golds'},
 | |
|           'cycle_duration_days': 30,
 | |
|         },
 | |
|         options: Options(headers: {'X-Noop': true}),
 | |
|       );
 | |
|       final subscription = SnWalletSubscription.fromJson(resp.data);
 | |
|       if (subscription.status == 1) return;
 | |
|       final orderResp = await client.post(
 | |
|         '/pass/subscriptions/${subscription.identifier}/order',
 | |
|       );
 | |
|       final order = SnWalletOrder.fromJson(orderResp.data);
 | |
| 
 | |
|       if (context.mounted) hideLoadingModal(context);
 | |
| 
 | |
|       // Show payment overlay to complete the payment
 | |
|       if (!context.mounted) return;
 | |
|       final paidOrder = await PaymentOverlay.show(
 | |
|         context: context,
 | |
|         order: order,
 | |
|         enableBiometric: true,
 | |
|       );
 | |
| 
 | |
|       if (context.mounted) showLoadingModal(context);
 | |
| 
 | |
|       if (paidOrder != null) {
 | |
|         // Wait for server to handle order
 | |
|         await Future.delayed(const Duration(seconds: 1));
 | |
|         ref.invalidate(accountStellarSubscriptionProvider);
 | |
|         ref.read(userInfoProvider.notifier).fetchUser();
 | |
|         if (context.mounted) {
 | |
|           showSnackBar('membershipPurchaseSuccess'.tr());
 | |
|         }
 | |
|       }
 | |
|     } catch (err) {
 | |
|       showErrorAlert(err);
 | |
|     } finally {
 | |
|       if (context.mounted) hideLoadingModal(context);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   Widget _buildGiftingSection(BuildContext context, WidgetRef ref) {
 | |
|     final sentGifts = ref.watch(accountSentGiftsProvider());
 | |
|     final receivedGifts = ref.watch(accountReceivedGiftsProvider());
 | |
| 
 | |
|     return Card(
 | |
|       child: Column(
 | |
|         crossAxisAlignment: CrossAxisAlignment.stretch,
 | |
|         children: [
 | |
|           Row(
 | |
|             children: [
 | |
|               Icon(
 | |
|                 Icons.card_giftcard,
 | |
|                 color: Theme.of(context).colorScheme.primary,
 | |
|                 size: 24,
 | |
|               ),
 | |
|               const Gap(8),
 | |
|               Text(
 | |
|                 'giftSubscriptions'.tr(),
 | |
|                 style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
 | |
|               ),
 | |
|             ],
 | |
|           ),
 | |
|           const Gap(12),
 | |
| 
 | |
|           // Purchase Gift Section
 | |
|           Text(
 | |
|             'purchaseAGift'.tr(),
 | |
|             style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
 | |
|           ),
 | |
|           const Gap(8),
 | |
|           _buildGiftPurchaseOptions(context, ref),
 | |
|           const Gap(16),
 | |
| 
 | |
|           // Redeem Gift Section
 | |
|           Text(
 | |
|             'redeemAGift'.tr(),
 | |
|             style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
 | |
|           ),
 | |
|           const Gap(8),
 | |
|           _buildGiftRedeemSection(context, ref),
 | |
|           const Gap(16),
 | |
| 
 | |
|           // Gift History
 | |
|           Text(
 | |
|             'giftHistory'.tr(),
 | |
|             style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
 | |
|           ),
 | |
|           const Gap(8),
 | |
|           _buildGiftHistory(context, ref, sentGifts, receivedGifts),
 | |
|         ],
 | |
|       ).padding(all: 16),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Widget _buildGiftPurchaseOptions(BuildContext context, WidgetRef ref) {
 | |
|     final tiers = [
 | |
|       {
 | |
|         'id': 'solian.stellar.primary',
 | |
|         'name': 'stellarGift'.tr(),
 | |
|         'price': 'sameAsMembership'.tr(),
 | |
|         'color': Colors.blue,
 | |
|       },
 | |
|       {
 | |
|         'id': 'solian.stellar.nova',
 | |
|         'name': 'novaGift'.tr(),
 | |
|         'price': 'sameAsMembership'.tr(),
 | |
|         'color': Color.fromRGBO(57, 197, 187, 1),
 | |
|       },
 | |
|       {
 | |
|         'id': 'solian.stellar.supernova',
 | |
|         'name': 'supernovaGift'.tr(),
 | |
|         'price': 'sameAsMembership'.tr(),
 | |
|         'color': Colors.orange,
 | |
|       },
 | |
|     ];
 | |
| 
 | |
|     return Column(
 | |
|       children:
 | |
|           tiers.map((tier) {
 | |
|             final tierColor = tier['color'] as Color;
 | |
| 
 | |
|             return Container(
 | |
|               margin: const EdgeInsets.only(bottom: 8),
 | |
|               child: Material(
 | |
|                 color: Colors.transparent,
 | |
|                 child: InkWell(
 | |
|                   onTap:
 | |
|                       () => _showPurchaseGiftDialog(
 | |
|                         context,
 | |
|                         ref,
 | |
|                         tier['id'] as String,
 | |
|                       ),
 | |
|                   borderRadius: BorderRadius.circular(8),
 | |
|                   child: Container(
 | |
|                     padding: const EdgeInsets.all(12),
 | |
|                     decoration: BoxDecoration(
 | |
|                       color: Theme.of(context).colorScheme.surface,
 | |
|                       borderRadius: BorderRadius.circular(8),
 | |
|                       border: Border.all(
 | |
|                         color: Theme.of(
 | |
|                           context,
 | |
|                         ).colorScheme.outline.withOpacity(0.2),
 | |
|                         width: 1,
 | |
|                       ),
 | |
|                     ),
 | |
|                     child: Row(
 | |
|                       children: [
 | |
|                         Container(
 | |
|                           width: 4,
 | |
|                           height: 40,
 | |
|                           decoration: BoxDecoration(
 | |
|                             color: tierColor,
 | |
|                             borderRadius: BorderRadius.circular(2),
 | |
|                           ),
 | |
|                         ),
 | |
|                         const Gap(12),
 | |
|                         Expanded(
 | |
|                           child: Column(
 | |
|                             crossAxisAlignment: CrossAxisAlignment.start,
 | |
|                             children: [
 | |
|                               Text(
 | |
|                                 tier['name'] as String,
 | |
|                                 style: TextStyle(
 | |
|                                   fontSize: 16,
 | |
|                                   fontWeight: FontWeight.w600,
 | |
|                                 ),
 | |
|                               ),
 | |
|                               Text(
 | |
|                                 tier['price'] as String,
 | |
|                                 style: Theme.of(
 | |
|                                   context,
 | |
|                                 ).textTheme.bodyMedium?.copyWith(
 | |
|                                   color:
 | |
|                                       Theme.of(
 | |
|                                         context,
 | |
|                                       ).colorScheme.onSurfaceVariant,
 | |
|                                 ),
 | |
|                               ),
 | |
|                             ],
 | |
|                           ),
 | |
|                         ),
 | |
|                         Icon(
 | |
|                           Icons.arrow_forward_ios,
 | |
|                           size: 16,
 | |
|                           color: Theme.of(context).colorScheme.onSurfaceVariant,
 | |
|                         ),
 | |
|                       ],
 | |
|                     ),
 | |
|                   ),
 | |
|                 ),
 | |
|               ),
 | |
|             );
 | |
|           }).toList(),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Widget _buildGiftRedeemSection(BuildContext context, WidgetRef ref) {
 | |
|     final codeController = useTextEditingController();
 | |
| 
 | |
|     return Container(
 | |
|       padding: const EdgeInsets.all(12),
 | |
|       decoration: BoxDecoration(
 | |
|         color: Theme.of(context).colorScheme.surface,
 | |
|         borderRadius: BorderRadius.circular(8),
 | |
|         border: Border.all(
 | |
|           color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
 | |
|         ),
 | |
|       ),
 | |
|       child: Column(
 | |
|         crossAxisAlignment: CrossAxisAlignment.start,
 | |
|         children: [
 | |
|           Text(
 | |
|             'enterGiftCodeToRedeem'.tr(),
 | |
|             style: Theme.of(context).textTheme.bodyMedium,
 | |
|           ),
 | |
|           const Gap(8),
 | |
|           TextField(
 | |
|             controller: codeController,
 | |
|             decoration: InputDecoration(
 | |
|               isDense: true,
 | |
|               hintText: 'enterGiftCode'.tr(),
 | |
|               border: OutlineInputBorder(),
 | |
|               enabledBorder: OutlineInputBorder(
 | |
|                 borderSide: BorderSide(
 | |
|                   color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
 | |
|                 ),
 | |
|               ),
 | |
|               focusedBorder: OutlineInputBorder(
 | |
|                 borderSide: BorderSide(
 | |
|                   color: Theme.of(context).colorScheme.primary,
 | |
|                 ),
 | |
|               ),
 | |
|               suffixIcon: IconButton(
 | |
|                 icon: Icon(Icons.redeem),
 | |
|                 onPressed:
 | |
|                     () => _redeemGift(context, ref, codeController.text.trim()),
 | |
|               ),
 | |
|             ),
 | |
|             onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | |
|             onSubmitted: (code) => _redeemGift(context, ref, code.trim()),
 | |
|           ),
 | |
|         ],
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Widget _buildGiftHistory(
 | |
|     BuildContext context,
 | |
|     WidgetRef ref,
 | |
|     AsyncValue<List<SnWalletGift>> sentGifts,
 | |
|     AsyncValue<List<SnWalletGift>> receivedGifts,
 | |
|   ) {
 | |
|     return Row(
 | |
|       children: [
 | |
|         Expanded(
 | |
|           child: OutlinedButton(
 | |
|             onPressed:
 | |
|                 () => _showGiftHistorySheet(context, ref, sentGifts, true),
 | |
|             child: Text('sentGifts'.tr()),
 | |
|           ),
 | |
|         ),
 | |
|         const Gap(8),
 | |
|         Expanded(
 | |
|           child: OutlinedButton(
 | |
|             onPressed:
 | |
|                 () => _showGiftHistorySheet(context, ref, receivedGifts, false),
 | |
|             child: Text('receivedGifts'.tr()),
 | |
|           ),
 | |
|         ),
 | |
|       ],
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Future<void> _showGiftHistorySheet(
 | |
|     BuildContext context,
 | |
|     WidgetRef ref,
 | |
|     AsyncValue<List<SnWalletGift>> giftsAsync,
 | |
|     bool isSent,
 | |
|   ) async {
 | |
|     await showModalBottomSheet(
 | |
|       isScrollControlled: true,
 | |
|       useRootNavigator: true,
 | |
|       context: context,
 | |
|       builder:
 | |
|           (context) => SheetScaffold(
 | |
|             titleText: isSent ? 'sentGifts'.tr() : 'receivedGifts'.tr(),
 | |
|             child: giftsAsync.when(
 | |
|               data:
 | |
|                   (gifts) =>
 | |
|                       gifts.isEmpty
 | |
|                           ? Padding(
 | |
|                             padding: const EdgeInsets.all(16),
 | |
|                             child: Text(
 | |
|                               isSent
 | |
|                                   ? 'noSentGifts'.tr()
 | |
|                                   : 'noReceivedGifts'.tr(),
 | |
|                             ),
 | |
|                           )
 | |
|                           : ListView.builder(
 | |
|                             padding: const EdgeInsets.only(top: 16),
 | |
|                             itemCount: gifts.length,
 | |
|                             itemBuilder:
 | |
|                                 (context, index) => _buildGiftItem(
 | |
|                                   context,
 | |
|                                   ref,
 | |
|                                   gifts[index],
 | |
|                                   isSent,
 | |
|                                 ),
 | |
|                           ),
 | |
|               loading: () => const Center(child: CircularProgressIndicator()),
 | |
|               error: (error, stack) => Center(child: Text('Error: $error')),
 | |
|             ),
 | |
|           ),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Widget _buildGiftItem(
 | |
|     BuildContext context,
 | |
|     WidgetRef ref,
 | |
|     SnWalletGift gift,
 | |
|     bool isSent,
 | |
|   ) {
 | |
|     final statusText = _getGiftStatusText(gift.status);
 | |
|     final statusColor = _getGiftStatusColor(context, gift.status);
 | |
|     final canCancel = isSent && (gift.status == 0 || gift.status == 1);
 | |
| 
 | |
|     return Container(
 | |
|       margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
 | |
|       padding: const EdgeInsets.all(12),
 | |
|       decoration: BoxDecoration(
 | |
|         color: Theme.of(context).colorScheme.surface,
 | |
|         borderRadius: BorderRadius.circular(8),
 | |
|         border: Border.all(
 | |
|           color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
 | |
|         ),
 | |
|       ),
 | |
|       child: Column(
 | |
|         crossAxisAlignment: CrossAxisAlignment.start,
 | |
|         children: [
 | |
|           Row(
 | |
|             children: [
 | |
|               Expanded(
 | |
|                 child: Row(
 | |
|                   children: [
 | |
|                     Text(
 | |
|                       'codeLabel'.tr(),
 | |
|                       style: TextStyle(fontWeight: FontWeight.w600),
 | |
|                     ),
 | |
|                     Expanded(
 | |
|                       child: Text(
 | |
|                         gift.giftCode,
 | |
|                         style: TextStyle(
 | |
|                           fontWeight: FontWeight.w600,
 | |
|                           fontFamily: 'monospace',
 | |
|                         ),
 | |
|                       ),
 | |
|                     ),
 | |
|                   ],
 | |
|                 ),
 | |
|               ),
 | |
|               Container(
 | |
|                 padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
 | |
|                 decoration: BoxDecoration(
 | |
|                   color: statusColor.withOpacity(0.1),
 | |
|                   borderRadius: BorderRadius.circular(12),
 | |
|                 ),
 | |
|                 child: Row(
 | |
|                   spacing: 6,
 | |
|                   children: [
 | |
|                     Text(
 | |
|                       statusText,
 | |
|                       style: TextStyle(
 | |
|                         color: statusColor,
 | |
|                         fontSize: 12,
 | |
|                         fontWeight: FontWeight.w600,
 | |
|                       ),
 | |
|                     ),
 | |
|                     if (gift.status == 2 && gift.redeemer != null)
 | |
|                       AccountPfcGestureDetector(
 | |
|                         uname: gift.redeemer!.name,
 | |
|                         child: ProfilePictureWidget(
 | |
|                           file: gift.redeemer!.profile.picture,
 | |
|                           radius: 8,
 | |
|                         ),
 | |
|                       ),
 | |
|                   ],
 | |
|                 ),
 | |
|               ),
 | |
|             ],
 | |
|           ),
 | |
|           const Gap(4),
 | |
|           Text(
 | |
|             '${'subscriptionLabel'.tr()} ${_getMembershipTierName(gift.subscriptionIdentifier)}',
 | |
|             style: Theme.of(context).textTheme.bodySmall,
 | |
|           ),
 | |
|           if (gift.recipient != null && isSent) ...[
 | |
|             const Gap(4),
 | |
|             Text(
 | |
|               '${'toLabel'.tr()} ${gift.recipient!.name}',
 | |
|               style: Theme.of(context).textTheme.bodySmall,
 | |
|             ),
 | |
|           ],
 | |
|           if (gift.gifter != null && !isSent) ...[
 | |
|             const Gap(4),
 | |
|             Text(
 | |
|               '${'fromLabel'.tr()} ${gift.gifter!.name}',
 | |
|               style: Theme.of(context).textTheme.bodySmall,
 | |
|             ),
 | |
|           ],
 | |
|           if (gift.message != null && gift.message!.isNotEmpty) ...[
 | |
|             const Gap(4),
 | |
|             Text(
 | |
|               '${'messageLabel'.tr()} ${gift.message}',
 | |
|               style: Theme.of(context).textTheme.bodySmall,
 | |
|             ),
 | |
|           ],
 | |
|           const Gap(8),
 | |
|           Row(
 | |
|             mainAxisAlignment: MainAxisAlignment.end,
 | |
|             spacing: 8,
 | |
|             children: [
 | |
|               FilledButton.tonalIcon(
 | |
|                 onPressed: () async {
 | |
|                   await Clipboard.setData(ClipboardData(text: gift.giftCode));
 | |
|                   if (context.mounted) {
 | |
|                     showSnackBar('giftCodeCopiedToClipboard'.tr());
 | |
|                   }
 | |
|                 },
 | |
|                 icon: const Icon(Icons.copy, size: 16),
 | |
|                 label: Text('copy'.tr()),
 | |
|                 style: FilledButton.styleFrom(
 | |
|                   visualDensity: VisualDensity.compact,
 | |
|                 ),
 | |
|               ),
 | |
|               if (canCancel) ...[
 | |
|                 OutlinedButton.icon(
 | |
|                   onPressed: () => _cancelGift(context, ref, gift),
 | |
|                   icon: const Icon(Icons.cancel, size: 16),
 | |
|                   label: Text('cancel'.tr()),
 | |
|                   style: OutlinedButton.styleFrom(
 | |
|                     visualDensity: VisualDensity.compact,
 | |
|                     foregroundColor: Theme.of(context).colorScheme.error,
 | |
|                     side: BorderSide(
 | |
|                       color: Theme.of(context).colorScheme.error,
 | |
|                     ),
 | |
|                   ),
 | |
|                 ),
 | |
|               ],
 | |
|             ],
 | |
|           ),
 | |
|         ],
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   String _getGiftStatusText(int status) {
 | |
|     switch (status) {
 | |
|       case 0:
 | |
|         return 'giftStatusCreated'.tr();
 | |
|       case 1:
 | |
|         return 'giftStatusSent'.tr();
 | |
|       case 2:
 | |
|         return 'giftStatusRedeemed'.tr();
 | |
|       case 3:
 | |
|         return 'giftStatusCancelled'.tr();
 | |
|       case 4:
 | |
|         return 'giftStatusExpired'.tr();
 | |
|       default:
 | |
|         return 'giftStatusUnknown'.tr();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   Color _getGiftStatusColor(BuildContext context, int status) {
 | |
|     switch (status) {
 | |
|       case 0:
 | |
|         return Colors.grey;
 | |
|       case 1:
 | |
|         return Colors.blue;
 | |
|       case 2:
 | |
|         return Colors.green;
 | |
|       case 3:
 | |
|         return Colors.red;
 | |
|       case 4:
 | |
|         return Colors.orange;
 | |
|       default:
 | |
|         return Theme.of(context).colorScheme.primary;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   Future<void> _showPurchaseGiftDialog(
 | |
|     BuildContext context,
 | |
|     WidgetRef ref,
 | |
|     String subscriptionId,
 | |
|   ) async {
 | |
|     final result = await showModalBottomSheet<Map<String, dynamic>>(
 | |
|       isScrollControlled: true,
 | |
|       useRootNavigator: true,
 | |
|       context: context,
 | |
|       builder: (context) => const PurchaseGiftSheet(),
 | |
|     );
 | |
| 
 | |
|     if (result != null && context.mounted) {
 | |
|       final recipient = result['recipient'] as SnAccount?;
 | |
|       final message = result['message'] as String?;
 | |
|       await _purchaseGift(context, ref, subscriptionId, recipient?.id, message);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   Future<void> _purchaseGift(
 | |
|     BuildContext context,
 | |
|     WidgetRef ref,
 | |
|     String subscriptionId,
 | |
|     String? recipientId,
 | |
|     String? message,
 | |
|   ) async {
 | |
|     final client = ref.watch(apiClientProvider);
 | |
|     try {
 | |
|       showLoadingModal(context);
 | |
|       final resp = await client.post(
 | |
|         '/pass/subscriptions/gifts/purchase',
 | |
|         data: {
 | |
|           'subscription_identifier': subscriptionId,
 | |
|           if (recipientId != null) 'recipient_id': recipientId,
 | |
|           'payment_method': 'solian.wallet',
 | |
|           'payment_details': {'currency': 'golds'},
 | |
|           if (message != null) 'message': message,
 | |
|           'gift_duration_days': 30,
 | |
|           'subscription_duration_days': 30,
 | |
|         },
 | |
|         options: Options(headers: {'X-Noop': true}),
 | |
|       );
 | |
|       final gift = SnWalletGift.fromJson(resp.data);
 | |
|       if (gift.status == 1) return; // Already paid
 | |
| 
 | |
|       final orderResp = await client.post(
 | |
|         '/pass/subscriptions/gifts/${gift.id}/order',
 | |
|       );
 | |
|       final order = SnWalletOrder.fromJson(orderResp.data);
 | |
| 
 | |
|       if (context.mounted) hideLoadingModal(context);
 | |
| 
 | |
|       // Show payment overlay to complete the payment
 | |
|       if (!context.mounted) return;
 | |
|       final paidOrder = await PaymentOverlay.show(
 | |
|         context: context,
 | |
|         order: order,
 | |
|         enableBiometric: true,
 | |
|       );
 | |
| 
 | |
|       if (context.mounted) showLoadingModal(context);
 | |
| 
 | |
|       if (paidOrder != null) {
 | |
|         // Wait for server to handle order
 | |
|         await Future.delayed(const Duration(seconds: 1));
 | |
| 
 | |
|         // Get the updated gift
 | |
|         final giftResp = await client.get(
 | |
|           '/pass/subscriptions/gifts/${gift.id}',
 | |
|         );
 | |
|         final updatedGift = SnWalletGift.fromJson(giftResp.data);
 | |
| 
 | |
|         if (context.mounted) hideLoadingModal(context);
 | |
| 
 | |
|         // Show gift code bottom sheet
 | |
|         if (context.mounted) {
 | |
|           await showModalBottomSheet(
 | |
|             context: context,
 | |
|             builder:
 | |
|                 (context) => SheetScaffold(
 | |
|                   titleText: 'giftPurchased'.tr(),
 | |
|                   child: Padding(
 | |
|                     padding: const EdgeInsets.all(16),
 | |
|                     child: Column(
 | |
|                       mainAxisSize: MainAxisSize.min,
 | |
|                       crossAxisAlignment: CrossAxisAlignment.stretch,
 | |
|                       children: [
 | |
|                         Container(
 | |
|                           padding: const EdgeInsets.all(12),
 | |
|                           decoration: BoxDecoration(
 | |
|                             color: Theme.of(context).colorScheme.surface,
 | |
|                             borderRadius: BorderRadius.circular(8),
 | |
|                             border: Border.all(
 | |
|                               color: Theme.of(
 | |
|                                 context,
 | |
|                               ).colorScheme.outline.withOpacity(0.2),
 | |
|                             ),
 | |
|                           ),
 | |
|                           child: Row(
 | |
|                             children: [
 | |
|                               Expanded(
 | |
|                                 child: Text(
 | |
|                                   updatedGift.giftCode,
 | |
|                                   style: TextStyle(
 | |
|                                     fontSize: 16,
 | |
|                                     fontWeight: FontWeight.w600,
 | |
|                                     fontFamily: 'monospace',
 | |
|                                   ),
 | |
|                                 ),
 | |
|                               ),
 | |
|                               IconButton(
 | |
|                                 onPressed: () async {
 | |
|                                   await Clipboard.setData(
 | |
|                                     ClipboardData(text: updatedGift.giftCode),
 | |
|                                   );
 | |
|                                   if (context.mounted) {
 | |
|                                     showSnackBar(
 | |
|                                       'giftCodeCopiedToClipboard'.tr(),
 | |
|                                     );
 | |
|                                   }
 | |
|                                 },
 | |
|                                 icon: const Icon(Icons.copy),
 | |
|                                 tooltip: 'copyGiftCode'.tr(),
 | |
|                               ),
 | |
|                             ],
 | |
|                           ),
 | |
|                         ),
 | |
|                         const Gap(16),
 | |
|                         Text(
 | |
|                           'shareCodeWithRecipient'.tr(),
 | |
|                           style: Theme.of(context).textTheme.bodyMedium,
 | |
|                         ),
 | |
|                         if (updatedGift.recipientId == null) ...[
 | |
|                           const Gap(8),
 | |
|                           Text(
 | |
|                             'openGiftAnyoneCanRedeem'.tr(),
 | |
|                             style: Theme.of(
 | |
|                               context,
 | |
|                             ).textTheme.bodySmall?.copyWith(
 | |
|                               color:
 | |
|                                   Theme.of(
 | |
|                                     context,
 | |
|                                   ).colorScheme.onSurfaceVariant,
 | |
|                             ),
 | |
|                           ),
 | |
|                         ],
 | |
|                         const Gap(24),
 | |
|                         FilledButton(
 | |
|                           onPressed: () => Navigator.of(context).pop(),
 | |
|                           child: Text('ok'.tr()),
 | |
|                         ),
 | |
|                       ],
 | |
|                     ),
 | |
|                   ),
 | |
|                 ),
 | |
|           );
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       ref.invalidate(accountSentGiftsProvider);
 | |
|     } catch (err) {
 | |
|       showErrorAlert(err);
 | |
|     } finally {
 | |
|       if (context.mounted) hideLoadingModal(context);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   Future<void> _redeemGift(
 | |
|     BuildContext context,
 | |
|     WidgetRef ref,
 | |
|     String giftCode,
 | |
|   ) async {
 | |
|     final client = ref.watch(apiClientProvider);
 | |
|     try {
 | |
|       showLoadingModal(context);
 | |
| 
 | |
|       // First check if gift can be redeemed
 | |
|       final checkResp = await client.get(
 | |
|         '/pass/subscriptions/gifts/check/$giftCode',
 | |
|       );
 | |
|       final checkData = checkResp.data as Map<String, dynamic>;
 | |
| 
 | |
|       if (!checkData['can_redeem']) {
 | |
|         if (context.mounted) hideLoadingModal(context);
 | |
|         showErrorAlert(checkData['error'] ?? 'Gift cannot be redeemed');
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       // Redeem the gift
 | |
|       await client.post(
 | |
|         '/pass/subscriptions/gifts/redeem',
 | |
|         data: {'gift_code': giftCode},
 | |
|       );
 | |
| 
 | |
|       if (context.mounted) {
 | |
|         hideLoadingModal(context);
 | |
|         await showDialog(
 | |
|           context: context,
 | |
|           builder:
 | |
|               (context) => AlertDialog(
 | |
|                 title: Text('giftRedeemed'.tr()),
 | |
|                 content: Text('giftRedeemedSuccessfully'.tr()),
 | |
|                 actions: [
 | |
|                   FilledButton(
 | |
|                     onPressed: () => Navigator.of(context).pop(),
 | |
|                     child: Text('ok'.tr()),
 | |
|                   ),
 | |
|                 ],
 | |
|               ),
 | |
|         );
 | |
|       }
 | |
| 
 | |
|       ref.invalidate(accountReceivedGiftsProvider);
 | |
|       ref.invalidate(accountStellarSubscriptionProvider);
 | |
|       ref.read(userInfoProvider.notifier).fetchUser();
 | |
|     } catch (err) {
 | |
|       if (context.mounted) hideLoadingModal(context);
 | |
|       showErrorAlert(err);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   Future<void> _cancelGift(
 | |
|     BuildContext context,
 | |
|     WidgetRef ref,
 | |
|     SnWalletGift gift,
 | |
|   ) async {
 | |
|     final confirm = await showConfirmAlert(
 | |
|       'cancelGift'.tr(),
 | |
|       'cancelGiftConfirm'.tr(),
 | |
|     );
 | |
|     if (!confirm || !context.mounted) return;
 | |
| 
 | |
|     final client = ref.watch(apiClientProvider);
 | |
|     try {
 | |
|       showLoadingModal(context);
 | |
|       await client.post('/pass/subscriptions/gifts/${gift.id}/cancel');
 | |
|       ref.invalidate(accountSentGiftsProvider);
 | |
|       if (context.mounted) {
 | |
|         hideLoadingModal(context);
 | |
|         showSnackBar('giftCancelledSuccessfully'.tr());
 | |
|       }
 | |
|     } catch (err) {
 | |
|       if (context.mounted) hideLoadingModal(context);
 | |
|       showErrorAlert(err);
 | |
|     }
 | |
|   }
 | |
| }
 |