Fund creation and attach found to message

This commit is contained in:
2025-11-16 23:43:28 +08:00
parent 50c25e919c
commit 3f9881e943
13 changed files with 589 additions and 1096 deletions

View File

@@ -240,7 +240,11 @@ class _PurchaseGiftSheetState extends State<PurchaseGiftSheet> {
labelText: 'personalMessage'.tr(),
hintText: 'addPersonalMessageForRecipient'.tr(),
alignLabelWithHint: true,
border: OutlineInputBorder(),
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(
@@ -925,7 +929,9 @@ class StellarProgramTab extends HookConsumerWidget {
decoration: InputDecoration(
isDense: true,
hintText: 'enterGiftCode'.tr(),
border: OutlineInputBorder(),
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),

View File

@@ -13,6 +13,7 @@ import "package:island/models/chat.dart";
import "package:island/models/file.dart";
import "package:island/models/poll.dart";
import "package:island/models/publisher.dart";
import "package:island/models/wallet.dart";
import "package:island/models/realm.dart";
import "package:island/models/sticker.dart";
import "package:island/pods/config.dart";
@@ -28,6 +29,7 @@ import "package:material_symbols_icons/symbols.dart";
import "package:island/widgets/stickers/sticker_picker.dart";
import "package:island/pods/chat/chat_subscribe.dart";
import "package:island/widgets/post/compose_poll.dart";
import "package:island/widgets/post/compose_fund.dart";
void _insertPlaceholder(TextEditingController controller, String placeholder) {
final text = controller.text;
@@ -47,11 +49,15 @@ class _ExpandedSection extends StatelessWidget {
final TextEditingController messageController;
final SnPoll? selectedPoll;
final Function(SnPoll?) onPollSelected;
final SnWalletFund? selectedFund;
final Function(SnWalletFund?) onFundSelected;
const _ExpandedSection({
required this.messageController,
this.selectedPoll,
required this.onPollSelected,
this.selectedFund,
required this.onFundSelected,
});
@override
@@ -132,7 +138,18 @@ class _ExpandedSection extends StatelessWidget {
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
onTap: () {},
onTap: () async {
final fund =
await showModalBottomSheet<SnWalletFund>(
context: context,
isScrollControlled: true,
builder:
(context) => const ComposeFundSheet(),
);
if (fund != null) {
onFundSelected(fund);
}
},
child: Card(
margin: EdgeInsets.zero,
color:
@@ -145,7 +162,7 @@ class _ExpandedSection extends StatelessWidget {
Icon(Symbols.currency_exchange),
const Gap(4),
Text(
'Fund',
'fund'.tr(),
style:
Theme.of(context).textTheme.bodySmall,
),
@@ -195,6 +212,8 @@ class ChatInput extends HookConsumerWidget {
final Map<String, Map<int, double?>> attachmentProgress;
final SnPoll? selectedPoll;
final Function(SnPoll?) onPollSelected;
final SnWalletFund? selectedFund;
final Function(SnWalletFund?) onFundSelected;
const ChatInput({
super.key,
@@ -217,6 +236,8 @@ class ChatInput extends HookConsumerWidget {
required this.attachmentProgress,
this.selectedPoll,
required this.onPollSelected,
this.selectedFund,
required this.onFundSelected,
});
@override
@@ -515,6 +536,114 @@ class ChatInput extends HookConsumerWidget {
key: ValueKey('no-selected-poll'),
),
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (Widget child, Animation<double> animation) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, -0.25),
end: Offset.zero,
).animate(animation),
child: FadeTransition(
opacity: animation,
child: SizeTransition(
sizeFactor: animation,
axisAlignment: -1.0,
child: child,
),
),
);
},
child:
selectedFund != null
? Container(
key: const ValueKey('selected-fund'),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
decoration: BoxDecoration(
color:
Theme.of(
context,
).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outline.withOpacity(0.2),
width: 1,
),
),
margin: const EdgeInsets.only(
left: 8,
right: 8,
top: 8,
bottom: 8,
),
child: Row(
children: [
Icon(
Symbols.currency_exchange,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
const Gap(8),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${selectedFund!.totalAmount.toStringAsFixed(2)} ${selectedFund!.currency}',
style: Theme.of(
context,
).textTheme.bodySmall!.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (selectedFund!.message != null)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
selectedFund!.message!,
style: Theme.of(
context,
).textTheme.bodySmall!.copyWith(
fontSize: 10,
color:
Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
SizedBox(
width: 24,
height: 24,
child: IconButton(
padding: EdgeInsets.zero,
icon: const Icon(Icons.close, size: 18),
onPressed: () => onFundSelected(null),
tooltip: 'clear'.tr(),
),
),
],
),
)
: const SizedBox.shrink(
key: ValueKey('no-selected-fund'),
),
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOutCubic,
@@ -904,6 +1033,8 @@ class ChatInput extends HookConsumerWidget {
messageController: messageController,
selectedPoll: selectedPoll,
onPollSelected: onPollSelected,
selectedFund: selectedFund,
onFundSelected: onFundSelected,
)
: const SizedBox.shrink(key: ValueKey('collapsed')),
),

View File

@@ -144,7 +144,11 @@ class ChatLinkAttachment extends HookConsumerWidget {
helperText: 'fileIdHint'.tr(),
helperMaxLines: 3,
errorText: errorMessage.value,
border: OutlineInputBorder(),
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
),
),
onTapOutside:
(_) =>

View File

@@ -28,7 +28,9 @@ Future<void> _showSetTokenDialog(BuildContext context, WidgetRef ref) async {
controller: controller,
decoration: const InputDecoration(
hintText: 'Enter access token',
border: OutlineInputBorder(),
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
),
autofocus: true,
),
@@ -96,7 +98,7 @@ class DebugSheet extends HookConsumerWidget {
'Unable to check for updates',
);
}
}
},
),
const Divider(height: 8),
ListTile(

View File

@@ -455,7 +455,11 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
return TextField(
controller: _textController,
maxLines: 6,
decoration: const InputDecoration(border: OutlineInputBorder()),
decoration: const InputDecoration(
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
),
);
}

View File

@@ -0,0 +1,388 @@
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.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/wallet.dart';
import 'package:island/pods/network.dart';
import 'package:island/screens/wallet.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/payment/payment_overlay.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
/// Bottom sheet for selecting or creating a fund. Returns SnWalletFund via Navigator.pop.
class ComposeFundSheet extends HookConsumerWidget {
const ComposeFundSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isPushing = useState(false);
final errorText = useState<String?>(null);
return SheetScaffold(
heightFactor: 0.6,
titleText: 'fund'.tr(),
child: DefaultTabController(
length: 2,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TabBar(
tabs: [
Tab(text: 'fundsRecent'.tr()),
Tab(text: 'fundCreateNew'.tr()),
],
),
Expanded(
child: TabBarView(
children: [
// Link/Select existing fund list
ref
.watch(walletFundsProvider())
.when(
data:
(funds) =>
funds.isEmpty
? Center(
child: Column(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(
Symbols.money_bag,
size: 48,
color:
Theme.of(
context,
).colorScheme.outline,
),
const Gap(16),
Text(
'noFundsCreated'.tr(),
style:
Theme.of(
context,
).textTheme.titleMedium,
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: funds.length,
itemBuilder: (context, index) {
final fund = funds[index];
return Card(
margin: const EdgeInsets.only(
bottom: 8,
),
child: InkWell(
onTap:
() => Navigator.of(
context,
).pop(fund),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Symbols.money_bag,
color:
Theme.of(context)
.colorScheme
.primary,
fill: 1,
),
const Gap(8),
Expanded(
child: Text(
'${fund.totalAmount.toStringAsFixed(2)} ${fund.currency}',
style: TextStyle(
fontSize: 18,
fontWeight:
FontWeight.bold,
color:
Theme.of(
context,
)
.colorScheme
.primary,
),
),
),
Container(
padding:
const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color:
_getFundStatusColor(
context,
fund.status,
).withOpacity(
0.1,
),
borderRadius:
BorderRadius.circular(
12,
),
),
child: Text(
_getFundStatusText(
fund.status,
),
style: TextStyle(
color:
_getFundStatusColor(
context,
fund.status,
),
fontSize: 12,
fontWeight:
FontWeight.w600,
),
),
),
],
),
if (fund.message != null &&
fund
.message!
.isNotEmpty) ...[
const Gap(8),
Text(
fund.message!,
style:
Theme.of(context)
.textTheme
.bodyMedium,
),
],
const Gap(8),
Text(
'${'recipients'.tr()}: ${fund.recipients.where((r) => r.isReceived).length}/${fund.recipients.length}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color:
Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
],
),
),
),
);
},
),
loading:
() => const Center(
child: CircularProgressIndicator(),
),
error:
(error, stack) =>
Center(child: Text('Error: $error')),
),
// Create new fund and return it
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'fundCreateNewHint',
).tr().fontSize(13).opacity(0.85).padding(bottom: 8),
if (errorText.value != null)
Padding(
padding: const EdgeInsets.only(
left: 16,
right: 16,
top: 4,
),
child: Text(
errorText.value!,
style: TextStyle(color: Colors.red[700]),
),
),
const Gap(16),
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
icon:
isPushing.value
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Symbols.add_circle),
label: Text('create'.tr()),
onPressed:
isPushing.value
? null
: () async {
errorText.value = null;
isPushing.value = true;
// Show modal bottom sheet with fund creation form and await result
final result = await showModalBottomSheet<
Map<String, dynamic>
>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder:
(context) =>
const CreateFundSheet(),
);
if (result == null) {
isPushing.value = false;
return;
}
try {
if (!context.mounted) return;
final client = ref.read(
apiClientProvider,
);
showLoadingModal(context);
final resp = await client.post(
'/pass/wallets/funds',
data: result,
options: Options(
headers: {'X-Noop': true},
),
);
final fund = SnWalletFund.fromJson(
resp.data,
);
if (fund.status == 0) {
// Return the fund that was just created (but not yet paid)
if (context.mounted) {
hideLoadingModal(context);
}
Navigator.of(context).pop(fund);
return;
}
final orderResp = await client.post(
'/pass/wallets/funds/${fund.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 (paidOrder != null &&
context.mounted) {
showLoadingModal(context);
// Wait for server to handle order
await Future.delayed(
const Duration(seconds: 1),
);
ref.invalidate(walletFundsProvider);
// Return the created fund
final updatedResp = await client.get(
'/pass/wallets/funds/${fund.id}',
);
final updatedFund =
SnWalletFund.fromJson(
updatedResp.data,
);
if (context.mounted) {
hideLoadingModal(context);
}
Navigator.of(
context,
).pop(updatedFund);
} else {
isPushing.value = false;
}
} catch (err) {
if (context.mounted) {
hideLoadingModal(context);
}
errorText.value = err.toString();
isPushing.value = false;
}
},
),
),
],
).padding(horizontal: 24, vertical: 24),
),
],
),
),
],
),
),
);
}
String _getFundStatusText(int status) {
switch (status) {
case 0:
return 'fundStatusCreated'.tr();
case 1:
return 'fundStatusPartial'.tr();
case 2:
return 'fundStatusCompleted'.tr();
case 3:
return 'fundStatusExpired'.tr();
default:
return 'fundStatusUnknown'.tr();
}
}
Color _getFundStatusColor(BuildContext context, int status) {
switch (status) {
case 0:
return Colors.blue;
case 1:
return Colors.orange;
case 2:
return Colors.green;
case 3:
return Colors.red;
default:
return Theme.of(context).colorScheme.primary;
}
}
}

View File

@@ -143,7 +143,11 @@ class ComposeLinkAttachment extends HookConsumerWidget {
helperText: 'fileIdHint'.tr(),
helperMaxLines: 3,
errorText: errorMessage.value,
border: OutlineInputBorder(),
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
),
),
onTapOutside:
(_) =>