Compare commits

...

7 Commits

Author SHA1 Message Date
24791b3293 🎉 Launch 3.3.0+146 2025-11-17 01:33:34 +08:00
3ac263d483 🐛 Fix build on web 2025-11-17 01:26:34 +08:00
2445d8adf8 💄 Optimzations 2025-11-17 01:23:27 +08:00
d4f95bbbf4 Claim fund 2025-11-17 01:20:49 +08:00
943e4b7b5c 🐛 Fix edit post didn't inherent poll and fund 2025-11-16 23:58:36 +08:00
7edc02a1d3 ♻️ No longer two submit post function 2025-11-16 23:54:50 +08:00
3f9881e943 Fund creation and attach found to message 2025-11-16 23:43:28 +08:00
29 changed files with 1677 additions and 1382 deletions

View File

@@ -1329,5 +1329,12 @@
"more": "More",
"collapse": "Collapse",
"pollConfirmDiscard": "Are you sure you want to leave? All the poll data you're editing will not be saved.",
"discard": "Discard"
"discard": "Discard",
"fund": "Fund",
"fundsRecent": "Recent Funds",
"fundCreateNew": "Create New",
"fundCreateNewHint": "Create a new fund for your message. Select recipients and amount.",
"amountOfSplits": "Amount of Splits",
"enterNumberOfSplits": "Enter Splits Amount",
"orCreateWith": "Or\ncreate with"
}

View File

