💄 Optimize gift subscription

This commit is contained in:
2025-10-03 20:45:08 +08:00
parent 0b1a23e81a
commit fbc7812a16

View File

@@ -1,8 +1,10 @@
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/account.dart'; import 'package:island/models/account.dart';
@@ -10,9 +12,11 @@ import 'package:island/models/wallet.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
import 'package:island/services/time.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/account_picker.dart';
import 'package:island/widgets/account/restore_purchase_sheet.dart'; import 'package:island/widgets/account/restore_purchase_sheet.dart';
import 'package:island/widgets/alert.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/content/sheet.dart';
import 'package:island/widgets/payment/payment_overlay.dart'; import 'package:island/widgets/payment/payment_overlay.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
@@ -67,6 +71,239 @@ Future<SnWalletGift> accountGift(Ref ref, String giftId) async {
return SnWalletGift.fromJson(resp.data); 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: 'Purchase Gift',
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Recipient Selection Section
Text(
'Select Recipient',
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(
'Selected recipient',
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(
'No recipient selected',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(
color:
Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
const Gap(4),
Text(
'This will be an open gift',
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
? 'Change Recipient'
: 'Select Recipient',
),
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
),
),
const Gap(24),
// Message Section
Text(
'Add Message',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
const Gap(8),
TextField(
controller: messageController,
decoration: InputDecoration(
labelText: 'Personal Message',
hintText: 'Add a personal message for the recipient',
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('Skip Recipient'),
),
),
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('Purchase Gift'),
),
),
],
),
),
],
),
);
}
}
class StellarProgramTab extends HookConsumerWidget { class StellarProgramTab extends HookConsumerWidget {
const StellarProgramTab({super.key}); const StellarProgramTab({super.key});
@@ -75,7 +312,7 @@ class StellarProgramTab extends HookConsumerWidget {
final stellarSubscription = ref.watch(accountStellarSubscriptionProvider); final stellarSubscription = ref.watch(accountStellarSubscriptionProvider);
return SingleChildScrollView( return SingleChildScrollView(
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 20), padding: EdgeInsets.symmetric(horizontal: 16, vertical: 20),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@@ -662,6 +899,8 @@ class StellarProgramTab extends HookConsumerWidget {
} }
Widget _buildGiftRedeemSection(BuildContext context, WidgetRef ref) { Widget _buildGiftRedeemSection(BuildContext context, WidgetRef ref) {
final codeController = useTextEditingController();
return Container( return Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -680,7 +919,9 @@ class StellarProgramTab extends HookConsumerWidget {
), ),
const Gap(8), const Gap(8),
TextField( TextField(
controller: codeController,
decoration: InputDecoration( decoration: InputDecoration(
isDense: true,
hintText: 'Enter gift code', hintText: 'Enter gift code',
border: OutlineInputBorder(), border: OutlineInputBorder(),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
@@ -695,10 +936,12 @@ class StellarProgramTab extends HookConsumerWidget {
), ),
suffixIcon: IconButton( suffixIcon: IconButton(
icon: Icon(Icons.redeem), icon: Icon(Icons.redeem),
onPressed: () => _showRedeemGiftDialog(context, ref), onPressed:
() => _redeemGift(context, ref, codeController.text.trim()),
), ),
), ),
onSubmitted: (code) => _redeemGift(context, ref, code), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: (code) => _redeemGift(context, ref, code.trim()),
), ),
], ],
), ),
@@ -797,31 +1040,58 @@ class StellarProgramTab extends HookConsumerWidget {
children: [ children: [
Row( Row(
children: [ children: [
Text( Expanded(
'Code: ${gift.giftCode}', child: Row(
style: TextStyle(fontWeight: FontWeight.w600), children: [
Text(
'Code: ',
style: TextStyle(fontWeight: FontWeight.w600),
),
Expanded(
child: Text(
gift.giftCode,
style: TextStyle(
fontWeight: FontWeight.w600,
fontFamily: 'monospace',
),
),
),
],
),
), ),
const Spacer(),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: statusColor.withOpacity(0.1), color: statusColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Text( child: Row(
statusText, spacing: 6,
style: TextStyle( children: [
color: statusColor, Text(
fontSize: 12, statusText,
fontWeight: FontWeight.w600, 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), const Gap(4),
Text( Text(
'Subscription: ${gift.subscriptionIdentifier}', 'Subscription: ${_getMembershipTierName(gift.subscriptionIdentifier)}',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
if (gift.recipient != null && isSent) ...[ if (gift.recipient != null && isSent) ...[
@@ -845,21 +1115,40 @@ class StellarProgramTab extends HookConsumerWidget {
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
], ],
if (canCancel) ...[ const Gap(8),
const Gap(8), Row(
Align( mainAxisAlignment: MainAxisAlignment.end,
alignment: Alignment.centerRight, spacing: 8,
child: OutlinedButton.icon( children: [
onPressed: () => _cancelGift(context, ref, gift), FilledButton.tonalIcon(
icon: const Icon(Icons.cancel, size: 16), onPressed: () async {
label: const Text('Cancel'), await Clipboard.setData(ClipboardData(text: gift.giftCode));
style: OutlinedButton.styleFrom( if (context.mounted) {
foregroundColor: Theme.of(context).colorScheme.error, showSnackBar('Gift code copied to clipboard');
side: BorderSide(color: Theme.of(context).colorScheme.error), }
},
icon: const Icon(Icons.copy, size: 16),
label: Text('Copy'),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
), ),
), ),
), if (canCancel) ...[
], OutlinedButton.icon(
onPressed: () => _cancelGift(context, ref, gift),
icon: const Icon(Icons.cancel, size: 16),
label: const Text('Cancel'),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
foregroundColor: Theme.of(context).colorScheme.error,
side: BorderSide(
color: Theme.of(context).colorScheme.error,
),
),
),
],
],
),
], ],
), ),
); );
@@ -904,101 +1193,16 @@ class StellarProgramTab extends HookConsumerWidget {
WidgetRef ref, WidgetRef ref,
String subscriptionId, String subscriptionId,
) async { ) async {
final messageController = TextEditingController(); final result = await showModalBottomSheet<Map<String, dynamic>>(
final recipient = await showModalBottomSheet<SnAccount>(
isScrollControlled: true, isScrollControlled: true,
useRootNavigator: true, useRootNavigator: true,
context: context, context: context,
builder: builder: (context) => const PurchaseGiftSheet(),
(context) => SheetScaffold(
titleText: 'Select Recipient (Optional)',
child: Column(
children: [
Expanded(child: AccountPickerSheet()),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Skip (Open Gift)'),
),
),
],
),
),
],
),
),
); );
if (!context.mounted) return; if (result != null && context.mounted) {
final recipient = result['recipient'] as SnAccount?;
final message = await showModalBottomSheet<String>( final message = result['message'] as String?;
isScrollControlled: true,
useRootNavigator: true,
context: context,
builder:
(context) => SheetScaffold(
titleText: 'Add Message (Optional)',
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: messageController,
decoration: InputDecoration(
labelText: 'Message',
hintText: 'Add a personal message',
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,
autofocus: true,
),
const Gap(16),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Skip'),
),
),
const Gap(8),
Expanded(
child: FilledButton(
onPressed:
() => Navigator.of(context).pop(
messageController.text.trim().isEmpty
? null
: messageController.text.trim(),
),
child: Text('Add Message'),
),
),
],
),
],
),
),
),
);
if (context.mounted) {
await _purchaseGift(context, ref, subscriptionId, recipient?.id, message); await _purchaseGift(context, ref, subscriptionId, recipient?.id, message);
} }
} }
@@ -1056,33 +1260,86 @@ class StellarProgramTab extends HookConsumerWidget {
if (context.mounted) hideLoadingModal(context); if (context.mounted) hideLoadingModal(context);
// Show gift code dialog // Show gift code bottom sheet
if (context.mounted) { if (context.mounted) {
await showDialog( await showModalBottomSheet(
context: context, context: context,
builder: builder:
(context) => AlertDialog( (context) => SheetScaffold(
title: Text('Gift Purchased!'), titleText: 'Gift Purchased!',
content: Column( child: Padding(
mainAxisSize: MainAxisSize.min, padding: const EdgeInsets.all(16),
children: [ child: Column(
Text('Gift Code: ${updatedGift.giftCode}'), mainAxisSize: MainAxisSize.min,
const Gap(8), crossAxisAlignment: CrossAxisAlignment.stretch,
Text( children: [
'Share this code with the recipient to redeem the gift.', Container(
), padding: const EdgeInsets.all(12),
if (updatedGift.recipientId == null) ...[ decoration: BoxDecoration(
const Gap(8), color: Theme.of(context).colorScheme.surface,
Text('This is an open gift that anyone can redeem.'), 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(
'Gift code copied to clipboard',
);
}
},
icon: const Icon(Icons.copy),
tooltip: 'Copy gift code',
),
],
),
),
const Gap(16),
Text(
'Share this code with the recipient to redeem the gift.',
style: Theme.of(context).textTheme.bodyMedium,
),
if (updatedGift.recipientId == null) ...[
const Gap(8),
Text(
'This is an open gift that anyone can redeem.',
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'),
),
], ],
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('OK'),
), ),
], ),
), ),
); );
} }
@@ -1096,57 +1353,6 @@ class StellarProgramTab extends HookConsumerWidget {
} }
} }
Future<void> _showRedeemGiftDialog(
BuildContext context,
WidgetRef ref,
) async {
final codeController = TextEditingController();
final result = await showDialog<String>(
context: context,
builder:
(context) => AlertDialog(
title: Text('Redeem Gift'),
content: TextField(
controller: codeController,
decoration: InputDecoration(
labelText: 'Gift Code',
hintText: 'Enter the gift code',
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,
),
),
),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Cancel'),
),
FilledButton(
onPressed:
() => Navigator.of(context).pop(codeController.text.trim()),
child: Text('Redeem'),
),
],
),
);
if (result != null && result.isNotEmpty && context.mounted) {
await _redeemGift(context, ref, result);
}
}
Future<void> _redeemGift( Future<void> _redeemGift(
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,