@@ -176,6 +176,8 @@ sealed class SnWalletFund with _$SnWalletFund {
required String id,
required String currency,
required double totalAmount,
required double remainingAmount,
required int amountOfSplits,
required int splitType, // 0: even, 1: random
required int
status, // 0: created, 1: partially claimed, 2: fully claimed, 3: expired
@@ -184,6 +186,7 @@ sealed class SnWalletFund with _$SnWalletFund {
required SnAccount? creatorAccount,
required DateTime expiredAt,
required List<SnWalletFundRecipient> recipients,
required bool isOpen,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,

View File

@@ -2553,9 +2553,9 @@ $SnWalletSubscriptionCopyWith<$Res>? get subscription {
/// @nodoc
mixin _$SnWalletFund {
String get id; String get currency; double get totalAmount; int get splitType;// 0: even, 1: random
String get id; String get currency; double get totalAmount; double get remainingAmount; int get amountOfSplits; int get splitType;// 0: even, 1: random
int get status;// 0: created, 1: partially claimed, 2: fully claimed, 3: expired
String? get message; String get creatorAccountId; SnAccount? get creatorAccount; DateTime get expiredAt; List<SnWalletFundRecipient> get recipients; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
String? get message; String get creatorAccountId; SnAccount? get creatorAccount; DateTime get expiredAt; List<SnWalletFundRecipient> get recipients; bool get isOpen; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
/// Create a copy of SnWalletFund
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -2568,16 +2568,16 @@ $SnWalletFundCopyWith<SnWalletFund> get copyWith => _$SnWalletFundCopyWithImpl<S
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnWalletFund&&(identical(other.id, id) || other.id == id)&&(identical(other.currency, currency) || other.currency == currency)&&(identical(other.totalAmount, totalAmount) || other.totalAmount == totalAmount)&&(identical(other.splitType, splitType) || other.splitType == splitType)&&(identical(other.status, status) || other.status == status)&&(identical(other.message, message) || other.message == message)&&(identical(other.creatorAccountId, creatorAccountId) || other.creatorAccountId == creatorAccountId)&&(identical(other.creatorAccount, creatorAccount) || other.creatorAccount == creatorAccount)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&const DeepCollectionEquality().equals(other.recipients, recipients)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnWalletFund&&(identical(other.id, id) || other.id == id)&&(identical(other.currency, currency) || other.currency == currency)&&(identical(other.totalAmount, totalAmount) || other.totalAmount == totalAmount)&&(identical(other.remainingAmount, remainingAmount) || other.remainingAmount == remainingAmount)&&(identical(other.amountOfSplits, amountOfSplits) || other.amountOfSplits == amountOfSplits)&&(identical(other.splitType, splitType) || other.splitType == splitType)&&(identical(other.status, status) || other.status == status)&&(identical(other.message, message) || other.message == message)&&(identical(other.creatorAccountId, creatorAccountId) || other.creatorAccountId == creatorAccountId)&&(identical(other.creatorAccount, creatorAccount) || other.creatorAccount == creatorAccount)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&const DeepCollectionEquality().equals(other.recipients, recipients)&&(identical(other.isOpen, isOpen) || other.isOpen == isOpen)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,currency,totalAmount,splitType,status,message,creatorAccountId,creatorAccount,expiredAt,const DeepCollectionEquality().hash(recipients),createdAt,updatedAt,deletedAt);
int get hashCode => Object.hash(runtimeType,id,currency,totalAmount,remainingAmount,amountOfSplits,splitType,status,message,creatorAccountId,creatorAccount,expiredAt,const DeepCollectionEquality().hash(recipients),isOpen,createdAt,updatedAt,deletedAt);
@override
String toString() {
return 'SnWalletFund(id: $id, currency: $currency, totalAmount: $totalAmount, splitType: $splitType, status: $status, message: $message, creatorAccountId: $creatorAccountId, creatorAccount: $creatorAccount, expiredAt: $expiredAt, recipients: $recipients, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
return 'SnWalletFund(id: $id, currency: $currency, totalAmount: $totalAmount, remainingAmount: $remainingAmount, amountOfSplits: $amountOfSplits, splitType: $splitType, status: $status, message: $message, creatorAccountId: $creatorAccountId, creatorAccount: $creatorAccount, expiredAt: $expiredAt, recipients: $recipients, isOpen: $isOpen, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
@@ -2588,7 +2588,7 @@ abstract mixin class $SnWalletFundCopyWith<$Res> {
factory $SnWalletFundCopyWith(SnWalletFund value, $Res Function(SnWalletFund) _then) = _$SnWalletFundCopyWithImpl;
@useResult
$Res call({
String id, String currency, double totalAmount, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
String id, String currency, double totalAmount, double remainingAmount, int amountOfSplits, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, bool isOpen, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
@@ -2605,19 +2605,22 @@ class _$SnWalletFundCopyWithImpl<$Res>
/// Create a copy of SnWalletFund
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? currency = null,Object? totalAmount = null,Object? splitType = null,Object? status = null,Object? message = freezed,Object? creatorAccountId = null,Object? creatorAccount = freezed,Object? expiredAt = null,Object? recipients = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? currency = null,Object? totalAmount = null,Object? remainingAmount = null,Object? amountOfSplits = null,Object? splitType = null,Object? status = null,Object? message = freezed,Object? creatorAccountId = null,Object? creatorAccount = freezed,Object? expiredAt = null,Object? recipients = null,Object? isOpen = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,currency: null == currency ? _self.currency : currency // ignore: cast_nullable_to_non_nullable
as String,totalAmount: null == totalAmount ? _self.totalAmount : totalAmount // ignore: cast_nullable_to_non_nullable
as double,splitType: null == splitType ? _self.splitType : splitType // ignore: cast_nullable_to_non_nullable
as double,remainingAmount: null == remainingAmount ? _self.remainingAmount : remainingAmount // ignore: cast_nullable_to_non_nullable
as double,amountOfSplits: null == amountOfSplits ? _self.amountOfSplits : amountOfSplits // ignore: cast_nullable_to_non_nullable
as int,splitType: null == splitType ? _self.splitType : splitType // ignore: cast_nullable_to_non_nullable
as int,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as int,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
as String?,creatorAccountId: null == creatorAccountId ? _self.creatorAccountId : creatorAccountId // ignore: cast_nullable_to_non_nullable
as String,creatorAccount: freezed == creatorAccount ? _self.creatorAccount : creatorAccount // ignore: cast_nullable_to_non_nullable
as SnAccount?,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as DateTime,recipients: null == recipients ? _self.recipients : recipients // ignore: cast_nullable_to_non_nullable
as List<SnWalletFundRecipient>,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as List<SnWalletFundRecipient>,isOpen: null == isOpen ? _self.isOpen : isOpen // ignore: cast_nullable_to_non_nullable
as bool,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
@@ -2714,10 +2717,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String currency, double totalAmount, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String currency, double totalAmount, double remainingAmount, int amountOfSplits, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, bool isOpen, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SnWalletFund() when $default != null:
return $default(_that.id,_that.currency,_that.totalAmount,_that.splitType,_that.status,_that.message,_that.creatorAccountId,_that.creatorAccount,_that.expiredAt,_that.recipients,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return $default(_that.id,_that.currency,_that.totalAmount,_that.remainingAmount,_that.amountOfSplits,_that.splitType,_that.status,_that.message,_that.creatorAccountId,_that.creatorAccount,_that.expiredAt,_that.recipients,_that.isOpen,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return orElse();
}
@@ -2735,10 +2738,10 @@ return $default(_that.id,_that.currency,_that.totalAmount,_that.splitType,_that.
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String currency, double totalAmount, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String currency, double totalAmount, double remainingAmount, int amountOfSplits, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, bool isOpen, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
switch (_that) {
case _SnWalletFund():
return $default(_that.id,_that.currency,_that.totalAmount,_that.splitType,_that.status,_that.message,_that.creatorAccountId,_that.creatorAccount,_that.expiredAt,_that.recipients,_that.createdAt,_that.updatedAt,_that.deletedAt);}
return $default(_that.id,_that.currency,_that.totalAmount,_that.remainingAmount,_that.amountOfSplits,_that.splitType,_that.status,_that.message,_that.creatorAccountId,_that.creatorAccount,_that.expiredAt,_that.recipients,_that.isOpen,_that.createdAt,_that.updatedAt,_that.deletedAt);}
}
/// A variant of `when` that fallback to returning `null`
///
@@ -2752,10 +2755,10 @@ return $default(_that.id,_that.currency,_that.totalAmount,_that.splitType,_that.
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String currency, double totalAmount, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String currency, double totalAmount, double remainingAmount, int amountOfSplits, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, bool isOpen, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
switch (_that) {
case _SnWalletFund() when $default != null:
return $default(_that.id,_that.currency,_that.totalAmount,_that.splitType,_that.status,_that.message,_that.creatorAccountId,_that.creatorAccount,_that.expiredAt,_that.recipients,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return $default(_that.id,_that.currency,_that.totalAmount,_that.remainingAmount,_that.amountOfSplits,_that.splitType,_that.status,_that.message,_that.creatorAccountId,_that.creatorAccount,_that.expiredAt,_that.recipients,_that.isOpen,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return null;
}
@@ -2767,12 +2770,14 @@ return $default(_that.id,_that.currency,_that.totalAmount,_that.splitType,_that.
@JsonSerializable()
class _SnWalletFund implements SnWalletFund {
const _SnWalletFund({required this.id, required this.currency, required this.totalAmount, required this.splitType, required this.status, required this.message, required this.creatorAccountId, required this.creatorAccount, required this.expiredAt, required final List<SnWalletFundRecipient> recipients, required this.createdAt, required this.updatedAt, required this.deletedAt}): _recipients = recipients;
const _SnWalletFund({required this.id, required this.currency, required this.totalAmount, required this.remainingAmount, required this.amountOfSplits, required this.splitType, required this.status, required this.message, required this.creatorAccountId, required this.creatorAccount, required this.expiredAt, required final List<SnWalletFundRecipient> recipients, required this.isOpen, required this.createdAt, required this.updatedAt, required this.deletedAt}): _recipients = recipients;
factory _SnWalletFund.fromJson(Map<String, dynamic> json) => _$SnWalletFundFromJson(json);
@override final String id;
@override final String currency;
@override final double totalAmount;
@override final double remainingAmount;
@override final int amountOfSplits;
@override final int splitType;
// 0: even, 1: random
@override final int status;
@@ -2788,6 +2793,7 @@ class _SnWalletFund implements SnWalletFund {
return EqualUnmodifiableListView(_recipients);
}
@override final bool isOpen;
@override final DateTime createdAt;
@override final DateTime updatedAt;
@override final DateTime? deletedAt;
@@ -2805,16 +2811,16 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnWalletFund&&(identical(other.id, id) || other.id == id)&&(identical(other.currency, currency) || other.currency == currency)&&(identical(other.totalAmount, totalAmount) || other.totalAmount == totalAmount)&&(identical(other.splitType, splitType) || other.splitType == splitType)&&(identical(other.status, status) || other.status == status)&&(identical(other.message, message) || other.message == message)&&(identical(other.creatorAccountId, creatorAccountId) || other.creatorAccountId == creatorAccountId)&&(identical(other.creatorAccount, creatorAccount) || other.creatorAccount == creatorAccount)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&const DeepCollectionEquality().equals(other._recipients, _recipients)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnWalletFund&&(identical(other.id, id) || other.id == id)&&(identical(other.currency, currency) || other.currency == currency)&&(identical(other.totalAmount, totalAmount) || other.totalAmount == totalAmount)&&(identical(other.remainingAmount, remainingAmount) || other.remainingAmount == remainingAmount)&&(identical(other.amountOfSplits, amountOfSplits) || other.amountOfSplits == amountOfSplits)&&(identical(other.splitType, splitType) || other.splitType == splitType)&&(identical(other.status, status) || other.status == status)&&(identical(other.message, message) || other.message == message)&&(identical(other.creatorAccountId, creatorAccountId) || other.creatorAccountId == creatorAccountId)&&(identical(other.creatorAccount, creatorAccount) || other.creatorAccount == creatorAccount)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&const DeepCollectionEquality().equals(other._recipients, _recipients)&&(identical(other.isOpen, isOpen) || other.isOpen == isOpen)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,currency,totalAmount,splitType,status,message,creatorAccountId,creatorAccount,expiredAt,const DeepCollectionEquality().hash(_recipients),createdAt,updatedAt,deletedAt);
int get hashCode => Object.hash(runtimeType,id,currency,totalAmount,remainingAmount,amountOfSplits,splitType,status,message,creatorAccountId,creatorAccount,expiredAt,const DeepCollectionEquality().hash(_recipients),isOpen,createdAt,updatedAt,deletedAt);
@override
String toString() {
return 'SnWalletFund(id: $id, currency: $currency, totalAmount: $totalAmount, splitType: $splitType, status: $status, message: $message, creatorAccountId: $creatorAccountId, creatorAccount: $creatorAccount, expiredAt: $expiredAt, recipients: $recipients, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
return 'SnWalletFund(id: $id, currency: $currency, totalAmount: $totalAmount, remainingAmount: $remainingAmount, amountOfSplits: $amountOfSplits, splitType: $splitType, status: $status, message: $message, creatorAccountId: $creatorAccountId, creatorAccount: $creatorAccount, expiredAt: $expiredAt, recipients: $recipients, isOpen: $isOpen, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
@@ -2825,7 +2831,7 @@ abstract mixin class _$SnWalletFundCopyWith<$Res> implements $SnWalletFundCopyWi
factory _$SnWalletFundCopyWith(_SnWalletFund value, $Res Function(_SnWalletFund) _then) = __$SnWalletFundCopyWithImpl;
@override @useResult
$Res call({
String id, String currency, double totalAmount, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
String id, String currency, double totalAmount, double remainingAmount, int amountOfSplits, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, bool isOpen, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
@@ -2842,19 +2848,22 @@ class __$SnWalletFundCopyWithImpl<$Res>
/// Create a copy of SnWalletFund
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? currency = null,Object? totalAmount = null,Object? splitType = null,Object? status = null,Object? message = freezed,Object? creatorAccountId = null,Object? creatorAccount = freezed,Object? expiredAt = null,Object? recipients = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? currency = null,Object? totalAmount = null,Object? remainingAmount = null,Object? amountOfSplits = null,Object? splitType = null,Object? status = null,Object? message = freezed,Object? creatorAccountId = null,Object? creatorAccount = freezed,Object? expiredAt = null,Object? recipients = null,Object? isOpen = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_SnWalletFund(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,currency: null == currency ? _self.currency : currency // ignore: cast_nullable_to_non_nullable
as String,totalAmount: null == totalAmount ? _self.totalAmount : totalAmount // ignore: cast_nullable_to_non_nullable
as double,splitType: null == splitType ? _self.splitType : splitType // ignore: cast_nullable_to_non_nullable
as double,remainingAmount: null == remainingAmount ? _self.remainingAmount : remainingAmount // ignore: cast_nullable_to_non_nullable
as double,amountOfSplits: null == amountOfSplits ? _self.amountOfSplits : amountOfSplits // ignore: cast_nullable_to_non_nullable
as int,splitType: null == splitType ? _self.splitType : splitType // ignore: cast_nullable_to_non_nullable
as int,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as int,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
as String?,creatorAccountId: null == creatorAccountId ? _self.creatorAccountId : creatorAccountId // ignore: cast_nullable_to_non_nullable
as String,creatorAccount: freezed == creatorAccount ? _self.creatorAccount : creatorAccount // ignore: cast_nullable_to_non_nullable
as SnAccount?,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as DateTime,recipients: null == recipients ? _self._recipients : recipients // ignore: cast_nullable_to_non_nullable
as List<SnWalletFundRecipient>,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as List<SnWalletFundRecipient>,isOpen: null == isOpen ? _self.isOpen : isOpen // ignore: cast_nullable_to_non_nullable
as bool,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,

View File

@@ -336,6 +336,8 @@ _SnWalletFund _$SnWalletFundFromJson(
id: json['id'] as String,
currency: json['currency'] as String,
totalAmount: (json['total_amount'] as num).toDouble(),
remainingAmount: (json['remaining_amount'] as num).toDouble(),
amountOfSplits: (json['amount_of_splits'] as num).toInt(),
splitType: (json['split_type'] as num).toInt(),
status: (json['status'] as num).toInt(),
message: json['message'] as String?,
@@ -349,6 +351,7 @@ _SnWalletFund _$SnWalletFundFromJson(
(json['recipients'] as List<dynamic>)
.map((e) => SnWalletFundRecipient.fromJson(e as Map<String, dynamic>))
.toList(),
isOpen: json['is_open'] as bool,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt:
@@ -362,6 +365,8 @@ Map<String, dynamic> _$SnWalletFundToJson(_SnWalletFund instance) =>
'id': instance.id,
'currency': instance.currency,
'total_amount': instance.totalAmount,
'remaining_amount': instance.remainingAmount,
'amount_of_splits': instance.amountOfSplits,
'split_type': instance.splitType,
'status': instance.status,
'message': instance.message,
@@ -369,6 +374,7 @@ Map<String, dynamic> _$SnWalletFundToJson(_SnWalletFund instance) =>
'creator_account': instance.creatorAccount?.toJson(),
'expired_at': instance.expiredAt.toIso8601String(),
'recipients': instance.recipients.map((e) => e.toJson()).toList(),
'is_open': instance.isOpen,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),

View File

@@ -9,6 +9,7 @@ import "package:island/database/message.dart";
import "package:island/models/chat.dart";
import "package:island/models/file.dart";
import "package:island/models/poll.dart";
import "package:island/models/wallet.dart";
import "package:island/pods/database.dart";
import "package:island/pods/lifecycle.dart";
import "package:island/pods/network.dart";
@@ -439,6 +440,7 @@ class MessagesNotifier extends _$MessagesNotifier {
String content,
List<UniversalFile> attachments, {
SnPoll? poll,
SnWalletFund? fund,
SnChatMessage? editingTo,
SnChatMessage? forwardingTo,
SnChatMessage? replyingTo,
@@ -501,6 +503,7 @@ class MessagesNotifier extends _$MessagesNotifier {
'replied_message_id': replyingTo?.id,
'forwarded_message_id': forwardingTo?.id,
'poll_id': poll?.id,
'fund_id': fund?.id,
'meta': {},
'nonce': nonce,
},

View File

@@ -6,7 +6,7 @@ part of 'messages_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$messagesNotifierHash() => r'c009eb8598e8b5fbcece2d0b5213b2e434edb3b2';
String _$messagesNotifierHash() => r'fc9c99024a0801efa4894f250aea8bdc6127a0b6';
/// Copied from Dart SDK
class _SystemHash {

View File

@@ -375,16 +375,17 @@ class AccountScreen extends HookConsumerWidget {
);
},
),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.files),
trailing: const Icon(Symbols.chevron_right),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('files').tr(),
onTap: () {
context.goNamed('files');
},
),
if (!isWideScreen(context))
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.files),
trailing: const Icon(Symbols.chevron_right),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('files').tr(),
onTap: () {
context.goNamed('files');
},
),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.wallet),

View File

@@ -12,6 +12,7 @@ import "package:island/database/message.dart";
import "package:island/models/chat.dart";
import "package:island/models/file.dart";
import "package:island/models/poll.dart";
import "package:island/models/wallet.dart";
import "package:island/pods/chat/chat_rooms.dart";
import "package:island/pods/chat/chat_subscribe.dart";
import "package:island/pods/chat/messages_notifier.dart";
@@ -172,6 +173,7 @@ class ChatRoomScreen extends HookConsumerWidget {
final messageForwardingTo = useState<SnChatMessage?>(null);
final messageEditingTo = useState<SnChatMessage?>(null);
final selectedPoll = useState<SnPoll?>(null);
final selectedFund = useState<SnWalletFund?>(null);
final attachments = useState<List<UniversalFile>>([]);
final attachmentProgress = useState<Map<String, Map<int, double?>>>({});
@@ -288,12 +290,14 @@ class ChatRoomScreen extends HookConsumerWidget {
void sendMessage() {
if (messageController.text.trim().isNotEmpty ||
attachments.value.isNotEmpty ||
selectedPoll.value != null) {
selectedPoll.value != null ||
selectedFund.value != null) {
messagesNotifier.sendMessage(
ref,
messageController.text.trim(),
attachments.value,
poll: selectedPoll.value,
fund: selectedFund.value,
editingTo: messageEditingTo.value,
forwardingTo: messageForwardingTo.value,
replyingTo: messageReplyingTo.value,
@@ -309,6 +313,7 @@ class ChatRoomScreen extends HookConsumerWidget {
messageReplyingTo.value = null;
messageForwardingTo.value = null;
selectedPoll.value = null;
selectedFund.value = null;
attachments.value = [];
}
}
@@ -1252,12 +1257,15 @@ class ChatRoomScreen extends HookConsumerWidget {
messageReplyingTo.value = null;
messageForwardingTo.value = null;
selectedPoll.value = null;
selectedFund.value = null;
},
messageEditingTo: messageEditingTo.value,
messageReplyingTo: messageReplyingTo.value,
messageForwardingTo: messageForwardingTo.value,
selectedPoll: selectedPoll.value,
onPollSelected: (poll) => selectedPoll.value = poll,
selectedFund: selectedFund.value,
onFundSelected: (fund) => selectedFund.value = fund,
onPickFile: (bool isPhoto) {
if (isPhoto) {
pickPhotoMedia();

View File

@@ -237,7 +237,9 @@ class PostSearchScreen extends HookConsumerWidget {
controller: pubNameController,
decoration: InputDecoration(
labelText: 'pubName'.tr(),
border: OutlineInputBorder(),
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
),
onChanged:
(value) => onSearchWithFilters(searchController.text),
@@ -247,7 +249,9 @@ class PostSearchScreen extends HookConsumerWidget {
controller: realmController,
decoration: InputDecoration(
labelText: 'realm'.tr(),
border: OutlineInputBorder(),
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
),
onChanged:
(value) => onSearchWithFilters(searchController.text),

View File

@@ -55,6 +55,7 @@ class CreateFundSheet extends StatefulWidget {
class _CreateFundSheetState extends State<CreateFundSheet> {
final amountController = TextEditingController();
final splitsController = TextEditingController(text: '1');
final messageController = TextEditingController();
String selectedCurrency = 'golds';
int selectedSplitType = 0; // 0: even, 1: random
@@ -64,6 +65,7 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
void dispose() {
amountController.dispose();
messageController.dispose();
splitsController.dispose();
super.dispose();
}
@@ -103,17 +105,9 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
labelText: 'enterAmount'.tr(),
hintText: '0.00',
prefixIcon: Icon(kCurrencyIconData[selectedCurrency]),
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,
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
),
),
@@ -136,17 +130,9 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
DropdownButtonFormField<String>(
value: selectedCurrency,
decoration: InputDecoration(
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,
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
),
),
@@ -173,49 +159,84 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
},
),
// Split Type Section (only show when there are 2+ recipients)
if (selectedRecipients.length >= 2) ...[
const Gap(16),
Text(
'splitType'.tr(),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
const Gap(16),
// Amount of Splits Section
Text(
'amountOfSplits'.tr(),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
const Gap(8),
TextField(
controller: splitsController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: InputDecoration(
labelText: 'enterNumberOfSplits'.tr(),
hintText:
selectedRecipients.isNotEmpty
? selectedRecipients.length.toString()
: '1',
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
),
),
const Gap(8),
Row(
children: [
Expanded(
child: RadioListTile<int>(
title: Text('evenSplit'.tr()),
subtitle: Text('equalAmountEach'.tr()),
value: 0,
groupValue: selectedSplitType,
onChanged: (value) {
if (value != null) {
setState(() => selectedSplitType = value);
}
},
),
),
Expanded(
child: RadioListTile<int>(
title: Text('randomSplit'.tr()),
subtitle: Text('randomAmountEach'.tr()),
value: 1,
groupValue: selectedSplitType,
onChanged: (value) {
if (value != null) {
setState(() => selectedSplitType = value);
}
},
),
),
],
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
onChanged: (value) {
if (value.isEmpty && selectedRecipients.isNotEmpty) {
splitsController.text =
selectedRecipients.length.toString();
}
},
),
const Gap(16),
Text(
'splitType'.tr(),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
],
),
const Gap(8),
Row(
children: [
Expanded(
child: RadioListTile<int>(
title: Text('evenSplit'.tr()),
subtitle: Text('equalAmountEach'.tr()),
value: 0,
groupValue: selectedSplitType,
onChanged: (value) {
if (value != null) {
setState(() => selectedSplitType = value);
}
},
),
),
Expanded(
child: RadioListTile<int>(
title: Text('randomSplit'.tr()),
subtitle: Text('randomAmountEach'.tr()),
value: 1,
groupValue: selectedSplitType,
onChanged: (value) {
if (value != null) {
setState(() => selectedSplitType = value);
}
},
),
),
],
),
const Gap(16),
@@ -370,17 +391,9 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
labelText: 'personalMessage'.tr(),
hintText: 'addPersonalMessageForRecipients'.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,
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
),
),
@@ -520,14 +533,15 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
Future<void> _createFund() async {
final amount = double.tryParse(amountController.text);
final splits = int.tryParse(splitsController.text);
if (amount == null || amount <= 0) {
showErrorAlert('invalidAmount'.tr());
return;
}
if (selectedRecipients.isEmpty) {
showErrorAlert('noRecipientsSelected'.tr());
if (splits == null || splits <= 0) {
showErrorAlert('invalidNumberOfSplits'.tr());
return;
}
@@ -535,6 +549,7 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
'currency': selectedCurrency,
'total_amount': amount,
'split_type': selectedSplitType,
'amount_of_splits': splits,
'recipient_account_ids': selectedRecipients.map((r) => r.id).toList(),
'message':
messageController.text.trim().isEmpty
@@ -610,17 +625,9 @@ class _CreateTransferSheetState extends State<CreateTransferSheet> {
labelText: 'enterAmount'.tr(),
hintText: '0.00',
prefixIcon: Icon(kCurrencyIconData[selectedCurrency]),
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,
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
),
),
@@ -643,17 +650,9 @@ class _CreateTransferSheetState extends State<CreateTransferSheet> {
DropdownButtonFormField<String>(
value: selectedCurrency,
decoration: InputDecoration(
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,
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
),
),
@@ -817,17 +816,9 @@ class _CreateTransferSheetState extends State<CreateTransferSheet> {
labelText: 'transferRemark'.tr(),
hintText: 'addRemarkForTransfer'.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,
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
),
),

File diff suppressed because it is too large Load Diff

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

@@ -3,8 +3,8 @@ import 'package:island/models/embed.dart';
import 'package:island/utils/mapping.dart';
import 'package:island/widgets/content/embed/link.dart';
import 'package:island/widgets/poll/poll_submit.dart';
import 'package:island/widgets/wallet/fund_envelope.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:styled_widget/styled_widget.dart';
class EmbedListWidget extends StatelessWidget {
final List<dynamic> embeds;
@@ -96,16 +96,32 @@ class EmbedListWidget extends StatelessWidget {
horizontal: renderingPadding.horizontal,
vertical: 8,
),
child:
embedData['id'] == null
? const Text('Poll was unavailable...')
: PollSubmit(
pollId: embedData['id'],
onSubmit: (_) {},
isReadonly: !isInteractive,
isInitiallyExpanded: isFullPost,
).padding(horizontal: 16, vertical: 12),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child:
embedData['id'] == null
? const Text('Poll was unavailable...')
: PollSubmit(
pollId: embedData['id'],
onSubmit: (_) {},
isReadonly: !isInteractive,
isInitiallyExpanded: isFullPost,
),
),
),
'fund' =>
embedData['id'] == null
? const Text('Fund envelope was unavailable...')
: FundEnvelopeWidget(
fundId: embedData['id'],
margin: EdgeInsets.symmetric(
horizontal: renderingPadding.horizontal,
vertical: 8,
),
),
_ => Text('Unable show embed: ${embedData['type']}'),
},
),

View File

@@ -3,12 +3,12 @@ import 'package:flutter/material.dart';
class UniversalVideo extends StatelessWidget {
final String uri;
final double aspectRatio;
final double? aspectRatio;
final bool autoplay;
const UniversalVideo({
super.key,
required this.uri,
required this.aspectRatio,
this.aspectRatio,
this.autoplay = false,
});

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

@@ -16,10 +16,8 @@ import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/post/compose_attachments.dart';
import 'package:island/widgets/post/compose_form_fields.dart';
import 'package:island/widgets/post/compose_info_banner.dart';
import 'package:island/widgets/post/compose_settings_sheet.dart';
import 'package:island/widgets/post/compose_shared.dart';
import 'package:island/widgets/post/compose_state_utils.dart';
import 'package:island/widgets/post/compose_submit_utils.dart';
import 'package:island/widgets/post/compose_toolbar.dart';
import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/post/publishers_modal.dart';
@@ -143,16 +141,11 @@ class PostComposeCard extends HookConsumerWidget {
// Helper methods
void showSettingsSheet() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (context) => ComposeSettingsSheet(state: composeState),
);
ComposeLogic.showSettingsSheet(context, composeState);
}
Future<void> performSubmit() async {
await ComposeSubmitUtils.performSubmit(
await ComposeLogic.performSubmit(
ref,
composeState,
context,

View File

@@ -9,10 +9,8 @@ import 'package:island/services/compose_storage_db.dart';
import 'package:island/services/event_bus.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/post/compose_card.dart';
import 'package:island/widgets/post/compose_settings_sheet.dart';
import 'package:island/widgets/post/compose_shared.dart';
import 'package:island/widgets/post/compose_state_utils.dart';
import 'package:island/widgets/post/compose_submit_utils.dart';
import 'package:material_symbols_icons/symbols.dart';
/// A dialog that wraps PostComposeCard for easy use in dialogs.
@@ -104,16 +102,11 @@ class PostComposeDialog extends HookConsumerWidget {
// Helper methods for actions
void showSettingsSheet() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (context) => ComposeSettingsSheet(state: state),
);
ComposeLogic.showSettingsSheet(context, state);
}
Future<void> performSubmit() async {
await ComposeSubmitUtils.performSubmit(
await ComposeLogic.performSubmit(
ref,
state,
context,

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:
(_) =>

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:collection/collection.dart';
import 'package:island/services/event_bus.dart';
import 'package:island/widgets/post/compose_settings_sheet.dart';
import 'package:mime/mime.dart';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -21,6 +22,7 @@ import 'package:island/services/compose_storage_db.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/post/compose_link_attachments.dart';
import 'package:island/widgets/post/compose_poll.dart';
import 'package:island/widgets/post/compose_fund.dart';
import 'package:island/widgets/post/compose_recorder.dart';
import 'package:island/pods/file_pool.dart';
import 'package:pasteboard/pasteboard.dart';
@@ -44,6 +46,8 @@ class ComposeState {
int postType;
// Linked poll id for this compose session (nullable)
final ValueNotifier<String?> pollId;
// Linked fund id for this compose session (nullable)
final ValueNotifier<String?> fundId;
Timer? _autoSaveTimer;
ComposeState({
@@ -63,7 +67,9 @@ class ComposeState {
required this.draftId,
this.postType = 0,
String? pollId,
}) : pollId = ValueNotifier<String?>(pollId);
String? fundId,
}) : pollId = ValueNotifier<String?>(pollId),
fundId = ValueNotifier<String?>(fundId);
void startAutoSave(WidgetRef ref) {
_autoSaveTimer?.cancel();
@@ -98,6 +104,22 @@ class ComposeLogic {
// Initialize categories from original post
final categories = originalPost?.categories ?? <SnPostCategory>[];
// Extract poll and fund IDs from embeds
String? pollId;
String? fundId;
if (originalPost?.meta?['embeds'] is List) {
final embeds =
(originalPost!.meta!['embeds'] as List).cast<Map<String, dynamic>>();
try {
final pollEmbed = embeds.firstWhere((e) => e['type'] == 'poll');
pollId = pollEmbed['id'];
} catch (_) {}
try {
final fundEmbed = embeds.firstWhere((e) => e['type'] == 'fund');
fundId = fundEmbed['id'];
} catch (_) {}
}
return ComposeState(
attachments: ValueNotifier<List<UniversalFile>>(
originalPost?.attachments
@@ -131,8 +153,8 @@ class ComposeLogic {
embedView: ValueNotifier<SnPostEmbedView?>(originalPost?.embedView),
draftId: id,
postType: postType,
// initialize without poll by default
pollId: null,
pollId: pollId,
fundId: fundId,
);
}
@@ -158,6 +180,8 @@ class ComposeLogic {
draftId: draft.id,
postType: postType,
pollId: null,
// initialize without fund by default
fundId: null,
);
}
@@ -618,15 +642,40 @@ class ComposeLogic {
state.pollId.value = poll.id;
}
static Future<void> performAction(
static Future<void> pickFund(
WidgetRef ref,
ComposeState state,
BuildContext context,
) async {
if (state.fundId.value != null) {
state.fundId.value = null;
return;
}
final fund = await showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => const ComposeFundSheet(),
);
if (fund == null) return;
state.fundId.value = fund.id;
}
/// Unified submit method that returns the created/updated post.
static Future<SnPost> performSubmit(
WidgetRef ref,
ComposeState state,
BuildContext context, {
SnPost? originalPost,
SnPost? repliedPost,
SnPost? forwardedPost,
required Function() onSuccess,
}) async {
if (state.submitting.value) return;
if (state.submitting.value) {
throw Exception('Already submitting');
}
// Don't submit empty posts (no content and no attachments)
final hasContent =
@@ -636,25 +685,26 @@ class ComposeLogic {
final hasAttachments = state.attachments.value.isNotEmpty;
if (!hasContent && !hasAttachments) {
if (context.mounted) {
showSnackBar('postContentEmpty'.tr());
}
return; // Don't submit empty posts
showErrorAlert('postContentEmpty'.tr());
throw Exception('Post content is empty'); // Don't submit empty posts
}
try {
state.submitting.value = true;
// pload any local attachments first
// Upload any local attachments first
await Future.wait(
state.attachments.value
.asMap()
.entries
.where((entry) => entry.value.isOnDevice)
.map((entry) => uploadAttachment(ref, state, entry.key)),
.map(
(entry) => ComposeLogic.uploadAttachment(ref, state, entry.key),
),
);
// Prepare API request
final client = ref.watch(apiClientProvider);
final client = ref.read(apiClientProvider);
final isNewPost = originalPost == null;
final endpoint =
'/sphere${isNewPost ? '/posts' : '/posts/${originalPost.id}'}';
@@ -679,43 +729,85 @@ class ComposeLogic {
'categories': state.categories.value.map((e) => e.slug).toList(),
if (state.realm.value != null) 'realm_id': state.realm.value?.id,
if (state.pollId.value != null) 'poll_id': state.pollId.value,
if (state.fundId.value != null) 'fund_id': state.fundId.value,
if (state.embedView.value != null)
'embed_view': state.embedView.value!.toJson(),
};
// Send request
await client.request(
final response = await client.request(
endpoint,
queryParameters: {'pub': state.currentPublisher.value?.name},
data: payload,
options: Options(method: isNewPost ? 'POST' : 'PATCH'),
);
// Delete draft after successful submission
if (state.postType == 1) {
// Delete article draft
await ref
.read(composeStorageNotifierProvider.notifier)
.deleteDraft(state.draftId);
} else {
// Delete regular post draft
await ref
.read(composeStorageNotifierProvider.notifier)
.deleteDraft(state.draftId);
}
if (context.mounted) {
Navigator.of(context).maybePop(true);
}
// Parse the response into a SnPost
final post = SnPost.fromJson(response.data);
// Call the success callback
onSuccess();
eventBus.fire(PostCreatedEvent());
return post;
} catch (err) {
showErrorAlert(err);
// Show error message if context is mounted
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $err')));
}
rethrow;
} finally {
state.submitting.value = false;
}
}
static Future<void> performAction(
WidgetRef ref,
ComposeState state,
BuildContext context, {
SnPost? originalPost,
SnPost? repliedPost,
SnPost? forwardedPost,
}) async {
await ComposeLogic.performSubmit(
ref,
state,
context,
originalPost: originalPost,
repliedPost: repliedPost,
forwardedPost: forwardedPost,
onSuccess: () async {
// Delete draft after successful submission
if (state.postType == 1) {
// Delete article draft
await ref
.read(composeStorageNotifierProvider.notifier)
.deleteDraft(state.draftId);
} else {
// Delete regular post draft
await ref
.read(composeStorageNotifierProvider.notifier)
.deleteDraft(state.draftId);
}
if (context.mounted) {
Navigator.of(context).maybePop(true);
}
},
);
}
/// Shows the settings sheet modal.
static void showSettingsSheet(BuildContext context, ComposeState state) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => ComposeSettingsSheet(state: state),
);
}
static Future<void> handlePaste(ComposeState state) async {
final clipboard = await Pasteboard.image;
if (clipboard == null) return;
@@ -778,5 +870,6 @@ class ComposeLogic {
state.realm.dispose();
state.embedView.dispose();
state.pollId.dispose();
state.fundId.dispose();
}
}

View File

@@ -8,10 +8,8 @@ import 'package:island/screens/posts/compose.dart';
import 'package:island/services/compose_storage_db.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/post/compose_card.dart';
import 'package:island/widgets/post/compose_settings_sheet.dart';
import 'package:island/widgets/post/compose_shared.dart';
import 'package:island/widgets/post/compose_state_utils.dart';
import 'package:island/widgets/post/compose_submit_utils.dart';
import 'package:material_symbols_icons/symbols.dart';
/// A dialog that wraps PostComposeCard for easy use in dialogs.
@@ -106,16 +104,11 @@ class PostComposeSheet extends HookConsumerWidget {
// Helper methods for actions
void showSettingsSheet() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (context) => ComposeSettingsSheet(state: state),
);
ComposeLogic.showSettingsSheet(context, state);
}
Future<void> performSubmit() async {
await ComposeSubmitUtils.performSubmit(
await ComposeLogic.performSubmit(
ref,
state,
context,

View File

@@ -1,145 +0,0 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart';
import 'package:island/pods/network.dart';
import 'package:island/services/event_bus.dart';
import 'package:island/widgets/post/compose_settings_sheet.dart';
import 'package:island/widgets/post/compose_shared.dart';
/// Utility class for common compose submit logic.
class ComposeSubmitUtils {
/// Performs the submit action for posts.
static Future<SnPost> performSubmit(
WidgetRef ref,
ComposeState state,
BuildContext context, {
SnPost? originalPost,
SnPost? repliedPost,
SnPost? forwardedPost,
required Function() onSuccess,
}) async {
if (state.submitting.value) {
throw Exception('Already submitting');
}
// Don't submit empty posts (no content and no attachments)
final hasContent =
state.titleController.text.trim().isNotEmpty ||
state.descriptionController.text.trim().isNotEmpty ||
state.contentController.text.trim().isNotEmpty;
final hasAttachments = state.attachments.value.isNotEmpty;
if (!hasContent && !hasAttachments) {
// Show error message if context is mounted
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('postContentEmpty')));
}
throw Exception('Post content is empty'); // Don't submit empty posts
}
try {
state.submitting.value = true;
// Upload any local attachments first
await Future.wait(
state.attachments.value
.asMap()
.entries
.where((entry) => entry.value.isOnDevice)
.map(
(entry) => ComposeLogic.uploadAttachment(ref, state, entry.key),
),
);
// Prepare API request
final client = ref.read(apiClientProvider);
final isNewPost = originalPost == null;
final endpoint =
'/sphere${isNewPost ? '/posts' : '/posts/${originalPost.id}'}';
// Create request payload
final payload = {
'title': state.titleController.text,
'description': state.descriptionController.text,
'content': state.contentController.text,
if (state.slugController.text.isNotEmpty)
'slug': state.slugController.text,
'visibility': state.visibility.value,
'attachments':
state.attachments.value
.where((e) => e.isOnCloud)
.map((e) => e.data.id)
.toList(),
'type': state.postType,
if (repliedPost != null) 'replied_post_id': repliedPost.id,
if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id,
'tags': state.tags.value,
'categories': state.categories.value.map((e) => e.slug).toList(),
if (state.realm.value != null) 'realm_id': state.realm.value?.id,
if (state.pollId.value != null) 'poll_id': state.pollId.value,
if (state.embedView.value != null)
'embed_view': state.embedView.value!.toJson(),
};
// Send request
final response = await client.request(
endpoint,
queryParameters: {'pub': state.currentPublisher.value?.name},
data: payload,
options: Options(method: isNewPost ? 'POST' : 'PATCH'),
);
// Parse the response into a SnPost
final post = SnPost.fromJson(response.data);
// Call the success callback
onSuccess();
eventBus.fire(PostCreatedEvent());
return post;
} catch (err) {
// Show error message if context is mounted
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $err')));
}
rethrow;
} finally {
state.submitting.value = false;
}
}
/// Shows the settings sheet modal.
static void showSettingsSheet(BuildContext context, ComposeState state) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => ComposeSettingsSheet(state: state),
);
}
/// Handles keyboard press events for compose shortcuts.
static void handleKeyPress(
KeyEvent event,
ComposeState state,
WidgetRef ref,
BuildContext context, {
SnPost? originalPost,
SnPost? repliedPost,
SnPost? forwardedPost,
}) {
ComposeLogic.handleKeyPress(
event,
state,
ref,
context,
originalPost: originalPost,
repliedPost: repliedPost,
forwardedPost: forwardedPost,
);
}
}

View File

@@ -54,6 +54,10 @@ class ComposeToolbar extends HookConsumerWidget {
ComposeLogic.pickPoll(ref, state, context);
}
void pickFund() {
ComposeLogic.pickFund(ref, state, context);
}
void showEmbedSheet() {
showModalBottomSheet(
context: context,
@@ -143,6 +147,29 @@ class ComposeToolbar extends HookConsumerWidget {
);
},
),
// Fund button with visual state when a fund is linked
ListenableBuilder(
listenable: state.fundId,
builder: (context, _) {
return IconButton(
onPressed: pickFund,
icon: const Icon(Symbols.account_balance_wallet),
tooltip: 'fund'.tr(),
color: colorScheme.primary,
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -2,
),
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
state.fundId.value != null
? colorScheme.primary.withOpacity(0.15)
: null,
),
),
);
},
),
// Embed button with visual state when embed is present
ListenableBuilder(
listenable: state.embedView,
@@ -252,6 +279,25 @@ class ComposeToolbar extends HookConsumerWidget {
);
},
),
// Fund button with visual state when a fund is linked
ListenableBuilder(
listenable: state.fundId,
builder: (context, _) {
return IconButton(
onPressed: pickFund,
icon: const Icon(Symbols.account_balance_wallet),
tooltip: 'fund'.tr(),
color: colorScheme.primary,
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
state.fundId.value != null
? colorScheme.primary.withOpacity(0.15)
: null,
),
),
);
},
),
// Embed button with visual state when embed is present
ListenableBuilder(
listenable: state.embedView,

View File

@@ -0,0 +1,596 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/wallet.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:easy_localization/easy_localization.dart';
part 'fund_envelope.g.dart';
@riverpod
Future<SnWalletFund> walletFund(Ref ref, String fundId) async {
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get('/pass/wallets/funds/$fundId');
return SnWalletFund.fromJson(resp.data);
}
class FundEnvelopeWidget extends HookConsumerWidget {
const FundEnvelopeWidget({
super.key,
required this.fundId,
this.maxWidth,
this.margin,
});
final String fundId;
final double? maxWidth;
final EdgeInsetsGeometry? margin;
@override
Widget build(BuildContext context, WidgetRef ref) {
final fundAsync = ref.watch(walletFundProvider(fundId));
return Container(
width: maxWidth,
margin: margin ?? const EdgeInsets.symmetric(vertical: 8),
child: fundAsync.when(
loading:
() => Card(
margin: EdgeInsets.zero,
child: const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
),
),
error:
(error, stack) => Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 8),
Text(
'Failed to load fund envelope',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
],
),
),
),
data:
(fund) => Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => _showClaimDialog(context, ref, fund),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Fund title and status
Row(
children: [
Icon(
Icons.account_balance_wallet,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Fund Envelope',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
),
),
_buildStatusChips(context, fund),
],
),
const SizedBox(height: 12),
// Amount information
Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${fund.totalAmount.toStringAsFixed(2)} ${fund.currency}',
style: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 4),
if (fund.remainingAmount != fund.totalAmount)
Text(
'Remaining: ${fund.remainingAmount.toStringAsFixed(2)} ${fund.currency}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color:
Theme.of(context).colorScheme.secondary,
fontWeight: FontWeight.w500,
),
),
Text(
'Split: ${fund.splitType == 0 ? 'Evenly' : 'Randomly'}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color: Theme.of(context)
.textTheme
.bodySmall
?.color
?.withOpacity(0.7),
),
),
],
),
],
),
// Recipients overview
if (fund.recipients.isNotEmpty) ...[
const SizedBox(height: 12),
_buildRecipientsOverview(context, fund),
],
// Message
if (fund.message != null && fund.message!.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
'"${fund.message}"',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(
fontStyle: FontStyle.italic,
color:
Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
// Creator info
if (fund.creatorAccount != null) ...[
const SizedBox(height: 12),
Row(
children: [
Icon(
Icons.person,
size: 16,
color:
Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
fund.creatorAccount!.nick,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color:
Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
],
),
],
// Expiry info
const SizedBox(height: 6),
Row(
children: [
Icon(
Icons.schedule,
size: 16,
color:
Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
_formatDate(fund.expiredAt),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color:
Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
],
),
],
),
),
),
),
),
);
}
void _showClaimDialog(
BuildContext context,
WidgetRef ref,
SnWalletFund fund,
) {
showDialog(
context: context,
builder:
(dialogContext) => FundClaimDialog(
fund: fund,
onClaim: () async {
try {
final apiClient = ref.read(apiClientProvider);
await apiClient.post('/pass/wallets/funds/${fund.id}/receive');
// Refresh the fund data after claiming
ref.invalidate(walletFundProvider(fund.id));
if (dialogContext.mounted) {
Navigator.of(dialogContext).pop();
ScaffoldMessenger.of(dialogContext).showSnackBar(
SnackBar(content: Text('Fund claimed successfully!'.tr())),
);
}
} catch (e) {
if (dialogContext.mounted) {
ScaffoldMessenger.of(dialogContext).showSnackBar(
SnackBar(
content: Text('Failed to claim fund: $e'),
backgroundColor:
Theme.of(dialogContext).colorScheme.error,
),
);
}
}
},
),
);
}
Widget _buildStatusChip(BuildContext context, int status) {
String text;
Color color;
switch (status) {
case 0:
text = 'Created';
color = Colors.blue;
break;
case 1:
text = 'Partially Claimed';
color = Colors.orange;
break;
case 2:
text = 'Fully Claimed';
color = Colors.green;
break;
case 3:
text = 'Expired';
color = Colors.red;
break;
default:
text = 'Unknown';
color = Colors.grey;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Text(
text,
style: TextStyle(
fontSize: 10,
color: color,
fontWeight: FontWeight.w500,
),
),
);
}
Widget _buildStatusChips(BuildContext context, SnWalletFund fund) {
return Row(
children: [
if (fund.isOpen) ...[
_buildOpenFundBadge(context),
const SizedBox(width: 6),
],
_buildStatusChip(context, fund.status),
],
);
}
Widget _buildOpenFundBadge(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.green.withOpacity(0.3)),
),
child: Text(
'Open Fund'.tr(),
style: const TextStyle(
fontSize: 10,
color: Colors.green,
fontWeight: FontWeight.w500,
),
),
);
}
Widget _buildRecipientsOverview(BuildContext context, SnWalletFund fund) {
final claimedCount = fund.recipients.where((r) => r.isReceived).length;
final totalCount =
fund.isOpen ? fund.amountOfSplits : fund.recipients.length;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Recipients ($claimedCount/$totalCount claimed)',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: totalCount > 0 ? claimedCount / totalCount : 0,
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
),
],
);
}
String _formatDate(DateTime date) {
try {
final now = DateTime.now();
final difference = date.difference(now);
if (difference.isNegative) {
return 'Expired ${difference.inDays.abs()} days ago';
} else if (difference.inDays == 0) {
final hours = difference.inHours;
if (hours == 0) {
return 'Expires soon';
}
return 'Expires in $hours hour${hours == 1 ? '' : 's'}';
} else if (difference.inDays < 7) {
return 'Expires in ${difference.inDays} day${difference.inDays == 1 ? '' : 's'}';
} else {
return '${date.day}/${date.month}/${date.year}';
}
} catch (e) {
return date.toString();
}
}
}
class FundClaimDialog extends HookConsumerWidget {
const FundClaimDialog({super.key, required this.fund, required this.onClaim});
final SnWalletFund fund;
final VoidCallback onClaim;
@override
Widget build(BuildContext context, WidgetRef ref) {
final userInfo = ref.watch(userInfoProvider);
// Check if user can claim
final now = DateTime.now();
final isExpired = fund.expiredAt.isBefore(now);
final hasRemainingAmount = fund.remainingAmount > 0;
final hasUserClaimed =
userInfo.value != null &&
fund.recipients.any(
(recipient) =>
recipient.recipientAccountId == userInfo.value!.id &&
recipient.isReceived,
);
final userAbleToClaim =
userInfo.value != null &&
(fund.isOpen ||
fund.recipients.any(
(recipient) => recipient.recipientAccountId == userInfo.value!.id,
));
final canClaim =
!isExpired && hasRemainingAmount && !hasUserClaimed && userAbleToClaim;
// Get claimed recipients for display
final claimedRecipients =
fund.recipients.where((r) => r.isReceived).toList();
final unclaimedRecipients =
fund.recipients.where((r) => !r.isReceived).toList();
final remainingSplits =
fund.isOpen
? fund.amountOfSplits - claimedRecipients.length
: unclaimedRecipients.length;
return AlertDialog(
title: Row(
children: [
Icon(
Icons.account_balance_wallet,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text('Claim Fund'.tr()),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Fund info
Text(
'${fund.totalAmount.toStringAsFixed(2)} ${fund.currency}',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
// Remaining amount
Text(
'${fund.remainingAmount.toStringAsFixed(2)} ${fund.currency} / ${remainingSplits} splits',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.secondary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
// Status indicator
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color:
fund.isOpen
? Colors.green.withOpacity(0.1)
: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color:
fund.isOpen
? Colors.green.withOpacity(0.3)
: Colors.blue.withOpacity(0.3),
),
),
child: Text(
fund.isOpen ? 'Open Fund'.tr() : 'Invite Only'.tr(),
style: TextStyle(
fontSize: 12,
color: fund.isOpen ? Colors.green : Colors.blue,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 16),
// Claimed recipients section
if (claimedRecipients.isNotEmpty) ...[
Text(
'Already Claimed'.tr(),
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
...claimedRecipients.map(
(recipient) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
Icon(Icons.check_circle, size: 16, color: Colors.green),
const SizedBox(width: 8),
Expanded(
child: Text(
recipient.recipientAccount?.nick ?? 'Unknown User',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
decoration: TextDecoration.lineThrough,
color:
Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
Text(
'${recipient.amount.toStringAsFixed(2)} ${fund.currency}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
),
),
const SizedBox(height: 12),
],
// Unclaimed recipients section
if (unclaimedRecipients.isNotEmpty) ...[
Text(
'Available to Claim'.tr(),
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
...unclaimedRecipients.map(
(recipient) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
Icon(
Icons.radio_button_unchecked,
size: 16,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withOpacity(0.5),
),
const SizedBox(width: 8),
Expanded(
child: Text(
recipient.recipientAccount?.nick ?? 'Unknown User',
style: Theme.of(context).textTheme.bodySmall,
),
),
Text(
'${recipient.amount.toStringAsFixed(2)} ${fund.currency}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Cancel'.tr()),
),
if (canClaim)
FilledButton.icon(
icon: const Icon(Icons.account_balance_wallet),
label: Text('Claim'.tr()),
onPressed: onClaim,
),
],
);
}
}

View File

@@ -0,0 +1,151 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'fund_envelope.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$walletFundHash() => r'521fa280708e71266f8164268ba11f135f4ba810';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
/// See also [walletFund].
@ProviderFor(walletFund)
const walletFundProvider = WalletFundFamily();
/// See also [walletFund].
class WalletFundFamily extends Family<AsyncValue<SnWalletFund>> {
/// See also [walletFund].
const WalletFundFamily();
/// See also [walletFund].
WalletFundProvider call(String fundId) {
return WalletFundProvider(fundId);
}
@override
WalletFundProvider getProviderOverride(
covariant WalletFundProvider provider,
) {
return call(provider.fundId);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'walletFundProvider';
}
/// See also [walletFund].
class WalletFundProvider extends AutoDisposeFutureProvider<SnWalletFund> {
/// See also [walletFund].
WalletFundProvider(String fundId)
: this._internal(
(ref) => walletFund(ref as WalletFundRef, fundId),
from: walletFundProvider,
name: r'walletFundProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$walletFundHash,
dependencies: WalletFundFamily._dependencies,
allTransitiveDependencies: WalletFundFamily._allTransitiveDependencies,
fundId: fundId,
);
WalletFundProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.fundId,
}) : super.internal();
final String fundId;
@override
Override overrideWith(
FutureOr<SnWalletFund> Function(WalletFundRef provider) create,
) {
return ProviderOverride(
origin: this,
override: WalletFundProvider._internal(
(ref) => create(ref as WalletFundRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
fundId: fundId,
),
);
}
@override
AutoDisposeFutureProviderElement<SnWalletFund> createElement() {
return _WalletFundProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is WalletFundProvider && other.fundId == fundId;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, fundId.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin WalletFundRef on AutoDisposeFutureProviderRef<SnWalletFund> {
/// The parameter `fundId` of this provider.
String get fundId;
}
class _WalletFundProviderElement
extends AutoDisposeFutureProviderElement<SnWalletFund>
with WalletFundRef {
_WalletFundProviderElement(super.provider);
@override
String get fundId => (origin as WalletFundProvider).fundId;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 3.3.0+145
version: 3.3.0+146
environment:
sdk: ^3.7.2