Compare commits

..

40 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
50c25e919c 🐛 Bug fixes in cloud file collection 2025-11-16 23:00:14 +08:00
99fb08dd55 Send message with poll 2025-11-16 22:43:18 +08:00
e43bc6b8a8 💄 Optimize poll 2025-11-16 22:29:24 +08:00
c247cdf81c ♻️ Refactored poll editor 2025-11-16 22:15:10 +08:00
3ffa730505 💄 Optimize chat input expand style 2025-11-16 21:49:41 +08:00
1cc34d3073 Chat input expansiable section basis 2025-11-16 21:42:10 +08:00
96a919cc4e 💫 Animated height padding in inputs 2025-11-16 20:20:24 +08:00
e7e3bfcadf 🐛 Fix oidc callback 2025-11-16 18:38:36 +08:00
a8617a5040 💄 Collspible link embeds 2025-11-16 18:23:24 +08:00
d94f8d004f 💄 Shows friends overview on mobile as well 2025-11-16 18:10:55 +08:00
d93b066979 📝 Rename the currencies 2025-11-16 17:59:10 +08:00
320664a547 💄 Friends overview optimization 2025-11-16 17:50:36 +08:00
98f4698d5b 🐛 Fixes of serval bugs 2025-11-16 17:40:37 +08:00
82397dd087 Friends overview basis 2025-11-16 17:26:31 +08:00
4ec10ceb47 ♻️ Rework of the oidc login flow (wip) 2025-11-16 17:07:34 +08:00
4b03b45a0d 💄 Create account, login modal varaint and more auth check to prevent user from entering certain widget in unauthorized state 2025-11-16 14:53:40 +08:00
7a72d32649 🐛 Fix publisher page account avatar no gesture detector, close #188 2025-11-16 12:06:48 +08:00
5152dd13ea 💄 Continue optimize the post reaction sheet 2025-11-16 12:04:13 +08:00
fd377aa7af 💄 Change the post reaction sheet sticker picker align and close 2025-11-16 11:56:49 +08:00
67044148f1 💄 Fix chat input overlaps with message sometimes 2025-11-16 11:48:51 +08:00
92bc43e4df 🐛 Fix chat creation cause stack has no page 2025-11-16 11:48:35 +08:00
a1a7b34c86 ♻️ Use bottom modal sheet for chat creation form 2025-11-16 11:46:31 +08:00
40c0e052cf 💄 Optimize thought input space to avoid input cover message 2025-11-16 11:22:47 +08:00
9a75228e38 Multi model support in thought 2025-11-16 02:45:02 +08:00
a9fd75cc45 Thinking billing check 2025-11-16 01:18:20 +08:00
a713b30d93 🐛 Fix bugs 2025-11-16 00:52:17 +08:00
e516f0a862 🐛 Bug fixes 2025-11-16 00:34:10 +08:00
429b966c4b 🐛 Fix wrong tool call progress status 2025-11-15 23:22:33 +08:00
f14da0d3a2 💫 Add tool call calling hint animation 2025-11-15 23:22:07 +08:00
d201182bd2 ♻️ Turn thought into a Tab 2025-11-15 23:08:31 +08:00
6f6422c15e 💄 Optimize thought function call style 2025-11-15 23:02:25 +08:00
9f6ae639ee 🐛 Fix publisher member management missing service id to use sphere API 2025-11-15 22:40:27 +08:00
35f4d7d885 ♻️ Updated the thought rendering 2025-11-15 22:16:29 +08:00
71 changed files with 6615 additions and 3365 deletions

View File

@@ -136,6 +136,7 @@
"reactionNegative": "Negative",
"reactionNeutral": "Neutral",
"customReaction": "Custom Reaction",
"customReactionHint": "Custom Reaction allow you to use user uploaded stickers as the symbol of the reaction for the post. Exclusive for Stellar Program members.",
"customReactions": "Custom Reactions",
"stickerPlaceholder": "Sticker Placeholder",
"reactionAttitude": "Reaction Attitude",
@@ -1302,7 +1303,9 @@
"thoughtInputHint": "Ask sn-chan anything...",
"thoughtNewConversation": "Start New Conversation",
"thoughtParseError": "Failed to parse AI response",
"thoughtFunctionCall": "Function Call",
"thoughtFunctionCall": "Use {}",
"thoughtFunctionCallBegin": "Calling tool {}",
"thoughtFunctionCallFinish": "{} responded",
"aiThought": "AI Thought",
"aiThoughtTitle": "Let sn-chan think",
"postReferenceUnavailable": "Referenced post is unavailable",
@@ -1321,5 +1324,17 @@
"popularity": "Popularity",
"descendingOrder": "Descending Order",
"selectDate": "Select Date",
"pinnedPosts": "Pinned Posts"
"pinnedPosts": "Pinned Posts",
"thoughtUnpaidHint": "Thinking unavaiable due to unpaid orders",
"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",
"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

@@ -251,10 +251,10 @@
"translatorBadgeName": "翻译者",
"translatorBadgeDescription": "协助将 Solar Network 翻译成不同语言",
"wallet": "钱包",
"walletCurrencyPoints": "新太阳点",
"walletCurrencyPoints": "源能点",
"walletCurrencyShortPoints": "NSP",
"walletCurrencyGolds": "太阳币",
"walletCurrencyShortGolds": "TSD",
"walletCurrencyGolds": "星辰碎片",
"walletCurrencyShortGolds": "SHD",
"retry": "重试",
"creatorHubUnselectedHint": "选择/创建一个发布者以开始使用。",
"relationships": "关系",
@@ -1090,5 +1090,6 @@
"thoughtNewConversation": "开始新对话",
"thoughtParseError": "解析 AI 响应失败",
"aiThought": "寻思",
"aiThoughtTitle": "让 SN 酱寻思寻思"
"aiThoughtTitle": "让 SN 酱寻思寻思",
"thoughtUnpaidHint": "寻思因为有未支付的订单而被禁用"
}

View File

@@ -1,4 +1,5 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/models/activity.dart';
import 'package:island/models/auth.dart';
import 'package:island/models/file.dart';
import 'package:island/models/wallet.dart';
@@ -263,3 +264,15 @@ sealed class SnSocialCreditRecord with _$SnSocialCreditRecord {
factory SnSocialCreditRecord.fromJson(Map<String, dynamic> json) =>
_$SnSocialCreditRecordFromJson(json);
}
@freezed
sealed class SnFriendOverviewItem with _$SnFriendOverviewItem {
const factory SnFriendOverviewItem({
required SnAccount account,
required SnAccountStatus status,
required List<SnPresenceActivity> activities,
}) = _SnFriendOverviewItem;
factory SnFriendOverviewItem.fromJson(Map<String, dynamic> json) =>
_$SnFriendOverviewItemFromJson(json);
}

View File

@@ -3912,4 +3912,309 @@ as DateTime?,
}
/// @nodoc
mixin _$SnFriendOverviewItem {
SnAccount get account; SnAccountStatus get status; List<SnPresenceActivity> get activities;
/// Create a copy of SnFriendOverviewItem
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnFriendOverviewItemCopyWith<SnFriendOverviewItem> get copyWith => _$SnFriendOverviewItemCopyWithImpl<SnFriendOverviewItem>(this as SnFriendOverviewItem, _$identity);
/// Serializes this SnFriendOverviewItem to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnFriendOverviewItem&&(identical(other.account, account) || other.account == account)&&(identical(other.status, status) || other.status == status)&&const DeepCollectionEquality().equals(other.activities, activities));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,account,status,const DeepCollectionEquality().hash(activities));
@override
String toString() {
return 'SnFriendOverviewItem(account: $account, status: $status, activities: $activities)';
}
}
/// @nodoc
abstract mixin class $SnFriendOverviewItemCopyWith<$Res> {
factory $SnFriendOverviewItemCopyWith(SnFriendOverviewItem value, $Res Function(SnFriendOverviewItem) _then) = _$SnFriendOverviewItemCopyWithImpl;
@useResult
$Res call({
SnAccount account, SnAccountStatus status, List<SnPresenceActivity> activities
});
$SnAccountCopyWith<$Res> get account;$SnAccountStatusCopyWith<$Res> get status;
}
/// @nodoc
class _$SnFriendOverviewItemCopyWithImpl<$Res>
implements $SnFriendOverviewItemCopyWith<$Res> {
_$SnFriendOverviewItemCopyWithImpl(this._self, this._then);
final SnFriendOverviewItem _self;
final $Res Function(SnFriendOverviewItem) _then;
/// Create a copy of SnFriendOverviewItem
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? account = null,Object? status = null,Object? activities = null,}) {
return _then(_self.copyWith(
account: null == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
as SnAccount,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as SnAccountStatus,activities: null == activities ? _self.activities : activities // ignore: cast_nullable_to_non_nullable
as List<SnPresenceActivity>,
));
}
/// Create a copy of SnFriendOverviewItem
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res> get account {
return $SnAccountCopyWith<$Res>(_self.account, (value) {
return _then(_self.copyWith(account: value));
});
}/// Create a copy of SnFriendOverviewItem
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountStatusCopyWith<$Res> get status {
return $SnAccountStatusCopyWith<$Res>(_self.status, (value) {
return _then(_self.copyWith(status: value));
});
}
}
/// Adds pattern-matching-related methods to [SnFriendOverviewItem].
extension SnFriendOverviewItemPatterns on SnFriendOverviewItem {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _SnFriendOverviewItem value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _SnFriendOverviewItem() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _SnFriendOverviewItem value) $default,){
final _that = this;
switch (_that) {
case _SnFriendOverviewItem():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _SnFriendOverviewItem value)? $default,){
final _that = this;
switch (_that) {
case _SnFriendOverviewItem() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( SnAccount account, SnAccountStatus status, List<SnPresenceActivity> activities)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SnFriendOverviewItem() when $default != null:
return $default(_that.account,_that.status,_that.activities);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( SnAccount account, SnAccountStatus status, List<SnPresenceActivity> activities) $default,) {final _that = this;
switch (_that) {
case _SnFriendOverviewItem():
return $default(_that.account,_that.status,_that.activities);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( SnAccount account, SnAccountStatus status, List<SnPresenceActivity> activities)? $default,) {final _that = this;
switch (_that) {
case _SnFriendOverviewItem() when $default != null:
return $default(_that.account,_that.status,_that.activities);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _SnFriendOverviewItem implements SnFriendOverviewItem {
const _SnFriendOverviewItem({required this.account, required this.status, required final List<SnPresenceActivity> activities}): _activities = activities;
factory _SnFriendOverviewItem.fromJson(Map<String, dynamic> json) => _$SnFriendOverviewItemFromJson(json);
@override final SnAccount account;
@override final SnAccountStatus status;
final List<SnPresenceActivity> _activities;
@override List<SnPresenceActivity> get activities {
if (_activities is EqualUnmodifiableListView) return _activities;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_activities);
}
/// Create a copy of SnFriendOverviewItem
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnFriendOverviewItemCopyWith<_SnFriendOverviewItem> get copyWith => __$SnFriendOverviewItemCopyWithImpl<_SnFriendOverviewItem>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnFriendOverviewItemToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnFriendOverviewItem&&(identical(other.account, account) || other.account == account)&&(identical(other.status, status) || other.status == status)&&const DeepCollectionEquality().equals(other._activities, _activities));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,account,status,const DeepCollectionEquality().hash(_activities));
@override
String toString() {
return 'SnFriendOverviewItem(account: $account, status: $status, activities: $activities)';
}
}
/// @nodoc
abstract mixin class _$SnFriendOverviewItemCopyWith<$Res> implements $SnFriendOverviewItemCopyWith<$Res> {
factory _$SnFriendOverviewItemCopyWith(_SnFriendOverviewItem value, $Res Function(_SnFriendOverviewItem) _then) = __$SnFriendOverviewItemCopyWithImpl;
@override @useResult
$Res call({
SnAccount account, SnAccountStatus status, List<SnPresenceActivity> activities
});
@override $SnAccountCopyWith<$Res> get account;@override $SnAccountStatusCopyWith<$Res> get status;
}
/// @nodoc
class __$SnFriendOverviewItemCopyWithImpl<$Res>
implements _$SnFriendOverviewItemCopyWith<$Res> {
__$SnFriendOverviewItemCopyWithImpl(this._self, this._then);
final _SnFriendOverviewItem _self;
final $Res Function(_SnFriendOverviewItem) _then;
/// Create a copy of SnFriendOverviewItem
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? account = null,Object? status = null,Object? activities = null,}) {
return _then(_SnFriendOverviewItem(
account: null == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
as SnAccount,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as SnAccountStatus,activities: null == activities ? _self._activities : activities // ignore: cast_nullable_to_non_nullable
as List<SnPresenceActivity>,
));
}
/// Create a copy of SnFriendOverviewItem
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res> get account {
return $SnAccountCopyWith<$Res>(_self.account, (value) {
return _then(_self.copyWith(account: value));
});
}/// Create a copy of SnFriendOverviewItem
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountStatusCopyWith<$Res> get status {
return $SnAccountStatusCopyWith<$Res>(_self.status, (value) {
return _then(_self.copyWith(status: value));
});
}
}
// dart format on

View File

@@ -449,3 +449,22 @@ Map<String, dynamic> _$SnSocialCreditRecordToJson(
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};
_SnFriendOverviewItem _$SnFriendOverviewItemFromJson(
Map<String, dynamic> json,
) => _SnFriendOverviewItem(
account: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
status: SnAccountStatus.fromJson(json['status'] as Map<String, dynamic>),
activities:
(json['activities'] as List<dynamic>)
.map((e) => SnPresenceActivity.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$SnFriendOverviewItemToJson(
_SnFriendOverviewItem instance,
) => <String, dynamic>{
'account': instance.account.toJson(),
'status': instance.status.toJson(),
'activities': instance.activities.map((e) => e.toJson()).toList(),
};

View File

@@ -46,6 +46,18 @@ sealed class SnPoll with _$SnPoll {
}) = _SnPoll;
factory SnPoll.fromJson(Map<String, dynamic> json) => _$SnPollFromJson(json);
factory SnPoll.fromPollWithStats(SnPollWithStats pollWithStats) => SnPoll(
id: pollWithStats.id,
questions: pollWithStats.questions,
title: pollWithStats.title,
description: pollWithStats.description,
endedAt: pollWithStats.endedAt,
publisherId: pollWithStats.publisherId,
createdAt: pollWithStats.createdAt,
updatedAt: pollWithStats.updatedAt,
deletedAt: pollWithStats.deletedAt,
);
}
@freezed

View File

@@ -71,6 +71,7 @@ sealed class StreamThinkingRequest with _$StreamThinkingRequest {
@Default([]) List<String> accpetProposals,
List<String>? attachedPosts,
List<Map<String, dynamic>>? attachedMessages,
@JsonKey(name: 'service_id') String? serviceId,
}) = _StreamThinkingRequest;
factory StreamThinkingRequest.fromJson(Map<String, dynamic> json) =>
@@ -175,3 +176,26 @@ sealed class SnThinkingThought with _$SnThinkingThought {
factory SnThinkingThought.fromJson(Map<String, dynamic> json) =>
_$SnThinkingThoughtFromJson(json);
}
@freezed
sealed class ThoughtService with _$ThoughtService {
const factory ThoughtService({
@JsonKey(name: 'service_id') required String serviceId,
required double billingMultiplier,
required int perkLevel,
}) = _ThoughtService;
factory ThoughtService.fromJson(Map<String, dynamic> json) =>
_$ThoughtServiceFromJson(json);
}
@freezed
sealed class ThoughtServicesResponse with _$ThoughtServicesResponse {
const factory ThoughtServicesResponse({
@JsonKey(name: 'default_service') required String defaultService,
required List<ThoughtService> services,
}) = _ThoughtServicesResponse;
factory ThoughtServicesResponse.fromJson(Map<String, dynamic> json) =>
_$ThoughtServicesResponseFromJson(json);
}

View File

@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$StreamThinkingRequest {
String get userMessage; String? get sequenceId; List<String> get accpetProposals; List<String>? get attachedPosts; List<Map<String, dynamic>>? get attachedMessages;
String get userMessage; String? get sequenceId; List<String> get accpetProposals; List<String>? get attachedPosts; List<Map<String, dynamic>>? get attachedMessages;@JsonKey(name: 'service_id') String? get serviceId;
/// Create a copy of StreamThinkingRequest
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -28,16 +28,16 @@ $StreamThinkingRequestCopyWith<StreamThinkingRequest> get copyWith => _$StreamTh
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is StreamThinkingRequest&&(identical(other.userMessage, userMessage) || other.userMessage == userMessage)&&(identical(other.sequenceId, sequenceId) || other.sequenceId == sequenceId)&&const DeepCollectionEquality().equals(other.accpetProposals, accpetProposals)&&const DeepCollectionEquality().equals(other.attachedPosts, attachedPosts)&&const DeepCollectionEquality().equals(other.attachedMessages, attachedMessages));
return identical(this, other) || (other.runtimeType == runtimeType&&other is StreamThinkingRequest&&(identical(other.userMessage, userMessage) || other.userMessage == userMessage)&&(identical(other.sequenceId, sequenceId) || other.sequenceId == sequenceId)&&const DeepCollectionEquality().equals(other.accpetProposals, accpetProposals)&&const DeepCollectionEquality().equals(other.attachedPosts, attachedPosts)&&const DeepCollectionEquality().equals(other.attachedMessages, attachedMessages)&&(identical(other.serviceId, serviceId) || other.serviceId == serviceId));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,userMessage,sequenceId,const DeepCollectionEquality().hash(accpetProposals),const DeepCollectionEquality().hash(attachedPosts),const DeepCollectionEquality().hash(attachedMessages));
int get hashCode => Object.hash(runtimeType,userMessage,sequenceId,const DeepCollectionEquality().hash(accpetProposals),const DeepCollectionEquality().hash(attachedPosts),const DeepCollectionEquality().hash(attachedMessages),serviceId);
@override
String toString() {
return 'StreamThinkingRequest(userMessage: $userMessage, sequenceId: $sequenceId, accpetProposals: $accpetProposals, attachedPosts: $attachedPosts, attachedMessages: $attachedMessages)';
return 'StreamThinkingRequest(userMessage: $userMessage, sequenceId: $sequenceId, accpetProposals: $accpetProposals, attachedPosts: $attachedPosts, attachedMessages: $attachedMessages, serviceId: $serviceId)';
}
@@ -48,7 +48,7 @@ abstract mixin class $StreamThinkingRequestCopyWith<$Res> {
factory $StreamThinkingRequestCopyWith(StreamThinkingRequest value, $Res Function(StreamThinkingRequest) _then) = _$StreamThinkingRequestCopyWithImpl;
@useResult
$Res call({
String userMessage, String? sequenceId, List<String> accpetProposals, List<String>? attachedPosts, List<Map<String, dynamic>>? attachedMessages
String userMessage, String? sequenceId, List<String> accpetProposals, List<String>? attachedPosts, List<Map<String, dynamic>>? attachedMessages,@JsonKey(name: 'service_id') String? serviceId
});
@@ -65,14 +65,15 @@ class _$StreamThinkingRequestCopyWithImpl<$Res>
/// Create a copy of StreamThinkingRequest
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? userMessage = null,Object? sequenceId = freezed,Object? accpetProposals = null,Object? attachedPosts = freezed,Object? attachedMessages = freezed,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? userMessage = null,Object? sequenceId = freezed,Object? accpetProposals = null,Object? attachedPosts = freezed,Object? attachedMessages = freezed,Object? serviceId = freezed,}) {
return _then(_self.copyWith(
userMessage: null == userMessage ? _self.userMessage : userMessage // ignore: cast_nullable_to_non_nullable
as String,sequenceId: freezed == sequenceId ? _self.sequenceId : sequenceId // ignore: cast_nullable_to_non_nullable
as String?,accpetProposals: null == accpetProposals ? _self.accpetProposals : accpetProposals // ignore: cast_nullable_to_non_nullable
as List<String>,attachedPosts: freezed == attachedPosts ? _self.attachedPosts : attachedPosts // ignore: cast_nullable_to_non_nullable
as List<String>?,attachedMessages: freezed == attachedMessages ? _self.attachedMessages : attachedMessages // ignore: cast_nullable_to_non_nullable
as List<Map<String, dynamic>>?,
as List<Map<String, dynamic>>?,serviceId: freezed == serviceId ? _self.serviceId : serviceId // ignore: cast_nullable_to_non_nullable
as String?,
));
}
@@ -154,10 +155,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String userMessage, String? sequenceId, List<String> accpetProposals, List<String>? attachedPosts, List<Map<String, dynamic>>? attachedMessages)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String userMessage, String? sequenceId, List<String> accpetProposals, List<String>? attachedPosts, List<Map<String, dynamic>>? attachedMessages, @JsonKey(name: 'service_id') String? serviceId)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _StreamThinkingRequest() when $default != null:
return $default(_that.userMessage,_that.sequenceId,_that.accpetProposals,_that.attachedPosts,_that.attachedMessages);case _:
return $default(_that.userMessage,_that.sequenceId,_that.accpetProposals,_that.attachedPosts,_that.attachedMessages,_that.serviceId);case _:
return orElse();
}
@@ -175,10 +176,10 @@ return $default(_that.userMessage,_that.sequenceId,_that.accpetProposals,_that.a
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String userMessage, String? sequenceId, List<String> accpetProposals, List<String>? attachedPosts, List<Map<String, dynamic>>? attachedMessages) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String userMessage, String? sequenceId, List<String> accpetProposals, List<String>? attachedPosts, List<Map<String, dynamic>>? attachedMessages, @JsonKey(name: 'service_id') String? serviceId) $default,) {final _that = this;
switch (_that) {
case _StreamThinkingRequest():
return $default(_that.userMessage,_that.sequenceId,_that.accpetProposals,_that.attachedPosts,_that.attachedMessages);}
return $default(_that.userMessage,_that.sequenceId,_that.accpetProposals,_that.attachedPosts,_that.attachedMessages,_that.serviceId);}
}
/// A variant of `when` that fallback to returning `null`
///
@@ -192,10 +193,10 @@ return $default(_that.userMessage,_that.sequenceId,_that.accpetProposals,_that.a
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String userMessage, String? sequenceId, List<String> accpetProposals, List<String>? attachedPosts, List<Map<String, dynamic>>? attachedMessages)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String userMessage, String? sequenceId, List<String> accpetProposals, List<String>? attachedPosts, List<Map<String, dynamic>>? attachedMessages, @JsonKey(name: 'service_id') String? serviceId)? $default,) {final _that = this;
switch (_that) {
case _StreamThinkingRequest() when $default != null:
return $default(_that.userMessage,_that.sequenceId,_that.accpetProposals,_that.attachedPosts,_that.attachedMessages);case _:
return $default(_that.userMessage,_that.sequenceId,_that.accpetProposals,_that.attachedPosts,_that.attachedMessages,_that.serviceId);case _:
return null;
}
@@ -207,7 +208,7 @@ return $default(_that.userMessage,_that.sequenceId,_that.accpetProposals,_that.a
@JsonSerializable()
class _StreamThinkingRequest implements StreamThinkingRequest {
const _StreamThinkingRequest({required this.userMessage, this.sequenceId, final List<String> accpetProposals = const [], final List<String>? attachedPosts, final List<Map<String, dynamic>>? attachedMessages}): _accpetProposals = accpetProposals,_attachedPosts = attachedPosts,_attachedMessages = attachedMessages;
const _StreamThinkingRequest({required this.userMessage, this.sequenceId, final List<String> accpetProposals = const [], final List<String>? attachedPosts, final List<Map<String, dynamic>>? attachedMessages, @JsonKey(name: 'service_id') this.serviceId}): _accpetProposals = accpetProposals,_attachedPosts = attachedPosts,_attachedMessages = attachedMessages;
factory _StreamThinkingRequest.fromJson(Map<String, dynamic> json) => _$StreamThinkingRequestFromJson(json);
@override final String userMessage;
@@ -237,6 +238,7 @@ class _StreamThinkingRequest implements StreamThinkingRequest {
return EqualUnmodifiableListView(value);
}
@override@JsonKey(name: 'service_id') final String? serviceId;
/// Create a copy of StreamThinkingRequest
/// with the given fields replaced by the non-null parameter values.
@@ -251,16 +253,16 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _StreamThinkingRequest&&(identical(other.userMessage, userMessage) || other.userMessage == userMessage)&&(identical(other.sequenceId, sequenceId) || other.sequenceId == sequenceId)&&const DeepCollectionEquality().equals(other._accpetProposals, _accpetProposals)&&const DeepCollectionEquality().equals(other._attachedPosts, _attachedPosts)&&const DeepCollectionEquality().equals(other._attachedMessages, _attachedMessages));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _StreamThinkingRequest&&(identical(other.userMessage, userMessage) || other.userMessage == userMessage)&&(identical(other.sequenceId, sequenceId) || other.sequenceId == sequenceId)&&const DeepCollectionEquality().equals(other._accpetProposals, _accpetProposals)&&const DeepCollectionEquality().equals(other._attachedPosts, _attachedPosts)&&const DeepCollectionEquality().equals(other._attachedMessages, _attachedMessages)&&(identical(other.serviceId, serviceId) || other.serviceId == serviceId));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,userMessage,sequenceId,const DeepCollectionEquality().hash(_accpetProposals),const DeepCollectionEquality().hash(_attachedPosts),const DeepCollectionEquality().hash(_attachedMessages));
int get hashCode => Object.hash(runtimeType,userMessage,sequenceId,const DeepCollectionEquality().hash(_accpetProposals),const DeepCollectionEquality().hash(_attachedPosts),const DeepCollectionEquality().hash(_attachedMessages),serviceId);
@override
String toString() {
return 'StreamThinkingRequest(userMessage: $userMessage, sequenceId: $sequenceId, accpetProposals: $accpetProposals, attachedPosts: $attachedPosts, attachedMessages: $attachedMessages)';
return 'StreamThinkingRequest(userMessage: $userMessage, sequenceId: $sequenceId, accpetProposals: $accpetProposals, attachedPosts: $attachedPosts, attachedMessages: $attachedMessages, serviceId: $serviceId)';
}
@@ -271,7 +273,7 @@ abstract mixin class _$StreamThinkingRequestCopyWith<$Res> implements $StreamThi
factory _$StreamThinkingRequestCopyWith(_StreamThinkingRequest value, $Res Function(_StreamThinkingRequest) _then) = __$StreamThinkingRequestCopyWithImpl;
@override @useResult
$Res call({
String userMessage, String? sequenceId, List<String> accpetProposals, List<String>? attachedPosts, List<Map<String, dynamic>>? attachedMessages
String userMessage, String? sequenceId, List<String> accpetProposals, List<String>? attachedPosts, List<Map<String, dynamic>>? attachedMessages,@JsonKey(name: 'service_id') String? serviceId
});
@@ -288,14 +290,15 @@ class __$StreamThinkingRequestCopyWithImpl<$Res>
/// Create a copy of StreamThinkingRequest
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? userMessage = null,Object? sequenceId = freezed,Object? accpetProposals = null,Object? attachedPosts = freezed,Object? attachedMessages = freezed,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? userMessage = null,Object? sequenceId = freezed,Object? accpetProposals = null,Object? attachedPosts = freezed,Object? attachedMessages = freezed,Object? serviceId = freezed,}) {
return _then(_StreamThinkingRequest(
userMessage: null == userMessage ? _self.userMessage : userMessage // ignore: cast_nullable_to_non_nullable
as String,sequenceId: freezed == sequenceId ? _self.sequenceId : sequenceId // ignore: cast_nullable_to_non_nullable
as String?,accpetProposals: null == accpetProposals ? _self._accpetProposals : accpetProposals // ignore: cast_nullable_to_non_nullable
as List<String>,attachedPosts: freezed == attachedPosts ? _self._attachedPosts : attachedPosts // ignore: cast_nullable_to_non_nullable
as List<String>?,attachedMessages: freezed == attachedMessages ? _self._attachedMessages : attachedMessages // ignore: cast_nullable_to_non_nullable
as List<Map<String, dynamic>>?,
as List<Map<String, dynamic>>?,serviceId: freezed == serviceId ? _self.serviceId : serviceId // ignore: cast_nullable_to_non_nullable
as String?,
));
}
@@ -2011,4 +2014,533 @@ $SnThinkingSequenceCopyWith<$Res>? get sequence {
}
}
/// @nodoc
mixin _$ThoughtService {
@JsonKey(name: 'service_id') String get serviceId; double get billingMultiplier; int get perkLevel;
/// Create a copy of ThoughtService
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ThoughtServiceCopyWith<ThoughtService> get copyWith => _$ThoughtServiceCopyWithImpl<ThoughtService>(this as ThoughtService, _$identity);
/// Serializes this ThoughtService to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ThoughtService&&(identical(other.serviceId, serviceId) || other.serviceId == serviceId)&&(identical(other.billingMultiplier, billingMultiplier) || other.billingMultiplier == billingMultiplier)&&(identical(other.perkLevel, perkLevel) || other.perkLevel == perkLevel));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,serviceId,billingMultiplier,perkLevel);
@override
String toString() {
return 'ThoughtService(serviceId: $serviceId, billingMultiplier: $billingMultiplier, perkLevel: $perkLevel)';
}
}
/// @nodoc
abstract mixin class $ThoughtServiceCopyWith<$Res> {
factory $ThoughtServiceCopyWith(ThoughtService value, $Res Function(ThoughtService) _then) = _$ThoughtServiceCopyWithImpl;
@useResult
$Res call({
@JsonKey(name: 'service_id') String serviceId, double billingMultiplier, int perkLevel
});
}
/// @nodoc
class _$ThoughtServiceCopyWithImpl<$Res>
implements $ThoughtServiceCopyWith<$Res> {
_$ThoughtServiceCopyWithImpl(this._self, this._then);
final ThoughtService _self;
final $Res Function(ThoughtService) _then;
/// Create a copy of ThoughtService
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? serviceId = null,Object? billingMultiplier = null,Object? perkLevel = null,}) {
return _then(_self.copyWith(
serviceId: null == serviceId ? _self.serviceId : serviceId // ignore: cast_nullable_to_non_nullable
as String,billingMultiplier: null == billingMultiplier ? _self.billingMultiplier : billingMultiplier // ignore: cast_nullable_to_non_nullable
as double,perkLevel: null == perkLevel ? _self.perkLevel : perkLevel // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// Adds pattern-matching-related methods to [ThoughtService].
extension ThoughtServicePatterns on ThoughtService {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ThoughtService value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ThoughtService() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ThoughtService value) $default,){
final _that = this;
switch (_that) {
case _ThoughtService():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ThoughtService value)? $default,){
final _that = this;
switch (_that) {
case _ThoughtService() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function(@JsonKey(name: 'service_id') String serviceId, double billingMultiplier, int perkLevel)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ThoughtService() when $default != null:
return $default(_that.serviceId,_that.billingMultiplier,_that.perkLevel);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function(@JsonKey(name: 'service_id') String serviceId, double billingMultiplier, int perkLevel) $default,) {final _that = this;
switch (_that) {
case _ThoughtService():
return $default(_that.serviceId,_that.billingMultiplier,_that.perkLevel);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function(@JsonKey(name: 'service_id') String serviceId, double billingMultiplier, int perkLevel)? $default,) {final _that = this;
switch (_that) {
case _ThoughtService() when $default != null:
return $default(_that.serviceId,_that.billingMultiplier,_that.perkLevel);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _ThoughtService implements ThoughtService {
const _ThoughtService({@JsonKey(name: 'service_id') required this.serviceId, required this.billingMultiplier, required this.perkLevel});
factory _ThoughtService.fromJson(Map<String, dynamic> json) => _$ThoughtServiceFromJson(json);
@override@JsonKey(name: 'service_id') final String serviceId;
@override final double billingMultiplier;
@override final int perkLevel;
/// Create a copy of ThoughtService
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ThoughtServiceCopyWith<_ThoughtService> get copyWith => __$ThoughtServiceCopyWithImpl<_ThoughtService>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$ThoughtServiceToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ThoughtService&&(identical(other.serviceId, serviceId) || other.serviceId == serviceId)&&(identical(other.billingMultiplier, billingMultiplier) || other.billingMultiplier == billingMultiplier)&&(identical(other.perkLevel, perkLevel) || other.perkLevel == perkLevel));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,serviceId,billingMultiplier,perkLevel);
@override
String toString() {
return 'ThoughtService(serviceId: $serviceId, billingMultiplier: $billingMultiplier, perkLevel: $perkLevel)';
}
}
/// @nodoc
abstract mixin class _$ThoughtServiceCopyWith<$Res> implements $ThoughtServiceCopyWith<$Res> {
factory _$ThoughtServiceCopyWith(_ThoughtService value, $Res Function(_ThoughtService) _then) = __$ThoughtServiceCopyWithImpl;
@override @useResult
$Res call({
@JsonKey(name: 'service_id') String serviceId, double billingMultiplier, int perkLevel
});
}
/// @nodoc
class __$ThoughtServiceCopyWithImpl<$Res>
implements _$ThoughtServiceCopyWith<$Res> {
__$ThoughtServiceCopyWithImpl(this._self, this._then);
final _ThoughtService _self;
final $Res Function(_ThoughtService) _then;
/// Create a copy of ThoughtService
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? serviceId = null,Object? billingMultiplier = null,Object? perkLevel = null,}) {
return _then(_ThoughtService(
serviceId: null == serviceId ? _self.serviceId : serviceId // ignore: cast_nullable_to_non_nullable
as String,billingMultiplier: null == billingMultiplier ? _self.billingMultiplier : billingMultiplier // ignore: cast_nullable_to_non_nullable
as double,perkLevel: null == perkLevel ? _self.perkLevel : perkLevel // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// @nodoc
mixin _$ThoughtServicesResponse {
@JsonKey(name: 'default_service') String get defaultService; List<ThoughtService> get services;
/// Create a copy of ThoughtServicesResponse
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ThoughtServicesResponseCopyWith<ThoughtServicesResponse> get copyWith => _$ThoughtServicesResponseCopyWithImpl<ThoughtServicesResponse>(this as ThoughtServicesResponse, _$identity);
/// Serializes this ThoughtServicesResponse to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ThoughtServicesResponse&&(identical(other.defaultService, defaultService) || other.defaultService == defaultService)&&const DeepCollectionEquality().equals(other.services, services));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,defaultService,const DeepCollectionEquality().hash(services));
@override
String toString() {
return 'ThoughtServicesResponse(defaultService: $defaultService, services: $services)';
}
}
/// @nodoc
abstract mixin class $ThoughtServicesResponseCopyWith<$Res> {
factory $ThoughtServicesResponseCopyWith(ThoughtServicesResponse value, $Res Function(ThoughtServicesResponse) _then) = _$ThoughtServicesResponseCopyWithImpl;
@useResult
$Res call({
@JsonKey(name: 'default_service') String defaultService, List<ThoughtService> services
});
}
/// @nodoc
class _$ThoughtServicesResponseCopyWithImpl<$Res>
implements $ThoughtServicesResponseCopyWith<$Res> {
_$ThoughtServicesResponseCopyWithImpl(this._self, this._then);
final ThoughtServicesResponse _self;
final $Res Function(ThoughtServicesResponse) _then;
/// Create a copy of ThoughtServicesResponse
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? defaultService = null,Object? services = null,}) {
return _then(_self.copyWith(
defaultService: null == defaultService ? _self.defaultService : defaultService // ignore: cast_nullable_to_non_nullable
as String,services: null == services ? _self.services : services // ignore: cast_nullable_to_non_nullable
as List<ThoughtService>,
));
}
}
/// Adds pattern-matching-related methods to [ThoughtServicesResponse].
extension ThoughtServicesResponsePatterns on ThoughtServicesResponse {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ThoughtServicesResponse value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ThoughtServicesResponse() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ThoughtServicesResponse value) $default,){
final _that = this;
switch (_that) {
case _ThoughtServicesResponse():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ThoughtServicesResponse value)? $default,){
final _that = this;
switch (_that) {
case _ThoughtServicesResponse() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function(@JsonKey(name: 'default_service') String defaultService, List<ThoughtService> services)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ThoughtServicesResponse() when $default != null:
return $default(_that.defaultService,_that.services);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function(@JsonKey(name: 'default_service') String defaultService, List<ThoughtService> services) $default,) {final _that = this;
switch (_that) {
case _ThoughtServicesResponse():
return $default(_that.defaultService,_that.services);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function(@JsonKey(name: 'default_service') String defaultService, List<ThoughtService> services)? $default,) {final _that = this;
switch (_that) {
case _ThoughtServicesResponse() when $default != null:
return $default(_that.defaultService,_that.services);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _ThoughtServicesResponse implements ThoughtServicesResponse {
const _ThoughtServicesResponse({@JsonKey(name: 'default_service') required this.defaultService, required final List<ThoughtService> services}): _services = services;
factory _ThoughtServicesResponse.fromJson(Map<String, dynamic> json) => _$ThoughtServicesResponseFromJson(json);
@override@JsonKey(name: 'default_service') final String defaultService;
final List<ThoughtService> _services;
@override List<ThoughtService> get services {
if (_services is EqualUnmodifiableListView) return _services;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_services);
}
/// Create a copy of ThoughtServicesResponse
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ThoughtServicesResponseCopyWith<_ThoughtServicesResponse> get copyWith => __$ThoughtServicesResponseCopyWithImpl<_ThoughtServicesResponse>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$ThoughtServicesResponseToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ThoughtServicesResponse&&(identical(other.defaultService, defaultService) || other.defaultService == defaultService)&&const DeepCollectionEquality().equals(other._services, _services));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,defaultService,const DeepCollectionEquality().hash(_services));
@override
String toString() {
return 'ThoughtServicesResponse(defaultService: $defaultService, services: $services)';
}
}
/// @nodoc
abstract mixin class _$ThoughtServicesResponseCopyWith<$Res> implements $ThoughtServicesResponseCopyWith<$Res> {
factory _$ThoughtServicesResponseCopyWith(_ThoughtServicesResponse value, $Res Function(_ThoughtServicesResponse) _then) = __$ThoughtServicesResponseCopyWithImpl;
@override @useResult
$Res call({
@JsonKey(name: 'default_service') String defaultService, List<ThoughtService> services
});
}
/// @nodoc
class __$ThoughtServicesResponseCopyWithImpl<$Res>
implements _$ThoughtServicesResponseCopyWith<$Res> {
__$ThoughtServicesResponseCopyWithImpl(this._self, this._then);
final _ThoughtServicesResponse _self;
final $Res Function(_ThoughtServicesResponse) _then;
/// Create a copy of ThoughtServicesResponse
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? defaultService = null,Object? services = null,}) {
return _then(_ThoughtServicesResponse(
defaultService: null == defaultService ? _self.defaultService : defaultService // ignore: cast_nullable_to_non_nullable
as String,services: null == services ? _self._services : services // ignore: cast_nullable_to_non_nullable
as List<ThoughtService>,
));
}
}
// dart format on

View File

@@ -24,6 +24,7 @@ _StreamThinkingRequest _$StreamThinkingRequestFromJson(
(json['attached_messages'] as List<dynamic>?)
?.map((e) => e as Map<String, dynamic>)
.toList(),
serviceId: json['service_id'] as String?,
);
Map<String, dynamic> _$StreamThinkingRequestToJson(
@@ -34,6 +35,7 @@ Map<String, dynamic> _$StreamThinkingRequestToJson(
'accpet_proposals': instance.accpetProposals,
'attached_posts': instance.attachedPosts,
'attached_messages': instance.attachedMessages,
'service_id': instance.serviceId,
};
_SnThinkingChunk _$SnThinkingChunkFromJson(Map<String, dynamic> json) =>
@@ -185,3 +187,34 @@ Map<String, dynamic> _$SnThinkingThoughtToJson(_SnThinkingThought instance) =>
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};
_ThoughtService _$ThoughtServiceFromJson(Map<String, dynamic> json) =>
_ThoughtService(
serviceId: json['service_id'] as String,
billingMultiplier: (json['billing_multiplier'] as num).toDouble(),
perkLevel: (json['perk_level'] as num).toInt(),
);
Map<String, dynamic> _$ThoughtServiceToJson(_ThoughtService instance) =>
<String, dynamic>{
'service_id': instance.serviceId,
'billing_multiplier': instance.billingMultiplier,
'perk_level': instance.perkLevel,
};
_ThoughtServicesResponse _$ThoughtServicesResponseFromJson(
Map<String, dynamic> json,
) => _ThoughtServicesResponse(
defaultService: json['default_service'] as String,
services:
(json['services'] as List<dynamic>)
.map((e) => ThoughtService.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$ThoughtServicesResponseToJson(
_ThoughtServicesResponse instance,
) => <String, dynamic>{
'default_service': instance.defaultService,
'services': instance.services.map((e) => e.toJson()).toList(),
};

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

@@ -8,6 +8,8 @@ import "package:island/database/drift_db.dart";
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";
@@ -437,6 +439,8 @@ class MessagesNotifier extends _$MessagesNotifier {
WidgetRef ref,
String content,
List<UniversalFile> attachments, {
SnPoll? poll,
SnWalletFund? fund,
SnChatMessage? editingTo,
SnChatMessage? forwardingTo,
SnChatMessage? replyingTo,
@@ -498,6 +502,8 @@ class MessagesNotifier extends _$MessagesNotifier {
'attachments_id': cloudAttachments.map((e) => e.id).toList(),
'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

@@ -30,7 +30,6 @@ import 'package:island/screens/account/me/profile_update.dart';
import 'package:island/screens/account/leveling.dart';
import 'package:island/screens/account/me/account_settings.dart';
import 'package:island/screens/chat/chat.dart';
import 'package:island/screens/chat/chat_form.dart';
import 'package:island/screens/chat/room.dart';
import 'package:island/screens/chat/room_detail.dart';
import 'package:island/screens/chat/call.dart';
@@ -45,7 +44,6 @@ import 'package:island/screens/discovery/feeds/feed_marketplace.dart';
import 'package:island/screens/discovery/feeds/feed_detail.dart';
import 'package:island/screens/creators/poll/poll_list.dart';
import 'package:island/screens/creators/webfeed/webfeed_list.dart';
import 'package:island/screens/poll/poll_editor.dart';
import 'package:island/screens/posts/compose.dart';
import 'package:island/screens/posts/compose_article.dart';
import 'package:island/screens/posts/post_detail.dart';
@@ -127,11 +125,6 @@ final routerProvider = Provider<GoRouter>((ref) {
return CallScreen(roomId: id);
},
),
GoRoute(
name: 'thought',
path: '/thought',
builder: (context, state) => const ThoughtScreen(),
),
GoRoute(
name: 'logs',
path: '/logs',
@@ -270,11 +263,6 @@ final routerProvider = Provider<GoRouter>((ref) {
path: '/chat',
builder: (context, state) => const ChatListScreen(),
),
GoRoute(
name: 'chatNew',
path: '/chat/new',
builder: (context, state) => const NewChatScreen(),
),
GoRoute(
name: 'chatRoom',
path: '/chat/:id',
@@ -283,14 +271,6 @@ final routerProvider = Provider<GoRouter>((ref) {
return ChatRoomScreen(id: id);
},
),
GoRoute(
name: 'chatEdit',
path: '/chat/:id/edit',
builder: (context, state) {
final id = state.pathParameters['id']!;
return EditChatScreen(id: id);
},
),
GoRoute(
name: 'chatDetail',
path: '/chat/:id/detail',
@@ -466,6 +446,13 @@ final routerProvider = Provider<GoRouter>((ref) {
],
),
// SN-chan tab
GoRoute(
name: 'thought',
path: '/thought',
builder: (context, state) => const ThoughtScreen(),
),
// Creator hub tab
GoRoute(
name: 'creatorHub',
@@ -498,28 +485,7 @@ final routerProvider = Provider<GoRouter>((ref) {
return CreatorPollListScreen(pubName: name);
},
),
// Poll routes
GoRoute(
name: 'creatorPollNew',
path: ':name/polls/new',
builder: (context, state) {
final name = state.pathParameters['name']!;
// initialPollId left null for create; initialPublisher prefilled
return PollEditorScreen(initialPublisher: name);
},
),
GoRoute(
name: 'creatorPollEdit',
path: ':name/polls/:id/edit',
builder: (context, state) {
final name = state.pathParameters['name']!;
final id = state.pathParameters['id']!;
return PollEditorScreen(
initialPollId: id,
initialPublisher: name,
);
},
),
GoRoute(
name: 'creatorStickers',
path: ':name/stickers',

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

@@ -1,315 +1,22 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:email_validator/email_validator.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/network.dart';
import 'package:island/screens/account/me/profile_update.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'captcha.dart';
import 'create_account_content.dart';
class CreateAccountScreen extends HookConsumerWidget {
const CreateAccountScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(GlobalKey<FormState>.new, const []);
final emailController = useTextEditingController();
final usernameController = useTextEditingController();
final nicknameController = useTextEditingController();
final passwordController = useTextEditingController();
void showPostCreateModal() {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) => _PostCreateModal(),
);
}
void performAction() async {
if (!formKey.currentState!.validate()) return;
final captchaTk = await CaptchaScreen.show(context);
if (captchaTk == null) return;
if (!context.mounted) return;
try {
showLoadingModal(context);
final client = ref.watch(apiClientProvider);
await client.post(
'/pass/accounts',
data: {
'name': usernameController.text,
'nick': nicknameController.text,
'email': emailController.text,
'password': passwordController.text,
'language':
kServerSupportedLanguages[EasyLocalization.of(
context,
)!.currentLocale.toString()] ??
'en-us',
'captcha_token': captchaTk,
},
);
if (!context.mounted) return;
hideLoadingModal(context);
showPostCreateModal();
} catch (err) {
if (context.mounted) hideLoadingModal(context);
showErrorAlert(err);
}
}
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
leading: const PageBackButton(),
title: Text('createAccount').tr(),
),
body:
StyledWidget(
Container(
constraints: const BoxConstraints(maxWidth: 380),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 26,
child: const Icon(Symbols.person_add, size: 28),
).padding(bottom: 8),
),
Text(
'createAccount',
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w900,
),
).tr().padding(left: 4, bottom: 16),
Form(
key: formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
children: [
TextFormField(
controller: usernameController,
validator: (value) {
if (value == null || value.isEmpty) {
return 'fieldCannotBeEmpty'.tr();
}
return null;
},
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'username'.tr(),
helperText: 'usernameCannotChangeHint'.tr(),
),
onTapOutside:
(_) =>
FocusManager.instance.primaryFocus
?.unfocus(),
),
const Gap(12),
TextFormField(
controller: nicknameController,
validator: (value) {
if (value == null || value.isEmpty) {
return 'fieldCannotBeEmpty'.tr();
}
return null;
},
autocorrect: false,
autofillHints: const [AutofillHints.nickname],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'nickname'.tr(),
),
onTapOutside:
(_) =>
FocusManager.instance.primaryFocus
?.unfocus(),
),
const Gap(12),
TextFormField(
controller: emailController,
validator: (value) {
if (value == null || value.isEmpty) {
return 'fieldCannotBeEmpty'.tr();
}
if (!EmailValidator.validate(value)) {
return 'fieldEmailAddressMustBeValid'.tr();
}
return null;
},
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.email],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'email'.tr(),
),
onTapOutside:
(_) =>
FocusManager.instance.primaryFocus
?.unfocus(),
),
const Gap(12),
TextFormField(
controller: passwordController,
validator: (value) {
if (value == null || value.isEmpty) {
return 'fieldCannotBeEmpty'.tr();
}
return null;
},
obscureText: true,
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.password],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'password'.tr(),
),
onTapOutside:
(_) =>
FocusManager.instance.primaryFocus
?.unfocus(),
),
],
).padding(horizontal: 7),
),
const Gap(16),
Align(
alignment: Alignment.centerRight,
child: StyledWidget(
Container(
constraints: const BoxConstraints(maxWidth: 290),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'termAcceptNextWithAgree'.tr(),
textAlign: TextAlign.end,
style: Theme.of(
context,
).textTheme.bodySmall!.copyWith(
color: Theme.of(context).colorScheme.onSurface
.withAlpha((255 * 0.75).round()),
),
),
Material(
color: Colors.transparent,
child: InkWell(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('termAcceptLink').tr(),
const Gap(4),
const Icon(Symbols.launch, size: 14),
],
),
onTap: () {
launchUrlString(
'https://solsynth.dev/terms',
);
},
),
),
],
),
),
).padding(horizontal: 16),
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
performAction();
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text("next").tr(),
const Icon(Symbols.chevron_right),
],
),
),
),
],
),
),
),
).padding(all: 24).center(),
);
}
}
class _PostCreateModal extends HookConsumerWidget {
const _PostCreateModal();
@override
Widget build(BuildContext context, WidgetRef ref) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('🎉').fontSize(32),
Text(
'postCreateAccountTitle'.tr(),
textAlign: TextAlign.center,
).fontSize(17),
const Gap(18),
Text('postCreateAccountNext').tr().fontSize(19).bold(),
const Gap(4),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 6,
children: [
Text('\u2022'),
Expanded(child: Text('postCreateAccountNext1').tr()),
],
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 6,
children: [
Text('\u2022'),
Expanded(child: Text('postCreateAccountNext2').tr()),
],
),
const Gap(6),
TextButton(
onPressed: () {
Navigator.pop(context);
context.pushReplacementNamed('login');
},
child: Text('login'.tr()),
),
],
),
),
body: CreateAccountContent(),
);
}
}

View File

@@ -0,0 +1,486 @@
import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:email_validator/email_validator.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:gap/gap.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/pods/websocket.dart';
import 'package:island/screens/account/me/profile_update.dart';
import 'package:island/services/event_bus.dart';
import 'package:island/services/notify.dart';
import 'package:island/services/udid.dart';
import 'package:island/widgets/alert.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'captcha.dart';
Widget getProviderIcon(String provider, {double size = 24, Color? color}) {
final providerLower = provider.toLowerCase();
// Check if we have an SVG for this provider
switch (providerLower) {
case 'apple':
case 'microsoft':
case 'google':
case 'github':
case 'discord':
case 'afdian':
case 'steam':
return SvgPicture.asset(
'assets/images/oidc/$providerLower.svg',
width: size,
height: size,
colorFilter:
color != null ? ColorFilter.mode(color, BlendMode.srcIn) : null,
);
case 'spotify':
return Image.asset(
'assets/images/oidc/spotify.png',
width: size,
height: size,
color: color,
);
default:
return Icon(Symbols.link, size: size);
}
}
class CreateAccountContent extends HookConsumerWidget {
const CreateAccountContent({super.key});
Map<String, dynamic> decodeJwt(String token) {
final parts = token.split('.');
if (parts.length != 3) throw FormatException('Invalid JWT');
final payload = parts[1];
final normalized = base64Url.normalize(payload);
final decoded = utf8.decode(base64Url.decode(normalized));
return json.decode(decoded);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(GlobalKey<FormState>.new, const []);
final emailController = useTextEditingController();
final usernameController = useTextEditingController();
final nicknameController = useTextEditingController();
final passwordController = useTextEditingController();
final waitingForOidc = useState(false);
final onboardingToken = useState<String?>(null);
void showPostCreateModal() {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) => _PostCreateModal(),
);
}
void performAction() async {
if (!formKey.currentState!.validate()) return;
String endpoint = '/pass/accounts';
Map<String, dynamic> data = {};
if (onboardingToken.value != null) {
// OIDC onboarding
endpoint = '/pass/account/onboard';
data['onboarding_token'] = onboardingToken.value;
data['name'] = usernameController.text;
data['nick'] = nicknameController.text;
// Password is required in form, but might be optional
} else {
// Manual account creation
final captchaTk = await CaptchaScreen.show(context);
if (captchaTk == null) return;
if (!context.mounted) return;
data['captcha_token'] = captchaTk;
data['name'] = usernameController.text;
data['nick'] = nicknameController.text;
data['email'] = emailController.text;
data['password'] = passwordController.text;
data['language'] =
kServerSupportedLanguages[EasyLocalization.of(
context,
)!.currentLocale.toString()] ??
'en-us';
}
if (!context.mounted) return;
try {
showLoadingModal(context);
final client = ref.watch(apiClientProvider);
final resp = await client.post(endpoint, data: data);
if (endpoint == '/pass/account/onboard') {
// Onboard response has tokens, set them
final token = resp.data['token'];
setToken(ref.watch(sharedPreferencesProvider), token);
ref.invalidate(tokenProvider);
final userNotifier = ref.read(userInfoProvider.notifier);
await userNotifier.fetchUser();
final apiClient = ref.read(apiClientProvider);
subscribePushNotification(apiClient);
final wsNotifier = ref.read(websocketStateProvider.notifier);
wsNotifier.connect();
if (context.mounted) Navigator.pop(context, true);
} else {
if (!context.mounted) return;
hideLoadingModal(context);
onboardingToken.value = null; // reset
showPostCreateModal();
}
} catch (err) {
if (context.mounted) hideLoadingModal(context);
showErrorAlert(err);
}
}
useEffect(() {
final subscription = eventBus.on<OidcAuthCallbackEvent>().listen((
event,
) async {
if (!waitingForOidc.value || !context.mounted) return;
waitingForOidc.value = false;
final client = ref.watch(apiClientProvider);
try {
// Exchange code for tokens
final resp = await client.post(
'/pass/auth/token',
data: {
'grant_type': 'authorization_code',
'code': event.challengeId,
},
);
final data = resp.data;
if (data.containsKey('onboarding_token')) {
// New user onboarding
final token = data['onboarding_token'] as String;
final decoded = decodeJwt(token);
final name = decoded['name'] as String?;
final email = decoded['email'] as String?;
final provider = decoded['provider'] as String?;
// Pre-fill form
usernameController.text = '';
nicknameController.text = name ?? '';
emailController.text = email ?? '';
passwordController.clear(); // User needs to set password
onboardingToken.value = token;
// Optionally show a message
showSnackBar('Pre-filled from ${provider ?? 'provider'}');
} else {
// Existing user, switch to login
showSnackBar('Account already exists. Redirecting to login.');
if (context.mounted) context.goNamed('login');
}
} catch (err) {
showErrorAlert(err);
}
});
return subscription.cancel;
}, [waitingForOidc.value, context.mounted]);
Future<void> withOidc(String provider) async {
waitingForOidc.value = true;
final serverUrl = ref.watch(serverUrlProvider);
final deviceId = await getUdid();
final url =
Uri.parse('$serverUrl/pass/auth/login/${provider.toLowerCase()}')
.replace(
queryParameters: {
'returnUrl': 'solian://auth/callback',
'deviceId': deviceId,
'flow': 'login',
},
)
.toString();
final isLaunched = await launchUrlString(
url,
mode:
kIsWeb
? LaunchMode.platformDefault
: LaunchMode.externalApplication,
);
if (!isLaunched) {
waitingForOidc.value = false;
showErrorAlert('failedToLaunchBrowser'.tr());
}
}
return StyledWidget(
Container(
constraints: const BoxConstraints(maxWidth: 380),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 26,
child: const Icon(Symbols.person_add, size: 28),
).padding(bottom: 8),
),
Text(
'createAccount',
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w900,
),
).tr().padding(left: 4, bottom: 16),
if (!kIsWeb)
Row(
spacing: 6,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Text("orCreateWith").tr().fontSize(11).opacity(0.85),
const Gap(8),
Spacer(),
IconButton.filledTonal(
onPressed: () => withOidc('github'),
padding: EdgeInsets.zero,
icon: getProviderIcon(
"github",
size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
tooltip: 'GitHub',
),
IconButton.filledTonal(
onPressed: () => withOidc('google'),
padding: EdgeInsets.zero,
icon: getProviderIcon(
"google",
size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
tooltip: 'Google',
),
IconButton.filledTonal(
onPressed: () => withOidc('apple'),
padding: EdgeInsets.zero,
icon: getProviderIcon(
"apple",
size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
tooltip: 'Apple Account',
),
],
).padding(horizontal: 8, vertical: 8)
else
const Gap(12),
Form(
key: formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
children: [
TextFormField(
controller: usernameController,
validator: (value) {
if (value == null || value.isEmpty) {
return 'fieldCannotBeEmpty'.tr();
}
return null;
},
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'username'.tr(),
helperText: 'usernameCannotChangeHint'.tr(),
),
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TextFormField(
controller: nicknameController,
validator: (value) {
if (value == null || value.isEmpty) {
return 'fieldCannotBeEmpty'.tr();
}
return null;
},
autocorrect: false,
autofillHints: const [AutofillHints.nickname],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'nickname'.tr(),
),
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TextFormField(
controller: emailController,
validator: (value) {
if (value == null || value.isEmpty) {
return 'fieldCannotBeEmpty'.tr();
}
if (!EmailValidator.validate(value)) {
return 'fieldEmailAddressMustBeValid'.tr();
}
return null;
},
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.email],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'email'.tr(),
),
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TextFormField(
controller: passwordController,
validator: (value) {
if (value == null || value.isEmpty) {
return 'fieldCannotBeEmpty'.tr();
}
return null;
},
obscureText: true,
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.password],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'password'.tr(),
),
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
],
).padding(horizontal: 7),
),
const Gap(16),
Align(
alignment: Alignment.centerRight,
child: StyledWidget(
Container(
constraints: const BoxConstraints(maxWidth: 290),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'termAcceptNextWithAgree'.tr(),
textAlign: TextAlign.end,
style: Theme.of(
context,
).textTheme.bodySmall!.copyWith(
color: Theme.of(context).colorScheme.onSurface
.withAlpha((255 * 0.75).round()),
),
),
Material(
color: Colors.transparent,
child: InkWell(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('termAcceptLink').tr(),
const Gap(4),
const Icon(Symbols.launch, size: 14),
],
),
onTap: () {
launchUrlString('https://solsynth.dev/terms');
},
),
),
],
),
),
).padding(horizontal: 16),
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
performAction();
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text("next").tr(),
const Icon(Symbols.chevron_right),
],
),
),
),
],
),
),
),
).padding(all: 24).center();
}
}
class _PostCreateModal extends HookConsumerWidget {
const _PostCreateModal();
@override
Widget build(BuildContext context, WidgetRef ref) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('🎉').fontSize(32),
Text(
'postCreateAccountTitle'.tr(),
textAlign: TextAlign.center,
).fontSize(17),
const Gap(18),
Text('postCreateAccountNext').tr().fontSize(19).bold(),
const Gap(4),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 6,
children: [
Text('\u2022'),
Expanded(child: Text('postCreateAccountNext1').tr()),
],
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 6,
children: [
Text('\u2022'),
Expanded(child: Text('postCreateAccountNext2').tr()),
],
),
const Gap(6),
TextButton(
onPressed: () {
Navigator.pop(context);
context.pushReplacementNamed('login');
},
child: Text('login'.tr()),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,19 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/widgets/content/sheet.dart';
import 'create_account_content.dart';
class CreateAccountModal extends HookConsumerWidget {
const CreateAccountModal({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return SheetScaffold(
titleText: 'createAccount'.tr(),
heightFactor: 0.9,
child: CreateAccountContent(),
);
}
}

View File

@@ -1,31 +1,10 @@
import 'dart:convert';
import 'dart:math' as math;
import 'package:animations/animations.dart';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_otp_text_field/flutter_otp_text_field.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:gap/gap.dart';
import 'package:island/models/auth.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/pods/websocket.dart';
import 'package:island/screens/account/me/settings_connections.dart';
import 'package:island/screens/auth/oidc.dart';
import 'package:island/services/notify.dart';
import 'package:island/services/udid.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'captcha.dart';
import 'login_content.dart';
final Map<int, (String, String, IconData)> kFactorTypes = {
0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
@@ -44,743 +23,13 @@ class LoginScreen extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isBusy = useState(false);
final period = useState(0);
final currentTicket = useState<SnAuthChallenge?>(null);
final factors = useState<List<SnAuthFactor>>([]);
final factorPicked = useState<SnAuthFactor?>(null);
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
leading: const PageBackButton(),
title: Text('login').tr(),
),
body: Theme(
data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
child: Column(
children: [
if (isBusy.value)
LinearProgressIndicator(
minHeight: 4,
borderRadius: BorderRadius.zero,
trackGap: 0,
stopIndicatorRadius: 0,
)
else if (currentTicket.value != null)
LinearProgressIndicator(
minHeight: 4,
borderRadius: BorderRadius.zero,
trackGap: 0,
stopIndicatorRadius: 0,
value:
1 -
(currentTicket.value!.stepRemain /
currentTicket.value!.stepTotal),
)
else
const Gap(4),
Expanded(
child:
SingleChildScrollView(
child: PageTransitionSwitcher(
transitionBuilder: (
Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation,
) {
return SharedAxisTransition(
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
child: Container(
constraints: BoxConstraints(maxWidth: 380),
child: child,
),
);
},
child: switch (period.value % 3) {
1 => _LoginPickerScreen(
key: const ValueKey(1),
challenge: currentTicket.value,
factors: factors.value,
onChallenge:
(SnAuthChallenge? p0) => currentTicket.value = p0,
onPickFactor:
(SnAuthFactor p0) => factorPicked.value = p0,
onNext: () => period.value++,
onBusy: (value) => isBusy.value = value,
),
2 => _LoginCheckScreen(
key: const ValueKey(2),
challenge: currentTicket.value,
factor: factorPicked.value,
onChallenge:
(SnAuthChallenge? p0) => currentTicket.value = p0,
onNext: () => period.value = 1,
onBusy: (value) => isBusy.value = value,
),
_ => _LoginLookupScreen(
key: const ValueKey(0),
ticket: currentTicket.value,
onChallenge:
(SnAuthChallenge? p0) => currentTicket.value = p0,
onFactor:
(List<SnAuthFactor>? p0) =>
factors.value = p0 ?? [],
onNext: () => period.value++,
onBusy: (value) => isBusy.value = value,
),
},
).padding(all: 24),
).center(),
),
const Gap(4),
],
),
),
);
}
}
class _LoginCheckScreen extends HookConsumerWidget {
final SnAuthChallenge? challenge;
final SnAuthFactor? factor;
final Function(SnAuthChallenge?) onChallenge;
final VoidCallback onNext;
final Function(bool) onBusy;
const _LoginCheckScreen({
super.key,
required this.challenge,
required this.factor,
required this.onChallenge,
required this.onNext,
required this.onBusy,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isBusy = useState(false);
final passwordController = useTextEditingController();
useEffect(() {
onBusy.call(isBusy.value);
return null;
}, [isBusy]);
Future<void> getToken({String? code}) async {
// Get token if challenge is completed
final client = ref.watch(apiClientProvider);
final tokenResp = await client.post(
'/pass/auth/token',
data: {
'grant_type': 'authorization_code',
'code': code ?? challenge!.id,
},
);
final token = tokenResp.data['token'];
setToken(ref.watch(sharedPreferencesProvider), token);
ref.invalidate(tokenProvider);
if (!context.mounted) return;
// Do post login tasks
final userNotifier = ref.read(userInfoProvider.notifier);
userNotifier.fetchUser().then((_) {
final apiClient = ref.read(apiClientProvider);
subscribePushNotification(apiClient);
final wsNotifier = ref.read(websocketStateProvider.notifier);
wsNotifier.connect();
if (context.mounted) Navigator.pop(context, true);
});
}
useEffect(() {
if (challenge != null && challenge?.stepRemain == 0) {
Future(() {
if (isBusy.value) return;
isBusy.value = true;
getToken().catchError((err) {
showErrorAlert(err);
isBusy.value = false;
});
});
}
return null;
}, [challenge]);
if (factor == null) {
// Logging in by third parties
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 26,
child: const Icon(Symbols.asterisk, size: 28),
).padding(bottom: 8),
),
Text(
'loginInProgress'.tr(),
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
).padding(left: 4, bottom: 16),
const Gap(16),
CircularProgressIndicator().alignment(Alignment.centerLeft),
],
);
}
Future<void> performCheckTicket() async {
final pwd = passwordController.value.text;
if (pwd.isEmpty) return;
isBusy.value = true;
try {
// Pass challenge
final client = ref.watch(apiClientProvider);
final resp = await client.patch(
'/pass/auth/challenge/${challenge!.id}',
data: {'factor_id': factor!.id, 'password': pwd},
);
final result = SnAuthChallenge.fromJson(resp.data);
onChallenge(result);
if (result.stepRemain > 0) {
onNext();
return;
}
await getToken(code: result.id);
} catch (err) {
showErrorAlert(err);
return;
} finally {
isBusy.value = false;
}
}
final width = math.min(380, MediaQuery.of(context).size.width);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 26,
child: const Icon(Symbols.asterisk, size: 28),
).padding(bottom: 8),
),
Text(
'loginEnterPassword'.tr(),
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
).padding(left: 4, bottom: 16),
if ([0].contains(factor!.type))
TextField(
autocorrect: false,
enableSuggestions: false,
controller: passwordController,
obscureText: true,
autofillHints: [
factor!.type == 0
? AutofillHints.password
: AutofillHints.oneTimeCode,
],
decoration: InputDecoration(
isDense: true,
labelText: 'password'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: isBusy.value ? null : (_) => performCheckTicket(),
).padding(horizontal: 7)
else
OtpTextField(
showCursor: false,
numberOfFields: 6,
obscureText: false,
showFieldAsBox: true,
focusedBorderColor: Theme.of(context).colorScheme.primary,
fieldWidth: (width / 6) - 10,
onSubmit: (value) {
passwordController.text = value;
performCheckTicket();
},
textStyle: Theme.of(context).textTheme.titleLarge!,
),
const Gap(12),
ListTile(
leading: Icon(
kFactorTypes[factor!.type]?.$3 ?? Symbols.question_mark,
),
title: Text(kFactorTypes[factor!.type]?.$1 ?? 'unknown').tr(),
subtitle: Text(kFactorTypes[factor!.type]?.$2 ?? 'unknown').tr(),
),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: isBusy.value ? null : () => performCheckTicket(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next').tr(),
const Icon(Symbols.chevron_right),
],
),
),
],
),
],
);
}
}
class _LoginPickerScreen extends HookConsumerWidget {
final SnAuthChallenge? challenge;
final List<SnAuthFactor>? factors;
final Function(SnAuthChallenge?) onChallenge;
final Function(SnAuthFactor) onPickFactor;
final VoidCallback onNext;
final Function(bool) onBusy;
const _LoginPickerScreen({
super.key,
required this.challenge,
required this.factors,
required this.onChallenge,
required this.onPickFactor,
required this.onNext,
required this.onBusy,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isBusy = useState(false);
final factorPicked = useState<SnAuthFactor?>(null);
useEffect(() {
onBusy.call(isBusy.value);
return null;
}, [isBusy]);
useEffect(() {
if (challenge != null && challenge?.stepRemain == 0) {
Future(() {
onNext();
});
}
return null;
}, [challenge]);
final unfocusColor = Theme.of(
context,
).colorScheme.onSurface.withAlpha((255 * 0.75).round());
final hintController = useTextEditingController();
void performGetFactorCode() async {
if (factorPicked.value == null) return;
isBusy.value = true;
final client = ref.watch(apiClientProvider);
try {
await client.post(
'/pass/auth/challenge/${challenge!.id}/factors/${factorPicked.value!.id}',
data:
hintController.text.isNotEmpty
? jsonEncode(hintController.text)
: null,
);
onPickFactor(factors!.where((x) => x == factorPicked.value).first);
onNext();
} catch (err) {
if (err is DioException && err.response?.statusCode == 400) {
onPickFactor(factors!.where((x) => x == factorPicked.value).first);
onNext();
if (context.mounted) {
showSnackBar(err.response!.data.toString());
}
return;
}
showErrorAlert(err);
return;
} finally {
isBusy.value = false;
}
}
return Column(
key: const ValueKey<int>(1),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 26,
child: const Icon(Symbols.lock, size: 28),
).padding(bottom: 8),
),
Text(
'loginPickFactor',
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
).tr().padding(left: 4),
const Gap(8),
Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: Column(
children:
factors
?.map(
(x) => CheckboxListTile(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
secondary: Icon(
kFactorTypes[x.type]?.$3 ?? Symbols.question_mark,
),
title: Text(kFactorTypes[x.type]?.$1 ?? 'unknown').tr(),
enabled: !challenge!.blacklistFactors.contains(x.id),
value: factorPicked.value == x,
onChanged: (value) {
if (value == true) {
factorPicked.value = x;
}
},
),
)
.toList() ??
List.empty(),
),
),
if ([1].contains(factorPicked.value?.type))
TextField(
controller: hintController,
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'authFactorHint'.tr(),
helperText: 'authFactorHintHelper'.tr(),
),
).padding(top: 12, bottom: 4, horizontal: 4),
const Gap(8),
Text(
'loginMultiFactor'.plural(challenge!.stepRemain),
style: TextStyle(color: unfocusColor, fontSize: 13),
).padding(horizontal: 16),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: isBusy.value ? null : () => performGetFactorCode(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next'.tr()),
const Icon(Symbols.chevron_right),
],
),
),
],
),
],
);
}
}
class _LoginLookupScreen extends HookConsumerWidget {
final SnAuthChallenge? ticket;
final Function(SnAuthChallenge?) onChallenge;
final Function(List<SnAuthFactor>?) onFactor;
final VoidCallback onNext;
final Function(bool) onBusy;
const _LoginLookupScreen({
super.key,
required this.ticket,
required this.onChallenge,
required this.onFactor,
required this.onNext,
required this.onBusy,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isBusy = useState(false);
final usernameController = useTextEditingController();
useEffect(() {
onBusy.call(isBusy.value);
return null;
}, [isBusy]);
Future<void> requestResetPassword() async {
final uname = usernameController.value.text;
if (uname.isEmpty) {
showErrorAlert('loginResetPasswordHint'.tr());
return;
}
final captchaTk = await CaptchaScreen.show(context);
if (captchaTk == null) return;
isBusy.value = true;
try {
final client = ref.watch(apiClientProvider);
await client.post(
'/pass/accounts/recovery/password',
data: {'account': uname, 'captcha_token': captchaTk},
);
showInfoAlert('loginResetPasswordSent'.tr(), 'done'.tr());
} catch (err) {
showErrorAlert(err);
} finally {
isBusy.value = false;
}
}
Future<void> performNewTicket() async {
final uname = usernameController.value.text;
if (uname.isEmpty) return;
isBusy.value = true;
try {
final client = ref.watch(apiClientProvider);
final resp = await client.post(
'/pass/auth/challenge',
data: {
'account': uname,
'device_id': await getUdid(),
'device_name': await getDeviceName(),
'platform':
kIsWeb
? 1
: switch (defaultTargetPlatform) {
TargetPlatform.iOS => 2,
TargetPlatform.android => 3,
TargetPlatform.macOS => 4,
TargetPlatform.windows => 5,
TargetPlatform.linux => 6,
_ => 0,
},
},
);
final result = SnAuthChallenge.fromJson(resp.data);
onChallenge(result);
final factorResp = await client.get(
'/pass/auth/challenge/${result.id}/factors',
);
onFactor(
List<SnAuthFactor>.from(
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
),
);
onNext();
} catch (err) {
showErrorAlert(err);
return;
} finally {
isBusy.value = false;
}
}
Future<void> withApple() async {
final client = ref.watch(apiClientProvider);
try {
final credential = await SignInWithApple.getAppleIDCredential(
scopes: [AppleIDAuthorizationScopes.email],
webAuthenticationOptions: WebAuthenticationOptions(
clientId: 'dev.solsynth.solarpass',
redirectUri: Uri.parse('https://nt.solian.app/auth/callback/apple'),
),
);
if (context.mounted) showLoadingModal(context);
final resp = await client.post(
'/pass/auth/login/apple/mobile',
data: {
'identity_token': credential.identityToken!,
'authorization_code': credential.authorizationCode,
'device_id': await getUdid(),
'device_name': await getDeviceName(),
},
);
final challenge = SnAuthChallenge.fromJson(resp.data);
onChallenge(challenge);
final factorResp = await client.get(
'/pass/auth/challenge/${challenge.id}/factors',
);
onFactor(
List<SnAuthFactor>.from(
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
),
);
onNext();
} catch (err) {
if (err is SignInWithAppleAuthorizationException) return;
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
Future<void> withOidc(String provider) async {
final challengeId = await Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(
builder: (context) => OidcScreen(provider: provider.toLowerCase()),
),
);
final client = ref.watch(apiClientProvider);
try {
final resp = await client.get('/pass/auth/challenge/$challengeId');
final challenge = SnAuthChallenge.fromJson(resp.data);
onChallenge(challenge);
final factorResp = await client.get(
'/pass/auth/challenge/${challenge.id}/factors',
);
onFactor(
List<SnAuthFactor>.from(
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
),
);
onNext();
} catch (err) {
showErrorAlert(err);
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 26,
child: const Icon(Symbols.login, size: 28),
).padding(bottom: 8),
),
Text(
'loginGreeting',
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
).tr().padding(left: 4, bottom: 16),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: usernameController,
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'username'.tr(),
helperText: 'usernameLookupHint'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: isBusy.value ? null : (_) => performNewTicket(),
).padding(horizontal: 7),
if (!kIsWeb)
Row(
spacing: 6,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Text("loginOr").tr().fontSize(11).opacity(0.85),
const Gap(8),
Spacer(),
IconButton.filledTonal(
onPressed: () => withOidc('github'),
padding: EdgeInsets.zero,
icon: getProviderIcon(
"github",
size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
tooltip: 'GitHub',
),
IconButton.filledTonal(
onPressed: () => withOidc('google'),
padding: EdgeInsets.zero,
icon: getProviderIcon(
"google",
size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
tooltip: 'Google',
),
IconButton.filledTonal(
onPressed: withApple,
padding: EdgeInsets.zero,
icon: getProviderIcon(
"apple",
size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
tooltip: 'Apple Account',
),
],
).padding(horizontal: 8, vertical: 8)
else
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: isBusy.value ? null : () => requestResetPassword(),
style: TextButton.styleFrom(foregroundColor: Colors.grey),
child: Text('forgotPassword'.tr()),
),
TextButton(
onPressed: isBusy.value ? null : () => performNewTicket(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next').tr(),
const Icon(Symbols.chevron_right),
],
),
),
],
),
const Gap(12),
Align(
alignment: Alignment.centerRight,
child: StyledWidget(
Container(
constraints: const BoxConstraints(maxWidth: 290),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'termAcceptNextWithAgree'.tr(),
textAlign: TextAlign.end,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withAlpha((255 * 0.75).round()),
),
),
Material(
color: Colors.transparent,
child: InkWell(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('termAcceptLink'.tr()),
const Gap(4),
const Icon(Symbols.launch, size: 14),
],
),
onTap: () {
launchUrlString('https://solsynth.dev/terms');
},
),
),
],
),
),
).padding(horizontal: 16),
),
],
body: LoginContent(),
);
}
}

View File

@@ -0,0 +1,814 @@
import 'dart:convert';
import 'dart:math' as math;
import 'package:animations/animations.dart';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_otp_text_field/flutter_otp_text_field.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:gap/gap.dart';
import 'package:island/models/auth.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/pods/websocket.dart';
import 'package:island/screens/account/me/settings_connections.dart';
import 'package:island/services/event_bus.dart';
import 'package:island/services/notify.dart';
import 'package:island/services/udid.dart';
import 'package:island/widgets/alert.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'captcha.dart';
final Map<int, (String, String, IconData)> kFactorTypes = {
0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
1: ('authFactorEmail', 'authFactorEmailDescription', Symbols.email),
2: (
'authFactorInAppNotify',
'authFactorInAppNotifyDescription',
Symbols.notifications_active,
),
3: ('authFactorTOTP', 'authFactorTOTPDescription', Symbols.timer),
4: ('authFactorPin', 'authFactorPinDescription', Symbols.nest_secure_alarm),
};
class _LoginCheckScreen extends HookConsumerWidget {
final SnAuthChallenge? challenge;
final SnAuthFactor? factor;
final Function(SnAuthChallenge?) onChallenge;
final VoidCallback onNext;
final Function(bool) onBusy;
const _LoginCheckScreen({
super.key,
required this.challenge,
required this.factor,
required this.onChallenge,
required this.onNext,
required this.onBusy,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isBusy = useState(false);
final passwordController = useTextEditingController();
useEffect(() {
onBusy.call(isBusy.value);
return null;
}, [isBusy]);
Future<void> getToken({String? code}) async {
// Get token if challenge is completed
final client = ref.watch(apiClientProvider);
final tokenResp = await client.post(
'/pass/auth/token',
data: {
'grant_type': 'authorization_code',
'code': code ?? challenge!.id,
},
);
final token = tokenResp.data['token'];
setToken(ref.watch(sharedPreferencesProvider), token);
ref.invalidate(tokenProvider);
if (!context.mounted) return;
// Do post login tasks
final userNotifier = ref.read(userInfoProvider.notifier);
userNotifier.fetchUser().then((_) {
final apiClient = ref.read(apiClientProvider);
subscribePushNotification(apiClient);
final wsNotifier = ref.read(websocketStateProvider.notifier);
wsNotifier.connect();
if (context.mounted) Navigator.pop(context, true);
});
}
useEffect(() {
if (challenge != null && challenge?.stepRemain == 0) {
Future(() {
if (isBusy.value) return;
isBusy.value = true;
getToken().catchError((err) {
showErrorAlert(err);
isBusy.value = false;
});
});
}
return null;
}, [challenge]);
if (factor == null) {
// Logging in by third parties
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 26,
child: const Icon(Symbols.asterisk, size: 28),
).padding(bottom: 8),
),
Text(
'loginInProgress'.tr(),
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
).padding(left: 4, bottom: 16),
const Gap(16),
CircularProgressIndicator().alignment(Alignment.centerLeft),
],
);
}
Future<void> performCheckTicket() async {
final pwd = passwordController.value.text;
if (pwd.isEmpty) return;
isBusy.value = true;
try {
// Pass challenge
final client = ref.watch(apiClientProvider);
final resp = await client.patch(
'/pass/auth/challenge/${challenge!.id}',
data: {'factor_id': factor!.id, 'password': pwd},
);
final result = SnAuthChallenge.fromJson(resp.data);
onChallenge(result);
if (result.stepRemain > 0) {
onNext();
return;
}
await getToken(code: result.id);
} catch (err) {
showErrorAlert(err);
return;
} finally {
isBusy.value = false;
}
}
final width = math.min(380, MediaQuery.of(context).size.width);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 26,
child: const Icon(Symbols.asterisk, size: 28),
).padding(bottom: 8),
),
Text(
'loginEnterPassword'.tr(),
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
).padding(left: 4, bottom: 16),
if ([0].contains(factor!.type))
TextField(
autocorrect: false,
enableSuggestions: false,
controller: passwordController,
obscureText: true,
autofillHints: [
factor!.type == 0
? AutofillHints.password
: AutofillHints.oneTimeCode,
],
decoration: InputDecoration(
isDense: true,
labelText: 'password'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: isBusy.value ? null : (_) => performCheckTicket(),
).padding(horizontal: 7)
else
OtpTextField(
showCursor: false,
numberOfFields: 6,
obscureText: false,
showFieldAsBox: true,
focusedBorderColor: Theme.of(context).colorScheme.primary,
fieldWidth: (width / 6) - 10,
onSubmit: (value) {
passwordController.text = value;
performCheckTicket();
},
textStyle: Theme.of(context).textTheme.titleLarge!,
),
const Gap(12),
ListTile(
leading: Icon(
kFactorTypes[factor!.type]?.$3 ?? Symbols.question_mark,
),
title: Text(kFactorTypes[factor!.type]?.$1 ?? 'unknown').tr(),
subtitle: Text(kFactorTypes[factor!.type]?.$2 ?? 'unknown').tr(),
),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: isBusy.value ? null : () => performCheckTicket(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next').tr(),
const Icon(Symbols.chevron_right),
],
),
),
],
),
],
);
}
}
class LoginContent extends HookConsumerWidget {
const LoginContent({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isBusy = useState(false);
final period = useState(0);
final currentTicket = useState<SnAuthChallenge?>(null);
final factors = useState<List<SnAuthFactor>>([]);
final factorPicked = useState<SnAuthFactor?>(null);
return Theme(
data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
child: Column(
children: [
if (isBusy.value)
LinearProgressIndicator(
minHeight: 4,
borderRadius: BorderRadius.zero,
trackGap: 0,
stopIndicatorRadius: 0,
)
else if (currentTicket.value != null)
LinearProgressIndicator(
minHeight: 4,
borderRadius: BorderRadius.zero,
trackGap: 0,
stopIndicatorRadius: 0,
value:
1 -
(currentTicket.value!.stepRemain /
currentTicket.value!.stepTotal),
)
else
const Gap(4),
Expanded(
child:
SingleChildScrollView(
child: PageTransitionSwitcher(
transitionBuilder: (
Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation,
) {
return SharedAxisTransition(
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
child: Container(
constraints: BoxConstraints(maxWidth: 380),
child: child,
),
);
},
child: switch (period.value % 3) {
1 => _LoginPickerScreen(
key: const ValueKey(1),
challenge: currentTicket.value,
factors: factors.value,
onChallenge:
(SnAuthChallenge? p0) => currentTicket.value = p0,
onPickFactor:
(SnAuthFactor p0) => factorPicked.value = p0,
onNext: () => period.value++,
onBusy: (value) => isBusy.value = value,
),
2 => _LoginCheckScreen(
key: const ValueKey(2),
challenge: currentTicket.value,
factor: factorPicked.value,
onChallenge:
(SnAuthChallenge? p0) => currentTicket.value = p0,
onNext: () => period.value = 1,
onBusy: (value) => isBusy.value = value,
),
_ => _LoginLookupScreen(
key: const ValueKey(0),
ticket: currentTicket.value,
onChallenge:
(SnAuthChallenge? p0) => currentTicket.value = p0,
onFactor:
(List<SnAuthFactor>? p0) =>
factors.value = p0 ?? [],
onNext: () => period.value++,
onBusy: (value) => isBusy.value = value,
),
},
).padding(all: 24),
).center(),
),
const Gap(4),
],
),
);
}
}
class _LoginPickerScreen extends HookConsumerWidget {
final SnAuthChallenge? challenge;
final List<SnAuthFactor>? factors;
final Function(SnAuthChallenge?) onChallenge;
final Function(SnAuthFactor) onPickFactor;
final VoidCallback onNext;
final Function(bool) onBusy;
const _LoginPickerScreen({
super.key,
required this.challenge,
required this.factors,
required this.onChallenge,
required this.onPickFactor,
required this.onNext,
required this.onBusy,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isBusy = useState(false);
final factorPicked = useState<SnAuthFactor?>(null);
useEffect(() {
onBusy.call(isBusy.value);
return null;
}, [isBusy]);
useEffect(() {
if (challenge != null && challenge?.stepRemain == 0) {
Future(() {
onNext();
});
}
return null;
}, [challenge]);
final unfocusColor = Theme.of(
context,
).colorScheme.onSurface.withAlpha((255 * 0.75).round());
final hintController = useTextEditingController();
void performGetFactorCode() async {
if (factorPicked.value == null) return;
isBusy.value = true;
final client = ref.watch(apiClientProvider);
try {
await client.post(
'/pass/auth/challenge/${challenge!.id}/factors/${factorPicked.value!.id}',
data:
hintController.text.isNotEmpty
? jsonEncode(hintController.text)
: null,
);
onPickFactor(factors!.where((x) => x == factorPicked.value).first);
onNext();
} catch (err) {
if (err is DioException && err.response?.statusCode == 400) {
onPickFactor(factors!.where((x) => x == factorPicked.value).first);
onNext();
if (context.mounted) {
showSnackBar(err.response!.data.toString());
}
return;
}
showErrorAlert(err);
return;
} finally {
isBusy.value = false;
}
}
return Column(
key: const ValueKey<int>(1),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 26,
child: const Icon(Symbols.lock, size: 28),
).padding(bottom: 8),
),
Text(
'loginPickFactor'.tr(),
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
).padding(left: 4),
const Gap(8),
Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: Column(
children:
factors
?.map(
(x) => CheckboxListTile(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
secondary: Icon(
kFactorTypes[x.type]?.$3 ?? Symbols.question_mark,
),
title: Text(kFactorTypes[x.type]?.$1 ?? 'unknown').tr(),
enabled: !challenge!.blacklistFactors.contains(x.id),
value: factorPicked.value == x,
onChanged: (value) {
if (value == true) {
factorPicked.value = x;
}
},
),
)
.toList() ??
List.empty(),
),
),
if ([1].contains(factorPicked.value?.type))
TextField(
controller: hintController,
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'authFactorHint'.tr(),
helperText: 'authFactorHintHelper'.tr(),
),
).padding(top: 12, bottom: 4, horizontal: 4),
const Gap(8),
Text(
'loginMultiFactor'.plural(challenge!.stepRemain),
style: TextStyle(color: unfocusColor, fontSize: 13),
).padding(horizontal: 16),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: isBusy.value ? null : () => performGetFactorCode(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next'.tr()),
const Icon(Symbols.chevron_right),
],
),
),
],
),
],
);
}
}
class _LoginLookupScreen extends HookConsumerWidget {
final SnAuthChallenge? ticket;
final Function(SnAuthChallenge?) onChallenge;
final Function(List<SnAuthFactor>?) onFactor;
final VoidCallback onNext;
final Function(bool) onBusy;
const _LoginLookupScreen({
super.key,
required this.ticket,
required this.onChallenge,
required this.onFactor,
required this.onNext,
required this.onBusy,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isBusy = useState(false);
final usernameController = useTextEditingController();
final waitingForOidc = useState(false);
useEffect(() {
onBusy.call(isBusy.value);
return null;
}, [isBusy]);
useEffect(() {
final subscription = eventBus.on<OidcAuthCallbackEvent>().listen((
event,
) async {
if (!waitingForOidc.value || !context.mounted) return;
waitingForOidc.value = false;
final client = ref.watch(apiClientProvider);
try {
final resp = await client.get(
'/pass/auth/challenge/${event.challengeId}',
);
final challenge = SnAuthChallenge.fromJson(resp.data);
onChallenge(challenge);
final factorResp = await client.get(
'/pass/auth/challenge/${challenge.id}/factors',
);
onFactor(
List<SnAuthFactor>.from(
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
),
);
onNext();
} catch (err) {
showErrorAlert(err);
}
});
return subscription.cancel;
}, [waitingForOidc.value, context.mounted]);
Future<void> requestResetPassword() async {
final uname = usernameController.value.text;
if (uname.isEmpty) {
showErrorAlert('loginResetPasswordHint'.tr());
return;
}
final captchaTk = await CaptchaScreen.show(context);
if (captchaTk == null) return;
isBusy.value = true;
try {
final client = ref.watch(apiClientProvider);
await client.post(
'/pass/accounts/recovery/password',
data: {'account': uname, 'captcha_token': captchaTk},
);
showInfoAlert('loginResetPasswordSent'.tr(), 'done'.tr());
} catch (err) {
showErrorAlert(err);
} finally {
isBusy.value = false;
}
}
Future<void> performNewTicket() async {
final uname = usernameController.value.text;
if (uname.isEmpty) return;
isBusy.value = true;
try {
final client = ref.watch(apiClientProvider);
final resp = await client.post(
'/pass/auth/challenge',
data: {
'account': uname,
'device_id': await getUdid(),
'device_name': await getDeviceName(),
'platform':
kIsWeb
? 1
: switch (defaultTargetPlatform) {
TargetPlatform.iOS => 2,
TargetPlatform.android => 3,
TargetPlatform.macOS => 4,
TargetPlatform.windows => 5,
TargetPlatform.linux => 6,
_ => 0,
},
},
);
final result = SnAuthChallenge.fromJson(resp.data);
onChallenge(result);
final factorResp = await client.get(
'/pass/auth/challenge/${result.id}/factors',
);
onFactor(
List<SnAuthFactor>.from(
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
),
);
onNext();
} catch (err) {
showErrorAlert(err);
return;
} finally {
isBusy.value = false;
}
}
Future<void> withApple() async {
final client = ref.watch(apiClientProvider);
try {
final credential = await SignInWithApple.getAppleIDCredential(
scopes: [AppleIDAuthorizationScopes.email],
webAuthenticationOptions: WebAuthenticationOptions(
clientId: 'dev.solsynth.solarpass',
redirectUri: Uri.parse('https://nt.solian.app/auth/callback/apple'),
),
);
if (context.mounted) showLoadingModal(context);
final resp = await client.post(
'/pass/auth/login/apple/mobile',
data: {
'identity_token': credential.identityToken!,
'authorization_code': credential.authorizationCode,
'device_id': await getUdid(),
'device_name': await getDeviceName(),
},
);
final challenge = SnAuthChallenge.fromJson(resp.data);
onChallenge(challenge);
final factorResp = await client.get(
'/pass/auth/challenge/${challenge.id}/factors',
);
onFactor(
List<SnAuthFactor>.from(
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
),
);
onNext();
} catch (err) {
if (err is SignInWithAppleAuthorizationException) return;
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
Future<void> withOidc(String provider) async {
waitingForOidc.value = true;
final serverUrl = ref.watch(serverUrlProvider);
final token = ref.watch(tokenProvider);
final deviceId = await getUdid();
final queryParams = <String, String>{
'returnUrl': 'solian://auth/callback',
'deviceId': deviceId,
'flow': 'login',
};
if (token?.token != null) {
queryParams['token'] = token!.token;
}
final url =
Uri.parse(
'$serverUrl/pass/auth/login/${provider.toLowerCase()}',
).replace(queryParameters: queryParams).toString();
final isLaunched = await launchUrlString(
url,
mode:
kIsWeb
? LaunchMode.platformDefault
: LaunchMode.externalApplication,
webOnlyWindowName:
token?.token != null ? 'auth-${token!.token}' : 'auth',
);
if (!isLaunched) {
waitingForOidc.value = false;
showErrorAlert('failedToLaunchBrowser'.tr());
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 26,
child: const Icon(Symbols.login, size: 28),
).padding(bottom: 8),
),
Text(
'loginGreeting'.tr(),
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
).padding(left: 4, bottom: 16),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: usernameController,
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'username'.tr(),
helperText: 'usernameLookupHint'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: isBusy.value ? null : (_) => performNewTicket(),
).padding(horizontal: 7),
if (!kIsWeb)
Row(
spacing: 6,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Text("loginOr").tr().fontSize(11).opacity(0.85),
const Gap(8),
Spacer(),
IconButton.filledTonal(
onPressed: () => withOidc('github'),
padding: EdgeInsets.zero,
icon: getProviderIcon(
"github",
size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
tooltip: 'GitHub',
),
IconButton.filledTonal(
onPressed: () => withOidc('google'),
padding: EdgeInsets.zero,
icon: getProviderIcon(
"google",
size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
tooltip: 'Google',
),
IconButton.filledTonal(
onPressed: withApple,
padding: EdgeInsets.zero,
icon: getProviderIcon(
"apple",
size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
tooltip: 'Apple Account',
),
],
).padding(horizontal: 8, vertical: 8)
else
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: isBusy.value ? null : () => requestResetPassword(),
style: TextButton.styleFrom(foregroundColor: Colors.grey),
child: Text('forgotPassword'.tr()),
),
TextButton(
onPressed: isBusy.value ? null : () => performNewTicket(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next').tr(),
const Icon(Symbols.chevron_right),
],
),
),
],
),
const Gap(12),
Align(
alignment: Alignment.centerRight,
child: StyledWidget(
Container(
constraints: const BoxConstraints(maxWidth: 290),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'termAcceptNextWithAgree'.tr(),
textAlign: TextAlign.end,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withAlpha((255 * 0.75).round()),
),
),
Material(
color: Colors.transparent,
child: InkWell(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('termAcceptLink'.tr()),
const Gap(4),
const Icon(Symbols.launch, size: 14),
],
),
onTap: () {
launchUrlString('https://solsynth.dev/terms');
},
),
),
],
),
),
).padding(horizontal: 16),
),
],
);
}
}

View File

@@ -0,0 +1,19 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/widgets/content/sheet.dart';
import 'login_content.dart';
class LoginModal extends HookConsumerWidget {
const LoginModal({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return SheetScaffold(
titleText: 'login'.tr(),
heightFactor: 0.9,
child: LoginContent(),
);
}
}

View File

@@ -16,10 +16,10 @@ import 'package:island/screens/realm/realms.dart';
import 'package:island/services/file.dart';
import 'package:island/services/file_uploader.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:island/widgets/content/sheet.dart';
class NewChatScreen extends StatelessWidget {
const NewChatScreen({super.key});
@@ -151,12 +151,10 @@ class EditChatScreen extends HookConsumerWidget {
}
}
return AppScaffold(
appBar: AppBar(
title: Text(id == null ? 'createChatRoom' : 'editChatRoom').tr(),
leading: const PageBackButton(),
),
body: SingleChildScrollView(
return SheetScaffold(
titleText: (id == null ? 'createChatRoom' : 'editChatRoom').tr(),
onClose: () => context.pop(),
child: SingleChildScrollView(
child: Column(
children: [
AspectRatio(
@@ -204,16 +202,24 @@ class EditChatScreen extends HookConsumerWidget {
children: [
TextFormField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Name'),
decoration: InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
TextFormField(
controller: descriptionController,
decoration: const InputDecoration(
decoration: InputDecoration(
labelText: 'Description',
alignLabelWithHint: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
minLines: 3,
maxLines: null,
@@ -223,7 +229,12 @@ class EditChatScreen extends HookConsumerWidget {
const SizedBox(height: 16),
DropdownButtonFormField<SnRealm>(
value: currentRealm.value,
decoration: InputDecoration(labelText: 'realm'.tr()),
decoration: InputDecoration(
labelText: 'realm'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
items: [
DropdownMenuItem<SnRealm>(
value: null,

View File

@@ -11,6 +11,8 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
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";
@@ -142,12 +144,36 @@ class ChatRoomScreen extends HookConsumerWidget {
final messageController = useTextEditingController();
final scrollController = useScrollController();
// Input height measurement for dynamic padding
final inputKey = useMemoized(() => GlobalKey());
final inputHeight = useState<double>(80.0);
// Track previous height for smooth animations
final previousInputHeight = usePrevious<double>(inputHeight.value);
// Periodic height measurement for dynamic sizing
useEffect(() {
final timer = Timer.periodic(const Duration(milliseconds: 50), (_) {
final renderBox =
inputKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox != null) {
final newHeight = renderBox.size.height;
if (newHeight != inputHeight.value) {
inputHeight.value = newHeight;
}
}
});
return timer.cancel;
}, []);
// Scroll animation notifiers
final bottomGradientNotifier = useState(ValueNotifier<double>(0.0));
final messageReplyingTo = useState<SnChatMessage?>(null);
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?>>>({});
@@ -263,11 +289,15 @@ class ChatRoomScreen extends HookConsumerWidget {
void sendMessage() {
if (messageController.text.trim().isNotEmpty ||
attachments.value.isNotEmpty) {
attachments.value.isNotEmpty ||
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,
@@ -282,6 +312,8 @@ class ChatRoomScreen extends HookConsumerWidget {
messageEditingTo.value = null;
messageReplyingTo.value = null;
messageForwardingTo.value = null;
selectedPoll.value = null;
selectedFund.value = null;
attachments.value = [];
}
}
@@ -592,183 +624,428 @@ class ChatRoomScreen extends HookConsumerWidget {
}
}
Widget chatMessageListWidget(
List<LocalChatMessage> messageList,
) => SuperListView.builder(
listController: listController,
padding: EdgeInsets.only(
top: 16,
bottom:
MediaQuery.of(context).padding.bottom +
80, // Leave space for chat input
),
controller: scrollController,
reverse: true, // Show newest messages at the bottom
itemCount: messageList.length,
findChildIndexCallback: (key) {
if (key is! ValueKey<String>) return null;
final messageId = key.value.substring(messageKeyPrefix.length);
final index = messageList.indexWhere(
(m) => (m.nonce ?? m.id) == messageId,
);
// Return null for invalid indices to let SuperListView handle it properly
return index >= 0 ? index : null;
},
extentEstimation: (_, _) => 40,
itemBuilder: (context, index) {
final message = messageList[index];
final nextMessage =
index < messageList.length - 1 ? messageList[index + 1] : null;
final isLastInGroup =
nextMessage == null ||
nextMessage.senderId != message.senderId ||
nextMessage.createdAt
.difference(message.createdAt)
.inMinutes
.abs() >
3;
Widget chatMessageListWidget(List<LocalChatMessage> messageList) =>
previousInputHeight != null && previousInputHeight != inputHeight.value
? TweenAnimationBuilder<double>(
tween: Tween<double>(
begin: previousInputHeight,
end: inputHeight.value,
),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
builder:
(context, height, child) => SuperListView.builder(
listController: listController,
padding: EdgeInsets.only(
top: 16,
bottom:
MediaQuery.of(context).padding.bottom + 8 + height,
),
controller: scrollController,
reverse: true, // Show newest messages at the bottom
itemCount: messageList.length,
findChildIndexCallback: (key) {
if (key is! ValueKey<String>) return null;
final messageId = key.value.substring(
messageKeyPrefix.length,
);
final index = messageList.indexWhere(
(m) => (m.nonce ?? m.id) == messageId,
);
// Return null for invalid indices to let SuperListView handle it properly
return index >= 0 ? index : null;
},
extentEstimation: (_, _) => 40,
itemBuilder: (context, index) {
final message = messageList[index];
final nextMessage =
index < messageList.length - 1
? messageList[index + 1]
: null;
final isLastInGroup =
nextMessage == null ||
nextMessage.senderId != message.senderId ||
nextMessage.createdAt
.difference(message.createdAt)
.inMinutes
.abs() >
3;
// Use a stable animation key that doesn't change during message lifecycle
final key = Key('$messageKeyPrefix${message.nonce ?? message.id}');
// Use a stable animation key that doesn't change during message lifecycle
final key = Key(
'$messageKeyPrefix${message.nonce ?? message.id}',
);
final messageWidget = chatIdentity.when(
skipError: true,
data:
(identity) => GestureDetector(
onLongPress: () {
if (!isSelectionMode.value) {
toggleSelectionMode();
toggleMessageSelection(message.id);
}
},
onTap: () {
if (isSelectionMode.value) {
toggleMessageSelection(message.id);
}
},
child: Container(
color:
selectedMessages.value.contains(message.id)
? Theme.of(
context,
).colorScheme.primaryContainer.withOpacity(0.3)
: null,
child: Stack(
children: [
MessageItem(
key: settings.disableAnimation ? key : null,
message: message,
isCurrentUser: identity?.id == message.senderId,
onAction:
isSelectionMode.value
? null
: (action) {
switch (action) {
case MessageItemAction.delete:
messagesNotifier.deleteMessage(
message.id,
);
case MessageItemAction.edit:
messageEditingTo.value =
message.toRemoteMessage();
messageController.text =
messageEditingTo.value?.content ?? '';
attachments.value =
messageEditingTo.value!.attachments
.map(
(e) =>
UniversalFile.fromAttachment(
e,
),
)
.toList();
case MessageItemAction.forward:
messageForwardingTo.value =
message.toRemoteMessage();
case MessageItemAction.reply:
messageReplyingTo.value =
message.toRemoteMessage();
case MessageItemAction.resend:
messagesNotifier.retryMessage(message.id);
}
},
onJump: (messageId) {
scrollToMessage(
messageId: messageId,
messageList: messageList,
messagesNotifier: messagesNotifier,
listController: listController,
scrollController: scrollController,
ref: ref,
final messageWidget = chatIdentity.when(
skipError: true,
data:
(identity) => GestureDetector(
onLongPress: () {
if (!isSelectionMode.value) {
toggleSelectionMode();
toggleMessageSelection(message.id);
}
},
onTap: () {
if (isSelectionMode.value) {
toggleMessageSelection(message.id);
}
},
child: Container(
color:
selectedMessages.value.contains(message.id)
? Theme.of(context)
.colorScheme
.primaryContainer
.withOpacity(0.3)
: null,
child: Stack(
children: [
MessageItem(
key:
settings.disableAnimation
? key
: null,
message: message,
isCurrentUser:
identity?.id == message.senderId,
onAction:
isSelectionMode.value
? null
: (action) {
switch (action) {
case MessageItemAction.delete:
messagesNotifier
.deleteMessage(
message.id,
);
case MessageItemAction.edit:
messageEditingTo.value =
message
.toRemoteMessage();
messageController.text =
messageEditingTo
.value
?.content ??
'';
attachments.value =
messageEditingTo
.value!
.attachments
.map(
(e) =>
UniversalFile.fromAttachment(
e,
),
)
.toList();
case MessageItemAction
.forward:
messageForwardingTo.value =
message
.toRemoteMessage();
case MessageItemAction.reply:
messageReplyingTo.value =
message
.toRemoteMessage();
case MessageItemAction.resend:
messagesNotifier
.retryMessage(
message.id,
);
}
},
onJump: (messageId) {
scrollToMessage(
messageId: messageId,
messageList: messageList,
messagesNotifier: messagesNotifier,
listController: listController,
scrollController: scrollController,
ref: ref,
);
},
progress:
attachmentProgress.value[message.id],
showAvatar: isLastInGroup,
isSelectionMode: isSelectionMode.value,
isSelected: selectedMessages.value
.contains(message.id),
onToggleSelection: toggleMessageSelection,
onEnterSelectionMode: () {
if (!isSelectionMode.value) {
toggleSelectionMode();
}
},
),
if (selectedMessages.value.contains(
message.id,
))
...([
Positioned(
top: 8,
right: 8,
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color:
Theme.of(
context,
).colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(
Icons.check,
size: 12,
color:
Theme.of(
context,
).colorScheme.onPrimary,
),
),
),
]),
],
),
),
),
loading:
() => MessageItem(
message: message,
isCurrentUser: false,
onAction: null,
progress: null,
showAvatar: false,
onJump: (_) {},
),
error: (_, _) => const SizedBox.shrink(),
);
return settings.disableAnimation
? messageWidget
: TweenAnimationBuilder<double>(
key: key,
tween: Tween<double>(begin: 0.0, end: 1.0),
duration: Duration(
milliseconds: 400 + (index % 5) * 50,
), // Staggered delay
curve: Curves.easeOutCubic,
builder: (context, animationValue, child) {
return Transform.translate(
offset: Offset(
0,
20 * (1 - animationValue),
), // Slide up from bottom
child: Opacity(
opacity: animationValue,
child: child,
),
);
},
child: messageWidget,
);
},
progress: attachmentProgress.value[message.id],
showAvatar: isLastInGroup,
isSelectionMode: isSelectionMode.value,
isSelected: selectedMessages.value.contains(message.id),
onToggleSelection: toggleMessageSelection,
onEnterSelectionMode: () {
},
),
)
: SuperListView.builder(
listController: listController,
padding: EdgeInsets.only(
top: 16,
bottom:
MediaQuery.of(context).padding.bottom +
8 +
inputHeight.value,
),
controller: scrollController,
reverse: true, // Show newest messages at the bottom
itemCount: messageList.length,
findChildIndexCallback: (key) {
if (key is! ValueKey<String>) return null;
final messageId = key.value.substring(messageKeyPrefix.length);
final index = messageList.indexWhere(
(m) => (m.nonce ?? m.id) == messageId,
);
// Return null for invalid indices to let SuperListView handle it properly
return index >= 0 ? index : null;
},
extentEstimation: (_, _) => 40,
itemBuilder: (context, index) {
final message = messageList[index];
final nextMessage =
index < messageList.length - 1
? messageList[index + 1]
: null;
final isLastInGroup =
nextMessage == null ||
nextMessage.senderId != message.senderId ||
nextMessage.createdAt
.difference(message.createdAt)
.inMinutes
.abs() >
3;
// Use a stable animation key that doesn't change during message lifecycle
final key = Key(
'$messageKeyPrefix${message.nonce ?? message.id}',
);
final messageWidget = chatIdentity.when(
skipError: true,
data:
(identity) => GestureDetector(
onLongPress: () {
if (!isSelectionMode.value) {
toggleSelectionMode();
toggleMessageSelection(message.id);
}
},
),
if (selectedMessages.value.contains(message.id))
Positioned(
top: 8,
right: 8,
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(
Icons.check,
size: 12,
color: Theme.of(context).colorScheme.onPrimary,
),
onTap: () {
if (isSelectionMode.value) {
toggleMessageSelection(message.id);
}
},
child: Container(
color:
selectedMessages.value.contains(message.id)
? Theme.of(context)
.colorScheme
.primaryContainer
.withOpacity(0.3)
: null,
child: Stack(
children: [
MessageItem(
key: settings.disableAnimation ? key : null,
message: message,
isCurrentUser: identity?.id == message.senderId,
onAction:
isSelectionMode.value
? null
: (action) {
switch (action) {
case MessageItemAction.delete:
messagesNotifier.deleteMessage(
message.id,
);
case MessageItemAction.edit:
messageEditingTo.value =
message.toRemoteMessage();
messageController.text =
messageEditingTo
.value
?.content ??
'';
attachments.value =
messageEditingTo
.value!
.attachments
.map(
(e) =>
UniversalFile.fromAttachment(
e,
),
)
.toList();
case MessageItemAction.forward:
messageForwardingTo.value =
message.toRemoteMessage();
case MessageItemAction.reply:
messageReplyingTo.value =
message.toRemoteMessage();
case MessageItemAction.resend:
messagesNotifier.retryMessage(
message.id,
);
}
},
onJump: (messageId) {
scrollToMessage(
messageId: messageId,
messageList: messageList,
messagesNotifier: messagesNotifier,
listController: listController,
scrollController: scrollController,
ref: ref,
);
},
progress: attachmentProgress.value[message.id],
showAvatar: isLastInGroup,
isSelectionMode: isSelectionMode.value,
isSelected: selectedMessages.value.contains(
message.id,
),
onToggleSelection: toggleMessageSelection,
onEnterSelectionMode: () {
if (!isSelectionMode.value) {
toggleSelectionMode();
}
},
),
if (selectedMessages.value.contains(message.id))
...([
Positioned(
top: 8,
right: 8,
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color:
Theme.of(
context,
).colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(
Icons.check,
size: 12,
color:
Theme.of(
context,
).colorScheme.onPrimary,
),
),
),
]),
],
),
),
],
),
),
),
loading:
() => MessageItem(
message: message,
isCurrentUser: false,
onAction: null,
progress: null,
showAvatar: false,
onJump: (_) {},
),
error: (_, _) => const SizedBox.shrink(),
);
return settings.disableAnimation
? messageWidget
: TweenAnimationBuilder<double>(
key: key,
tween: Tween<double>(begin: 0.0, end: 1.0),
duration: Duration(
milliseconds: 400 + (index % 5) * 50,
), // Staggered delay
curve: Curves.easeOutCubic,
builder: (context, animationValue, child) {
return Transform.translate(
offset: Offset(
0,
20 * (1 - animationValue),
), // Slide up from bottom
child: Opacity(opacity: animationValue, child: child),
),
loading:
() => MessageItem(
message: message,
isCurrentUser: false,
onAction: null,
progress: null,
showAvatar: false,
onJump: (_) {},
),
error: (_, _) => const SizedBox.shrink(),
);
return settings.disableAnimation
? messageWidget
: TweenAnimationBuilder<double>(
key: key,
tween: Tween<double>(begin: 0.0, end: 1.0),
duration: Duration(
milliseconds: 400 + (index % 5) * 50,
), // Staggered delay
curve: Curves.easeOutCubic,
builder: (context, animationValue, child) {
return Transform.translate(
offset: Offset(
0,
20 * (1 - animationValue),
), // Slide up from bottom
child: Opacity(opacity: animationValue, child: child),
);
},
child: messageWidget,
);
},
child: messageWidget,
);
},
);
return AppScaffold(
appBar: AppBar(
@@ -964,6 +1241,7 @@ class ChatRoomScreen extends HookConsumerWidget {
child: chatRoom.when(
data:
(room) => Column(
key: inputKey,
mainAxisSize: MainAxisSize.min,
children: [
ChatInput(
@@ -978,10 +1256,16 @@ class ChatRoomScreen extends HookConsumerWidget {
messageEditingTo.value = null;
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

@@ -16,6 +16,7 @@ import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/screens/chat/chat_form.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
@@ -447,10 +448,17 @@ class _ChatRoomActionMenu extends HookConsumerWidget {
if ((chatIdentity.value?.role ?? 0) >= 50)
PopupMenuItem(
onTap: () {
context.pushReplacementNamed(
'chatEdit',
pathParameters: {'id': id},
);
showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => EditChatScreen(id: id),
).then((value) {
if (value != null) {
// Invalidate to refresh room data after edit
ref.invalidate(chatroomProvider(id));
}
});
},
child: Row(
children: [

View File

@@ -831,7 +831,7 @@ class _PublisherMemberListSheet extends HookConsumerWidget {
try {
final apiClient = ref.watch(apiClientProvider);
await apiClient.post(
'/publishers/$publisherUname/invites',
'/sphere/publishers/$publisherUname/invites',
data: {'related_user_id': result.id, 'role': 0},
);
// Refresh both providers
@@ -962,7 +962,7 @@ class _PublisherMemberListSheet extends HookConsumerWidget {
apiClientProvider,
);
await apiClient.delete(
'/publishers/$publisherUname/members/${member.accountId}',
'/sphere/publishers/$publisherUname/members/${member.accountId}',
);
// Refresh both providers
memberNotifier.reset();
@@ -1087,7 +1087,7 @@ class _PublisherMemberRoleSheet extends HookConsumerWidget {
final apiClient = ref.read(apiClientProvider);
await apiClient.patch(
'/publishers/$publisherUname/members/${member.accountId}/role',
'/sphere/publishers/$publisherUname/members/${member.accountId}/role',
data: newRole,
);

View File

@@ -1,10 +1,10 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/poll.dart';
import 'package:island/pods/network.dart';
import 'package:island/screens/poll/poll_editor.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/poll/poll_feedback.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -73,10 +73,14 @@ class CreatorPollListScreen extends HookConsumerWidget {
final String pubName;
Future<void> _createPoll(BuildContext context) async {
final result = await GoRouter.of(
context,
).pushNamed('creatorPollNew', pathParameters: {'name': pubName});
if (result is SnPollWithStats && context.mounted) {
final result = await showModalBottomSheet<SnPollWithStats>(
context: context,
isScrollControlled: true,
isDismissible: false,
enableDrag: false,
builder: (context) => PollEditorScreen(initialPublisher: pubName),
);
if (result != null && context.mounted) {
Navigator.of(context).maybePop(result);
}
}
@@ -176,11 +180,20 @@ class _CreatorPollItem extends HookConsumerWidget {
Text('edit').tr(),
],
),
onTap: () {
GoRouter.of(context).pushNamed(
'creatorPollEdit',
pathParameters: {'name': pubName, 'id': pollWithStats.id},
onTap: () async {
final result = await showModalBottomSheet<SnPoll>(
context: context,
isScrollControlled: true,
isDismissible: false,
builder:
(context) => PollEditorScreen(
initialPublisher: pubName,
initialPollId: pollWithStats.id,
),
);
if (result != null && context.mounted) {
ref.invalidate(pollListNotifierProvider(pubName));
}
},
),
PopupMenuItem(

View File

@@ -11,8 +11,10 @@ import 'package:island/models/realm.dart';
import 'package:island/models/webfeed.dart';
import 'package:island/pods/event_calendar.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/screens/auth/login_modal.dart';
import 'package:island/screens/notification.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/account/friends_overview.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/models/post.dart';
import 'package:island/widgets/check_in.dart';
@@ -340,6 +342,7 @@ class ExploreScreen extends HookConsumerWidget {
margin: EdgeInsets.zero,
),
PostFeaturedList(),
FriendsOverviewWidget(),
],
),
),
@@ -348,21 +351,39 @@ class ExploreScreen extends HookConsumerWidget {
else
Flexible(
flex: 2,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Welcome to\nthe Solar Network',
style: Theme.of(context).textTheme.titleLarge,
).bold(),
const Gap(2),
Text(
'Login to explore more!',
style: Theme.of(context).textTheme.bodyLarge,
),
],
).padding(horizontal: 36, vertical: 16),
child:
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.emoji_people_rounded, size: 40),
const Gap(8),
Text(
'Welcome to\nthe Solar Network',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
).bold(),
const Gap(2),
Text(
'Login to explore more!',
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
const Gap(4),
TextButton.icon(
onPressed: () {
showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => LoginModal(),
);
},
icon: const Icon(Symbols.login),
label: Text('login').tr(),
),
],
).padding(horizontal: 36, vertical: 16).center(),
),
],
).padding(horizontal: 12);
@@ -521,6 +542,11 @@ class ExploreScreen extends HookConsumerWidget {
child: PostFeaturedList(),
),
),
SliverToBoxAdapter(
child: FriendsOverviewWidget(
padding: const EdgeInsets.only(bottom: 8),
),
),
if (notificationCount.value != null &&
notificationCount.value! > 0)
SliverToBoxAdapter(

View File

@@ -13,6 +13,7 @@ import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/file_list_view.dart';
import 'package:island/widgets/usage_overview.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class FileListScreen extends HookConsumerWidget {
const FileListScreen({super.key});
@@ -196,7 +197,10 @@ class FileListScreen extends HookConsumerWidget {
builder:
(context) => SheetScaffold(
titleText: 'Usage Overview',
child: UsageOverviewWidget(usage: usage, quota: quota),
child: UsageOverviewWidget(
usage: usage,
quota: quota,
).padding(horizontal: 8, vertical: 16),
),
);
}

View File

@@ -8,7 +8,7 @@ import 'package:island/pods/network.dart';
import 'package:island/talker.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/models/poll.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:uuid/uuid.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -393,7 +393,7 @@ class PollEditorScreen extends ConsumerWidget {
showSnackBar(isUpdate ? 'pollUpdated'.tr() : 'pollCreated'.tr());
if (!context.mounted) return;
Navigator.of(context).maybePop(res.data);
Navigator.of(context).maybePop(SnPoll.fromJson(res.data));
} catch (e) {
showErrorAlert(e);
}
@@ -415,23 +415,46 @@ class PollEditorScreen extends ConsumerWidget {
});
}
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
title: Text(model.id == null ? 'pollCreate'.tr() : 'pollEdit'.tr()),
actions: [
if (kDebugMode)
IconButton(
tooltip: 'pollPreviewJsonDebug'.tr(),
onPressed: () {
_showDebugPreview(context, model);
},
icon: const Icon(Icons.visibility_outlined),
),
const Gap(8),
],
),
body: Column(
return SheetScaffold(
titleText: model.id == null ? 'pollCreate'.tr() : 'pollEdit'.tr(),
actions: [
if (kDebugMode)
IconButton(
tooltip: 'pollPreviewJsonDebug'.tr(),
onPressed: () {
_showDebugPreview(context, model);
},
icon: const Icon(Icons.visibility_outlined),
),
],
heightFactor: 0.9,
onClose: () async {
final confirmed = await showDialog<bool>(
context: context,
builder:
(ctx) => AlertDialog(
title: Text('confirm'.tr()),
content: Text('pollConfirmDiscard'.tr()),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: Text('cancel'.tr()),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(ctx).colorScheme.error,
),
child: Text('discard'.tr()),
),
],
),
);
if (confirmed == true) {
Navigator.of(context).pop();
}
},
child: Column(
children: [
Expanded(
child: ConstrainedBox(

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

@@ -73,10 +73,33 @@ class _PublisherBasisWidget extends StatelessWidget {
Positioned(
bottom: -24,
left: 16,
child: ProfilePictureWidget(
file: data.picture,
radius: 32,
borderRadius: data.type == 0 ? null : 12,
child: GestureDetector(
child: Badge(
isLabelVisible: data.type == 0,
padding: EdgeInsets.all(3),
label: Icon(
Symbols.launch,
size: 12,
color: Theme.of(context).colorScheme.onPrimary,
),
backgroundColor:
Theme.of(context).colorScheme.primary,
offset: Offset(0, 48),
child: ProfilePictureWidget(
file: data.picture,
radius: 32,
borderRadius: data.type == 0 ? null : 12,
),
),
onTap: () {
if (data.account?.name != null) {
Navigator.pop(context, true);
context.pushNamed(
'accountProfile',
pathParameters: {'name': data.account!.name},
);
}
},
),
),
],

View File

@@ -24,6 +24,7 @@ const kTabRoutes = [
'/realms',
'/account',
'/files',
'/thought',
'/creators',
'/developers',
];
@@ -90,6 +91,10 @@ class TabsScreen extends HookConsumerWidget {
label: 'files'.tr(),
icon: const Icon(Symbols.folder_rounded),
),
NavigationDestination(
label: 'aiThought'.tr(),
icon: const Icon(Symbols.bubble_chart),
),
NavigationDestination(
label: 'creatorHub'.tr(),
icon: const Icon(Symbols.design_services_rounded),

View File

@@ -6,6 +6,7 @@ import "package:riverpod_annotation/riverpod_annotation.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:island/models/thought.dart";
import "package:island/pods/network.dart";
import "package:island/widgets/alert.dart";
import "package:island/widgets/app_scaffold.dart";
import "package:island/widgets/response.dart";
import "package:island/widgets/thought/thought_sequence_list.dart";
@@ -14,6 +15,13 @@ import "package:material_symbols_icons/material_symbols_icons.dart";
part 'think.g.dart';
@riverpod
Future<bool> thoughtAvailableStaus(Ref ref) async {
final apiClient = ref.watch(apiClientProvider);
final response = await apiClient.get('/insight/billing/status');
return response.data['status'] == 'ok';
}
@riverpod
Future<List<SnThinkingThought>> thoughtSequence(
Ref ref,
@@ -28,6 +36,13 @@ Future<List<SnThinkingThought>> thoughtSequence(
.toList();
}
@riverpod
Future<ThoughtServicesResponse> thoughtServices(Ref ref) async {
final apiClient = ref.watch(apiClientProvider);
final response = await apiClient.get('/insight/thought/services');
return ThoughtServicesResponse.fromJson(response.data);
}
class ThoughtScreen extends HookConsumerWidget {
const ThoughtScreen({super.key});
@@ -39,6 +54,17 @@ class ThoughtScreen extends HookConsumerWidget {
? ref.watch(thoughtSequenceProvider(selectedSequenceId.value!))
: const AsyncValue<List<SnThinkingThought>>.data([]);
// Extract sequence ID from loaded thoughts for the chat interface
final sequenceIdFromThoughts = thoughts.maybeWhen(
data: (thoughts) {
if (thoughts.isNotEmpty && thoughts.first.sequenceId.isNotEmpty) {
return thoughts.first.sequenceId;
}
return null;
},
orElse: () => null,
);
// Get initial thoughts and topic from provider
final initialThoughts = thoughts.valueOrNull;
final initialTopic =
@@ -47,10 +73,13 @@ class ThoughtScreen extends HookConsumerWidget {
? initialThoughts.first.sequence!.topic
: 'aiThought'.tr();
final statusAsync = ref.watch(thoughtAvailableStausProvider);
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
title: Text(initialTopic ?? 'aiThought'.tr()),
leading: const PageBackButton(),
actions: [
IconButton(
icon: const Icon(Symbols.history),
@@ -67,27 +96,95 @@ class ThoughtScreen extends HookConsumerWidget {
);
},
),
// TODO: Need to access chat state for actions
const Gap(8),
],
),
body: thoughts.when(
data:
(thoughtList) => ThoughtChatInterface(
initialThoughts: thoughtList,
initialTopic: initialTopic,
),
loading: () => const Center(child: CircularProgressIndicator()),
error:
(error, _) => ResponseErrorWidget(
error: error,
onRetry:
() =>
selectedSequenceId.value != null
? ref.invalidate(
thoughtSequenceProvider(selectedSequenceId.value!),
)
: null,
body: statusAsync.maybeWhen(
data: (status) {
final retry = useMemoized(
() => () async {
showLoadingModal(context);
try {
await ref
.read(apiClientProvider)
.post('/insight/billing/retry');
showSnackBar('Retried billing process');
ref.invalidate(thoughtAvailableStausProvider);
} catch (e) {
showSnackBar('Failed to retry billing');
}
if (context.mounted) hideLoadingModal(context);
},
[context, ref],
);
final thoughtsBody = thoughts.when(
data:
(thoughtList) => ThoughtChatInterface(
initialThoughts: thoughtList,
initialSequenceId: sequenceIdFromThoughts,
initialTopic: initialTopic,
isDisabled: !status,
),
loading: () => const Center(child: CircularProgressIndicator()),
error:
(error, _) => ResponseErrorWidget(
error: error,
onRetry:
() =>
selectedSequenceId.value != null
? ref.invalidate(
thoughtSequenceProvider(
selectedSequenceId.value!,
),
)
: null,
),
);
return status
? thoughtsBody
: Column(
children: [
MaterialBanner(
leading: const Icon(Symbols.error),
content: const Text(
'You have unpaid orders. Please settle your payment to continue using the service.',
style: TextStyle(fontWeight: FontWeight.bold),
),
actions: [
TextButton(
onPressed: () {
retry();
},
child: Text('retry'.tr()),
),
],
),
Expanded(child: thoughtsBody),
],
);
},
orElse:
() => thoughts.when(
data:
(thoughtList) => ThoughtChatInterface(
initialThoughts: thoughtList,
initialTopic: initialTopic,
),
loading: () => const Center(child: CircularProgressIndicator()),
error:
(error, _) => ResponseErrorWidget(
error: error,
onRetry:
() =>
selectedSequenceId.value != null
? ref.invalidate(
thoughtSequenceProvider(
selectedSequenceId.value!,
),
)
: null,
),
),
),
);

View File

@@ -6,6 +6,25 @@ part of 'think.dart';
// RiverpodGenerator
// **************************************************************************
String _$thoughtAvailableStausHash() =>
r'720e04e56bff8c4d4ca6854ce997da4e7926c84c';
/// See also [thoughtAvailableStaus].
@ProviderFor(thoughtAvailableStaus)
final thoughtAvailableStausProvider = AutoDisposeFutureProvider<bool>.internal(
thoughtAvailableStaus,
name: r'thoughtAvailableStausProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$thoughtAvailableStausHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ThoughtAvailableStausRef = AutoDisposeFutureProviderRef<bool>;
String _$thoughtSequenceHash() => r'2a93c0a04f9a720ba474c02a36502940fb7f3ed7';
/// Copied from Dart SDK
@@ -152,5 +171,25 @@ class _ThoughtSequenceProviderElement
String get sequenceId => (origin as ThoughtSequenceProvider).sequenceId;
}
String _$thoughtServicesHash() => r'0ddeaec713ecfcdc9786c197f3d4cb41d36c26a5';
/// See also [thoughtServices].
@ProviderFor(thoughtServices)
final thoughtServicesProvider =
AutoDisposeFutureProvider<ThoughtServicesResponse>.internal(
thoughtServices,
name: r'thoughtServicesProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$thoughtServicesHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ThoughtServicesRef =
AutoDisposeFutureProviderRef<ThoughtServicesResponse>;
// 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

@@ -1,8 +1,13 @@
import "package:easy_localization/easy_localization.dart";
import "package:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:island/pods/network.dart";
import "package:island/screens/thought/think.dart";
import "package:island/widgets/alert.dart";
import "package:island/widgets/content/sheet.dart";
import "package:island/widgets/thought/thought_shared.dart";
import "package:material_symbols_icons/material_symbols_icons.dart";
class ThoughtSheet extends HookConsumerWidget {
final List<Map<String, dynamic>> attachedMessages;
@@ -39,11 +44,62 @@ class ThoughtSheet extends HookConsumerWidget {
attachedPosts: attachedPosts,
);
final statusAsync = ref.watch(thoughtAvailableStausProvider);
return SheetScaffold(
titleText: chatState.currentTopic.value ?? 'aiThought'.tr(),
child: ThoughtChatInterface(
attachedMessages: attachedMessages,
attachedPosts: attachedPosts,
child: statusAsync.maybeWhen(
data: (status) {
final retry = useMemoized(
() => () async {
showLoadingModal(context);
try {
await ref
.read(apiClientProvider)
.post('/insight/billing/retry');
showSnackBar('Retried billing process');
ref.invalidate(thoughtAvailableStausProvider);
} catch (e) {
showSnackBar('Failed to retry billing');
}
if (context.mounted) hideLoadingModal(context);
},
[context, ref],
);
final chatInterface = ThoughtChatInterface(
attachedMessages: attachedMessages,
attachedPosts: attachedPosts,
isDisabled: !status,
);
return status
? chatInterface
: Column(
children: [
MaterialBanner(
leading: const Icon(Symbols.error),
content: const Text(
'You have unpaid orders. Please settle your payment to continue using the service.',
style: TextStyle(fontWeight: FontWeight.bold),
),
actions: [
TextButton(
onPressed: () {
retry();
},
child: Text('retry'.tr()),
),
],
),
Expanded(child: chatInterface),
],
);
},
orElse:
() => ThoughtChatInterface(
attachedMessages: attachedMessages,
attachedPosts: attachedPosts,
),
),
);
}

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),
),
),
),
@@ -1863,6 +1854,6 @@ class WalletScreen extends HookConsumerWidget {
}
const Map<String, IconData> kCurrencyIconData = {
'points': Symbols.toll,
'golds': Symbols.attach_money,
'points': Symbols.bolt,
'golds': Symbols.diamond,
};

File diff suppressed because it is too large Load Diff

View File

@@ -16,3 +16,10 @@ class PostCreatedEvent {
class ChatRoomsRefreshEvent {
const ChatRoomsRefreshEvent();
}
/// Event fired when OIDC auth callback is received
class OidcAuthCallbackEvent {
final String challengeId;
const OidcAuthCallbackEvent(this.challengeId);
}

View File

@@ -5,5 +5,11 @@ String formatFileSize(int bytes) {
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
if (bytes < 1024 * 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
if (bytes < 1024 * 1024 * 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024 * 1024 * 1024)).toStringAsFixed(2)} TB';
}
return '${(bytes / (1024 * 1024 * 1024 * 1024 * 1024)).toStringAsFixed(2)} PB';
}

View File

@@ -0,0 +1,208 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:island/models/account.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/config.dart';
import 'package:island/widgets/account/account_pfc.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:gap/gap.dart';
part 'friends_overview.g.dart';
@riverpod
Future<List<SnFriendOverviewItem>> friendsOverview(Ref ref) async {
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get('/pass/friends/overview');
return (resp.data as List<dynamic>)
.map((e) => SnFriendOverviewItem.fromJson(e))
.toList();
}
class FriendsOverviewWidget extends HookConsumerWidget {
final bool hideWhenEmpty;
final EdgeInsetsGeometry? padding;
const FriendsOverviewWidget({
super.key,
this.hideWhenEmpty = false,
this.padding,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Set up periodic refresh every minute
useEffect(() {
final timer = Timer.periodic(const Duration(minutes: 1), (_) {
ref.invalidate(friendsOverviewProvider);
});
return () => timer.cancel(); // Cleanup when widget is disposed
}, const []);
final friendsOverviewAsync = ref.watch(friendsOverviewProvider);
return friendsOverviewAsync.when(
data: (friends) {
// Filter for online friends
final onlineFriends =
friends.where((friend) => friend.status.isOnline).toList();
if (onlineFriends.isEmpty && hideWhenEmpty) {
return const SizedBox.shrink();
}
final card = Card(
margin: EdgeInsets.zero,
child: Column(
children: [
Row(
spacing: 8,
children: [const Icon(Symbols.group), Text('Friends Online')],
).padding(horizontal: 16).height(48),
if (onlineFriends.isEmpty)
Container(
height: 80,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: const Center(
child: Text(
'No friends online',
style: TextStyle(fontSize: 14, color: Colors.grey),
),
),
)
else
SizedBox(
height: 80,
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 4),
scrollDirection: Axis.horizontal,
itemCount: onlineFriends.length,
itemBuilder: (context, index) {
final friend = onlineFriends[index];
return AccountPfcGestureDetector(
uname: friend.account.name,
child: _FriendTile(friend: friend),
);
},
),
),
],
),
);
Widget result = card;
if (padding != null) {
result = Padding(padding: padding!, child: result);
}
return result;
},
loading:
() => const SizedBox(
height: 80,
child: Center(child: CircularProgressIndicator()),
),
error: (error, stack) => const SizedBox.shrink(), // Hide on error
);
}
}
class _FriendTile extends ConsumerWidget {
final SnFriendOverviewItem friend;
const _FriendTile({required this.friend});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final serverUrl = ref.watch(serverUrlProvider);
String? uri;
if (friend.account.profile.picture != null) {
uri = '$serverUrl/drive/files/${friend.account.profile.picture!.id}';
}
return Container(
width: 60,
margin: const EdgeInsets.only(right: 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Avatar with online indicator
Stack(
children: [
CircleAvatar(
radius: 24,
backgroundImage:
uri != null ? CachedNetworkImageProvider(uri) : null,
child:
uri == null
? Text(
friend.account.nick.isNotEmpty
? friend.account.nick[0].toUpperCase()
: friend.account.name[0].toUpperCase(),
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.onPrimary,
),
)
: null,
),
// Online indicator - show play arrow if user has activities, otherwise green dot
Positioned(
bottom: 0,
right: 0,
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color:
friend.activities.isNotEmpty
? Colors.blue.withOpacity(0.8)
: Colors.green,
shape:
friend.activities.isNotEmpty
? BoxShape.rectangle
: BoxShape.circle,
borderRadius:
friend.activities.isNotEmpty
? BorderRadius.circular(4)
: null,
border: Border.all(
color: theme.colorScheme.surface,
width: 2,
),
),
child:
friend.activities.isNotEmpty
? Icon(
Symbols.play_arrow,
size: 10,
color: Colors.white,
)
: null,
),
),
],
),
const Gap(4),
// Name (truncated if too long)
Text(
friend.account.nick.isNotEmpty
? friend.account.nick
: friend.account.name,
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
).center();
}
}

View File

@@ -0,0 +1,30 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'friends_overview.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$friendsOverviewHash() => r'5ef86c6849804c97abd3df094f120c7dd5e938db';
/// See also [friendsOverview].
@ProviderFor(friendsOverview)
final friendsOverviewProvider =
AutoDisposeFutureProvider<List<SnFriendOverviewItem>>.internal(
friendsOverview,
name: r'friendsOverviewProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$friendsOverviewHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef FriendsOverviewRef =
AutoDisposeFutureProviderRef<List<SnFriendOverviewItem>>;
// 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

@@ -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

@@ -8,6 +8,7 @@ import 'package:island/pods/activity/activity_rpc.dart';
import 'package:island/pods/websocket.dart';
import 'package:island/route.dart';
import 'package:island/screens/tray_manager.dart';
import 'package:island/services/event_bus.dart';
import 'package:island/services/notify.dart';
import 'package:island/services/sharing_intent.dart';
import 'package:island/services/update_service.dart';
@@ -115,8 +116,21 @@ class _AppWrapperState extends ConsumerState<AppWrapper>
}
void _handleDeepLink(Uri uri, WidgetRef ref) {
final router = ref.read(routerProvider);
String path = '/${uri.host}${uri.path}';
// Special handling for OIDC auth callback
if (path == '/auth/callback' &&
uri.queryParameters.containsKey('challenge')) {
final challenge = uri.queryParameters['challenge']!;
eventBus.fire(OidcAuthCallbackEvent(challenge));
if (!kIsWeb &&
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
windowManager.show();
}
return;
}
final router = ref.read(routerProvider);
if (uri.queryParameters.isNotEmpty) {
path =
Uri.parse(

View File

@@ -11,7 +11,9 @@ import "package:island/models/account.dart";
import "package:island/models/autocomplete_response.dart";
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";
@@ -26,6 +28,169 @@ import "package:styled_widget/styled_widget.dart";
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;
final selection = controller.selection;
final start = selection.start >= 0 ? selection.start : text.length;
final end = selection.end >= 0 ? selection.end : text.length;
final newText = text.replaceRange(start, end, placeholder);
controller.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(offset: start + placeholder.length),
);
}
const kInputDrawerExpandedHeight = 180.0;
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
Widget build(BuildContext context) {
return Container(
key: const ValueKey('expanded'),
decoration: BoxDecoration(
border: Border.all(width: 1, color: Theme.of(context).dividerColor),
borderRadius: const BorderRadius.all(Radius.circular(32)),
),
margin: const EdgeInsets.only(top: 8, bottom: 3),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(32)),
child: DefaultTabController(
length: 2,
child: Column(
children: [
TabBar(
splashBorderRadius: const BorderRadius.all(Radius.circular(40)),
tabs: [Tab(text: 'Features'), Tab(text: 'Stickers')],
),
SizedBox(
height: kInputDrawerExpandedHeight,
child: TabBarView(
children: [
SizedBox(
height:
kInputDrawerExpandedHeight -
48, // subtract tab bar height approx
child: GridView(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 120,
childAspectRatio: 1, // 1:1 aspect ratio
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
children: [
InkWell(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
onTap: () async {
final poll = await showModalBottomSheet<SnPoll>(
context: context,
isScrollControlled: true,
builder: (context) => const ComposePollSheet(),
);
if (poll != null) {
onPollSelected(poll);
}
},
child: Card(
margin: EdgeInsets.zero,
color:
Theme.of(
context,
).colorScheme.surfaceContainer,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Symbols.poll),
const Gap(4),
Text(
'Poll',
style:
Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
InkWell(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
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:
Theme.of(
context,
).colorScheme.surfaceContainer,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Symbols.currency_exchange),
const Gap(4),
Text(
'fund'.tr(),
style:
Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
],
),
),
StickerPickerEmbedded(
height: kInputDrawerExpandedHeight,
onPick:
(placeholder) => _insertPlaceholder(
messageController,
placeholder,
),
),
],
),
),
],
),
),
),
);
}
}
class ChatInput extends HookConsumerWidget {
final TextEditingController messageController;
@@ -45,6 +210,10 @@ class ChatInput extends HookConsumerWidget {
final Function(int, int) onMoveAttachment;
final Function(List<UniversalFile>) onAttachmentsChanged;
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,
@@ -65,12 +234,17 @@ class ChatInput extends HookConsumerWidget {
required this.onMoveAttachment,
required this.onAttachmentsChanged,
required this.attachmentProgress,
this.selectedPoll,
required this.onPollSelected,
this.selectedFund,
required this.onFundSelected,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final inputFocusNode = useFocusNode();
final chatSubscribe = ref.watch(chatSubscribeNotifierProvider(chatRoom.id));
final isExpanded = useState(false);
void send() {
inputFocusNode.requestFocus();
@@ -281,6 +455,195 @@ class ChatInput extends HookConsumerWidget {
key: ValueKey('no-attachments'),
),
),
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:
selectedPoll != null
? Container(
key: const ValueKey('selected-poll'),
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.how_to_vote,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
const Gap(8),
Expanded(
child: Text(
selectedPoll!.title ?? 'Poll',
style: Theme.of(context).textTheme.bodySmall!
.copyWith(fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
SizedBox(
width: 24,
height: 24,
child: IconButton(
padding: EdgeInsets.zero,
icon: const Icon(Icons.close, size: 18),
onPressed: () => onPollSelected(null),
tooltip: 'clear'.tr(),
),
),
],
),
)
: const SizedBox.shrink(
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,
@@ -426,43 +789,28 @@ class ChatInput extends HookConsumerWidget {
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'stickers'.tr(),
icon: const Icon(Symbols.add_reaction),
tooltip:
isExpanded.value ? 'collapse'.tr() : 'more'.tr(),
icon: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder:
(child, animation) => FadeTransition(
opacity: animation,
child: child,
),
child:
isExpanded.value
? const Icon(
Symbols.close,
key: ValueKey('close'),
)
: const Icon(
Symbols.add,
key: ValueKey('add'),
),
),
onPressed: () {
final size = MediaQuery.of(context).size;
showStickerPickerPopover(
context,
Offset(
20,
size.height -
480 -
MediaQuery.of(context).padding.bottom,
),
onPick: (placeholder) {
// Insert placeholder at current cursor position
final text = messageController.text;
final selection = messageController.selection;
final start =
selection.start >= 0
? selection.start
: text.length;
final end =
selection.end >= 0
? selection.end
: text.length;
final newText = text.replaceRange(
start,
end,
placeholder,
);
messageController.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(
offset: start + placeholder.length,
),
);
},
);
isExpanded.value = !isExpanded.value;
},
),
UploadMenu(
@@ -659,6 +1007,37 @@ class ChatInput extends HookConsumerWidget {
),
],
),
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.1),
end: Offset.zero,
).animate(animation),
child: FadeTransition(
opacity: animation,
child: SizeTransition(
sizeFactor: animation,
axisAlignment: -1.0,
child: child,
),
),
);
},
child:
isExpanded.value
? _ExpandedSection(
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

@@ -215,7 +215,6 @@ class CloudFileList extends HookConsumerWidget {
}
if (files.length == 1) {
final isImage = files.first.mimeType?.startsWith('image') ?? false;
final isAudio = files.first.mimeType?.startsWith('audio') ?? false;
final widgetItem = ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: _CloudFileListEntry(
@@ -243,11 +242,7 @@ class CloudFileList extends HookConsumerWidget {
minWidth: minWidth ?? 0,
maxWidth: files.length == 1 ? maxWidth : double.infinity,
),
height: isAudio ? 120 : null,
child:
isAudio
? widgetItem
: IntrinsicWidth(child: IntrinsicHeight(child: widgetItem)),
child: IntrinsicWidth(child: IntrinsicHeight(child: widgetItem)),
);
}
@@ -413,6 +408,8 @@ class _CloudFileListEntry extends HookConsumerWidget {
final lockedByMature = file.sensitiveMarks.isNotEmpty && !showMature.value;
final meta = file.fileMeta is Map ? file.fileMeta as Map : const {};
final ratio = meta['ratio'] as num?;
final fit = BoxFit.cover;
Widget bg = const SizedBox.shrink();
@@ -484,7 +481,7 @@ class _CloudFileListEntry extends HookConsumerWidget {
onTap?.call();
}
},
child: content,
child: AspectRatio(aspectRatio: ratio?.toDouble() ?? 1, child: content),
);
}
}

View File

@@ -1,12 +1,10 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:island/models/embed.dart';
import 'package:island/services/responsive.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:styled_widget/styled_widget.dart';
import 'package:island/widgets/wallet/fund_envelope.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
class EmbedListWidget extends StatelessWidget {
final List<dynamic> embeds;
@@ -26,46 +24,108 @@ class EmbedListWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final normalizedEmbeds =
embeds
.map((e) => convertMapKeysToSnakeCase(e as Map<String, dynamic>))
.toList();
final linkEmbeds =
normalizedEmbeds.where((e) => e['type'] == 'link').toList();
final otherEmbeds =
normalizedEmbeds.where((e) => e['type'] != 'link').toList();
return Column(
children:
embeds
.map((embedData) => convertMapKeysToSnakeCase(embedData))
.map(
(embedData) => switch (embedData['type']) {
'link' => EmbedLinkWidget(
link: SnScrappedLink.fromJson(embedData),
maxWidth:
maxWidth ??
math.min(
MediaQuery.of(context).size.width,
kWideScreenWidth,
),
margin: EdgeInsets.only(
top: 4,
bottom: 4,
left: renderingPadding.horizontal,
right: renderingPadding.horizontal,
),
children: [
if (linkEmbeds.isNotEmpty)
Container(
margin: EdgeInsets.only(
top: 8,
left: renderingPadding.horizontal,
right: renderingPadding.horizontal,
),
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(8),
),
child: Theme(
data: Theme.of(
context,
).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
initiallyExpanded: true,
dense: true,
leading: const Icon(Symbols.link),
title: Text('${linkEmbeds.length} links'),
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(8),
child:
linkEmbeds.length == 1
? EmbedLinkWidget(
link: SnScrappedLink.fromJson(linkEmbeds.first),
)
: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children:
linkEmbeds
.map(
(embedData) => EmbedLinkWidget(
link: SnScrappedLink.fromJson(
embedData,
),
maxWidth:
200, // Fixed width for horizontal scroll
margin: const EdgeInsets.symmetric(
horizontal: 4,
),
),
)
.toList(),
),
),
),
'poll' => Card(
],
),
),
),
...otherEmbeds.map(
(embedData) => switch (embedData['type']) {
'poll' => Card(
margin: EdgeInsets.symmetric(
horizontal: renderingPadding.horizontal,
vertical: 8,
),
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,
),
child:
embedData['id'] == null
? const Text('Poll was unavailable...')
: PollSubmit(
pollId: embedData['id'],
onSubmit: (_) {},
isReadonly: !isInteractive,
isInitiallyExpanded: isFullPost,
).padding(horizontal: 16, vertical: 12),
),
_ => Text('Unable show embed: ${embedData['type']}'),
},
)
.toList(),
_ => 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

@@ -4,11 +4,16 @@ import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/screens/auth/create_account_modal.dart';
import 'package:island/screens/auth/login_modal.dart';
import 'package:island/screens/notification.dart';
import 'package:island/services/event_bus.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/account/account_picker.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/post/compose_sheet.dart';
import 'package:island/screens/chat/chat_form.dart';
import 'package:material_symbols_icons/symbols.dart';
enum FabMenuType { main, compose, chat, realm }
@@ -50,16 +55,46 @@ class FabMenu extends HookConsumerWidget {
late final bool useRootNavigator;
late final Widget menuContent;
final commonEntires = <Widget>[
final unauthorizedEntires = <Widget>[
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.bubble_chart),
title: Text('aiThoughtTitle').tr(),
leading: const Icon(Symbols.login),
title: Text('login').tr(),
onTap: () async {
Navigator.of(context).pop();
context.pushNamed('thought');
showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => LoginModal(),
);
},
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.person_add),
title: Text('createAccount').tr(),
onTap: () async {
showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => CreateAccountModal(),
);
},
),
];
final authorizedEntires = <Widget>[
if (!isWideScreen(context))
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.bubble_chart),
title: Text('aiThoughtTitle').tr(),
onTap: () async {
Navigator.of(context).pop();
context.goNamed('thought');
},
),
Consumer(
builder: (context, ref, _) {
final notificationCount = ref.watch(
@@ -87,6 +122,10 @@ class FabMenu extends HookConsumerWidget {
),
];
final userInfo = ref.watch(userInfoProvider);
final authorized = userInfo.value != null;
final commonEntires = authorized ? authorizedEntires : unauthorizedEntires;
switch (fabType) {
case FabMenuType.compose:
icon = Symbols.create;
@@ -95,25 +134,28 @@ class FabMenu extends HookConsumerWidget {
mainAxisSize: MainAxisSize.min,
children: [
const Gap(24),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.post_add_rounded),
title: Text('postCompose').tr(),
onTap: () async {
Navigator.of(context).pop();
await PostComposeSheet.show(context);
},
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.article),
title: Text('articleCompose').tr(),
onTap: () async {
Navigator.of(context).pop();
GoRouter.of(context).pushNamed('articleCompose');
},
),
const Divider(),
if (authorized)
...([
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.post_add_rounded),
title: Text('postCompose').tr(),
onTap: () async {
Navigator.of(context).pop();
await PostComposeSheet.show(context);
},
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.article),
title: Text('articleCompose').tr(),
onTap: () async {
Navigator.of(context).pop();
GoRouter.of(context).pushNamed('articleCompose');
},
),
const Divider(),
]),
...commonEntires,
Gap(MediaQuery.of(context).padding.bottom + 16),
],
@@ -128,29 +170,35 @@ class FabMenu extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Gap(24),
ListTile(
title: const Text('createChatRoom').tr(),
leading: const Icon(Symbols.add),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () {
Navigator.pop(context);
context.pushNamed('chatNew').then((value) {
if (value != null) {
eventBus.fire(const ChatRoomsRefreshEvent());
}
});
},
),
ListTile(
title: const Text('createDirectMessage').tr(),
leading: const Icon(Symbols.person),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () {
Navigator.pop(context);
_createDirectMessage(context, ref);
},
),
const Divider(),
if (authorized)
...([
ListTile(
title: const Text('createChatRoom').tr(),
leading: const Icon(Symbols.add),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () {
showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => const EditChatScreen(),
).then((value) {
if (value != null) {
eventBus.fire(const ChatRoomsRefreshEvent());
}
});
},
),
ListTile(
title: const Text('createDirectMessage').tr(),
leading: const Icon(Symbols.person),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () {
_createDirectMessage(context, ref);
},
),
const Divider(),
]),
...commonEntires,
Gap(MediaQuery.of(context).padding.bottom + 16),
],
@@ -164,21 +212,24 @@ class FabMenu extends HookConsumerWidget {
mainAxisSize: MainAxisSize.min,
children: [
const Gap(24),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.group_add),
title: Text('createRealm').tr(),
onTap: () {
Navigator.of(context).pop();
context.pushNamed('realmNew').then((value) {
if (value != null) {
// Fire realm refresh event if needed
// eventBus.fire(const RealmsRefreshEvent());
}
});
},
),
const Divider(),
if (authorized)
...([
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.group_add),
title: Text('createRealm').tr(),
onTap: () {
Navigator.of(context).pop();
context.pushNamed('realmNew').then((value) {
if (value != null) {
// Fire realm refresh event if needed
// eventBus.fire(const RealmsRefreshEvent());
}
});
},
),
const Divider(),
]),
...commonEntires,
Gap(MediaQuery.of(context).padding.bottom + 16),
],

View File

@@ -62,9 +62,19 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
bool? _yesNoSelected;
int? _ratingSelected; // 1..5
/// Flag to track if user has edited the current question to prevent provider rebuilds from resetting state
bool _userHasEdited = false;
/// Listener for text controller to mark as edited when user types
late final VoidCallback _controllerListener;
@override
void initState() {
super.initState();
_controllerListener = () {
_userHasEdited = true;
};
_textController.addListener(_controllerListener);
_answers = Map<String, dynamic>.from(widget.initialAnswers ?? {});
// Set initial collapse state based on the parameter
_isCollapsed = !widget.isInitiallyExpanded;
@@ -75,6 +85,11 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
_isModifying = false;
}
}
// Load initial answers into local state
if (_questions != null) {
_loadCurrentIntoLocalState();
_userHasEdited = false;
}
}
void _initializeFromPollData(SnPollWithStats poll) {
@@ -101,6 +116,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
@override
void dispose() {
_textController.removeListener(_controllerListener);
_textController.dispose();
super.dispose();
}
@@ -111,30 +127,35 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
final q = _current;
final saved = _answers[q.id];
_singleChoiceSelected = null;
_multiChoiceSelected.clear();
_yesNoSelected = null;
_ratingSelected = null;
_textController.text = '';
if (!_userHasEdited) {
_singleChoiceSelected = null;
_multiChoiceSelected.clear();
_yesNoSelected = null;
_ratingSelected = null;
switch (q.type) {
case SnPollQuestionType.singleChoice:
if (saved is String) _singleChoiceSelected = saved;
break;
case SnPollQuestionType.multipleChoice:
if (saved is List) {
_multiChoiceSelected.addAll(saved.whereType<String>());
}
break;
case SnPollQuestionType.yesNo:
if (saved is bool) _yesNoSelected = saved;
break;
case SnPollQuestionType.rating:
if (saved is int) _ratingSelected = saved;
break;
case SnPollQuestionType.freeText:
if (saved is String) _textController.text = saved;
break;
switch (q.type) {
case SnPollQuestionType.singleChoice:
if (saved is String) _singleChoiceSelected = saved;
break;
case SnPollQuestionType.multipleChoice:
if (saved is List) {
_multiChoiceSelected.addAll(saved.whereType<String>());
}
break;
case SnPollQuestionType.yesNo:
if (saved is bool) _yesNoSelected = saved;
break;
case SnPollQuestionType.rating:
if (saved is int) _ratingSelected = saved;
break;
case SnPollQuestionType.freeText:
if (saved is String) {
_textController.removeListener(_controllerListener);
_textController.text = saved;
_textController.addListener(_controllerListener);
}
break;
}
}
}
@@ -214,6 +235,9 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
data: {'answer': _answers},
);
// Refresh poll data to show submitted answer
ref.invalidate(pollWithStatsProvider(widget.pollId));
// Only call onSubmit after server accepts
widget.onSubmit(Map<String, dynamic>.unmodifiable(_answers));
@@ -236,6 +260,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
if (_index < _questions!.length - 1) {
setState(() {
_index++;
_userHasEdited = false;
_loadCurrentIntoLocalState();
});
} else {
@@ -250,6 +275,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
if (_index > 0) {
setState(() {
_index--;
_userHasEdited = false;
_loadCurrentIntoLocalState();
});
} else {
@@ -342,7 +368,11 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
RadioListTile<String>(
value: opt.id,
groupValue: _singleChoiceSelected,
onChanged: (val) => setState(() => _singleChoiceSelected = val),
onChanged:
(val) => setState(() {
_singleChoiceSelected = val;
_userHasEdited = true;
}),
title: Text(opt.label),
subtitle: opt.description != null ? Text(opt.description!) : null,
),
@@ -364,6 +394,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
} else {
_multiChoiceSelected.remove(opt.id);
}
_userHasEdited = true;
});
},
title: Text(opt.label),
@@ -386,6 +417,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
onSelectionChanged: (sel) {
setState(() {
_yesNoSelected = sel.isEmpty ? null : sel.first;
_userHasEdited = true;
});
},
multiSelectionEnabled: false,
@@ -411,6 +443,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
onPressed: () {
setState(() {
_ratingSelected = value;
_userHasEdited = true;
});
},
);
@@ -422,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)),
),
),
);
}
@@ -441,6 +478,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
setState(() {
_isModifying = true;
_index = 0; // Reset to first question for modification
_userHasEdited = false;
_loadCurrentIntoLocalState();
});
},
@@ -487,32 +525,6 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (poll.title != null || poll.description != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (poll.title?.isNotEmpty ?? false)
Text(
poll.title!,
style: Theme.of(context).textTheme.titleLarge,
),
if (poll.description?.isNotEmpty ?? false)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
poll.description!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(
context,
).textTheme.bodyMedium?.color?.withOpacity(0.7),
),
),
),
],
),
),
for (final q in _questions!)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),

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,11 +2,12 @@ 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:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/poll.dart';
import 'package:island/models/publisher.dart';
import 'package:island/screens/creators/poll/poll_list.dart';
import 'package:island/screens/poll/poll_editor.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
@@ -15,14 +16,13 @@ import 'package:island/widgets/post/publishers_modal.dart';
/// Bottom sheet for selecting or creating a poll. Returns SnPoll via Navigator.pop.
class ComposePollSheet extends HookConsumerWidget {
/// Optional publisher name to filter polls and prefill creation.
final String? pubName;
final SnPublisher? pub;
const ComposePollSheet({super.key, this.pubName});
const ComposePollSheet({super.key, this.pub});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedPublisher = useState<String?>(pubName);
final selectedPublisher = useState<SnPublisher?>(pub);
final isPushing = useState(false);
final errorText = useState<String?>(null);
@@ -46,10 +46,11 @@ class ComposePollSheet extends HookConsumerWidget {
children: [
// Link/Select existing poll list
PagingHelperView(
provider: pollListNotifierProvider(pubName),
futureRefreshable: pollListNotifierProvider(pubName).future,
provider: pollListNotifierProvider(pub?.name),
futureRefreshable:
pollListNotifierProvider(pub?.name).future,
notifierRefreshable:
pollListNotifierProvider(pubName).notifier,
pollListNotifierProvider(pub?.name).notifier,
contentBuilder:
(data, widgetCount, endItemView) => ListView.builder(
padding: EdgeInsets.zero,
@@ -66,7 +67,9 @@ class ComposePollSheet extends HookConsumerWidget {
title: Text(poll.title ?? 'untitled'.tr()),
subtitle: _buildPollSubtitle(poll),
onTap: () {
Navigator.of(context).pop(poll);
Navigator.of(
context,
).pop(SnPoll.fromPollWithStats(poll));
},
);
},
@@ -81,38 +84,48 @@ class ComposePollSheet extends HookConsumerWidget {
Text(
'pollCreateNewHint',
).tr().fontSize(13).opacity(0.85).padding(bottom: 8),
ListTile(
title: Text(
selectedPublisher.value == null
? 'publisher'.tr()
: '@${selectedPublisher.value}',
),
subtitle: Text(
selectedPublisher.value == null
? 'publisherHint'.tr()
: 'selected'.tr(),
),
leading: const Icon(Symbols.account_circle),
trailing: const Icon(Symbols.chevron_right),
onTap: () async {
final picked =
await showModalBottomSheet<SnPublisher>(
context: context,
isScrollControlled: true,
builder: (context) => const PublisherModal(),
);
if (picked != null) {
try {
final name = picked.name;
if (name.isNotEmpty) {
selectedPublisher.value = name;
Card(
child: ListTile(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
),
title: Text(
selectedPublisher.value == null
? 'publisher'.tr()
: selectedPublisher.value!.nick,
),
subtitle: Text(
selectedPublisher.value == null
? 'publisherHint'.tr()
: '@${selectedPublisher.value?.name}',
),
leading:
selectedPublisher.value == null
? const Icon(Symbols.account_circle)
: ProfilePictureWidget(
file: selectedPublisher.value?.picture,
),
trailing: const Icon(Symbols.chevron_right),
onTap: () async {
final picked =
await showModalBottomSheet<SnPublisher>(
context: context,
isScrollControlled: true,
builder:
(context) => const PublisherModal(),
);
if (picked != null) {
try {
selectedPublisher.value = picked;
errorText.value = null;
} catch (_) {
// ignore
}
} catch (_) {
// ignore
}
}
},
},
),
),
if (errorText.value != null)
Padding(
@@ -146,8 +159,7 @@ class ComposePollSheet extends HookConsumerWidget {
isPushing.value
? null
: () async {
final pub = selectedPublisher.value ?? '';
if (pub.isEmpty) {
if (pub == null) {
errorText.value =
'publisherCannotBeEmpty'.tr();
return;
@@ -155,12 +167,18 @@ class ComposePollSheet extends HookConsumerWidget {
errorText.value = null;
isPushing.value = true;
// Push to creatorPollNew route and await result
final result = await GoRouter.of(
context,
).push<SnPoll>(
'/creators/$pub/polls/new',
);
// Show modal bottom sheet with poll editor and await result
final result =
await showModalBottomSheet<SnPoll>(
context: context,
isScrollControlled: true,
isDismissible: false,
enableDrag: false,
builder:
(context) => PollEditorScreen(
initialPublisher: pub?.name,
),
);
if (result == null) {
isPushing.value = false;

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,
);
}
@@ -611,22 +635,47 @@ class ComposeLogic {
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => const ComposePollSheet(),
builder: (context) => ComposePollSheet(pub: state.currentPublisher.value),
);
if (poll == null) return;
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

@@ -604,6 +604,7 @@ class PostItem extends HookConsumerWidget {
showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (BuildContext context) {
return PostReactionSheet(
reactionsCount: item.reactionsCount,
@@ -712,6 +713,7 @@ class PostReactionList extends HookConsumerWidget {
: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (BuildContext context) {
return PostReactionSheet(
reactionsCount: reactions,

View File

@@ -11,6 +11,7 @@ import 'package:island/pods/network.dart';
import 'package:island/services/time.dart';
import 'package:island/widgets/account/account_pfc.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
@@ -111,75 +112,54 @@ class PostReactionSheet extends StatelessWidget {
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(
top: 16,
left: 20,
right: 16,
bottom: 12,
child: SheetScaffold(
heightFactor: 0.75,
titleText: 'reactions'.plural(
reactionsCount.isNotEmpty
? reactionsCount.values.reduce((a, b) => a + b)
: 0,
),
child: Column(
children: [
TabBar(
tabs: [Tab(text: 'overview'.tr()), Tab(text: 'custom'.tr())],
),
child: Row(
children: [
Text(
'reactions'.plural(
reactionsCount.isNotEmpty
? reactionsCount.values.reduce((a, b) => a + b)
: 0,
Expanded(
child: TabBarView(
children: [
ListView(
children: [
_buildCustomReactionSection(context),
_buildReactionSection(
context,
Symbols.mood,
'reactionPositive'.tr(),
0,
),
_buildReactionSection(
context,
Symbols.sentiment_neutral,
'reactionNeutral'.tr(),
1,
),
_buildReactionSection(
context,
Symbols.mood_bad,
'reactionNegative'.tr(),
2,
),
const Gap(8),
],
),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
CustomReactionForm(
postId: postId,
onReact: (s, a) => onReact(s.replaceAll(':', ''), a),
),
),
const Spacer(),
IconButton(
icon: const Icon(Symbols.close),
onPressed: () => Navigator.pop(context),
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
),
],
],
),
),
),
const Divider(height: 1),
TabBar(tabs: [Tab(text: 'overview'.tr()), Tab(text: 'custom'.tr())]),
const Divider(height: 1),
Expanded(
child: TabBarView(
children: [
ListView(
children: [
_buildCustomReactionSection(context),
_buildReactionSection(
context,
Symbols.mood,
'reactionPositive'.tr(),
0,
),
_buildReactionSection(
context,
Symbols.sentiment_neutral,
'reactionNeutral'.tr(),
1,
),
_buildReactionSection(
context,
Symbols.mood_bad,
'reactionNegative'.tr(),
2,
),
const Gap(8),
],
),
CustomReactionForm(
postId: postId,
onReact: (s, a) => onReact(s.replaceAll(':', ''), a),
),
],
),
),
],
],
),
),
);
}
@@ -599,12 +579,50 @@ class CustomReactionForm extends HookConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'customReaction'.tr(),
style: Theme.of(context).textTheme.titleLarge,
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primaryContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Symbols.info,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
const Gap(8),
Text(
'customReaction'.tr(),
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
const Gap(8),
Text(
'customReactionHint'.tr(),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
const Gap(24),
const Gap(16),
TextField(
readOnly: true,
decoration: InputDecoration(
labelText: 'stickerPlaceholder'.tr(),
hintText: 'prefix+slug',
@@ -615,7 +633,10 @@ class CustomReactionForm extends HookConsumerWidget {
onTapDown: (details) async {
await showStickerPickerPopover(
context,
details.globalPosition.translate(-300, -280),
Offset(
(MediaQuery.sizeOf(context).width - 500) / 2,
MediaQuery.sizeOf(context).height - 500,
),
alignment: Alignment.topLeft,
onPick: (placeholder) {
// Remove the surrounding : from the placeholder

View File

@@ -240,9 +240,9 @@ class _StickersGrid extends StatelessWidget {
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 96,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
maxCrossAxisExtent: 56,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
itemCount: stickers.length,
itemBuilder: (context, index) {
@@ -276,6 +276,138 @@ class _StickersGrid extends StatelessWidget {
}
}
/// Embedded Sticker Picker variant
/// No background card, no title header, suitable for embedding in other UI
class StickerPickerEmbedded extends HookConsumerWidget {
final double? height;
final void Function(String placeholder) onPick;
const StickerPickerEmbedded({super.key, required this.onPick, this.height});
@override
Widget build(BuildContext context, WidgetRef ref) {
final packsAsync = ref.watch(myStickerPacksProvider);
return packsAsync.when(
data: (packs) {
if (packs.isEmpty) {
return _EmptyState(
onRefresh: () async {
ref.invalidate(myStickerPacksProvider);
},
);
}
return _EmbeddedPackSwitcher(
packs: packs,
onPick: (pack, sticker) {
final placeholder = ':${pack.prefix}+${sticker.slug}:';
HapticFeedback.selectionClick();
onPick(placeholder);
},
onRefresh: () async {
ref.invalidate(myStickerPacksProvider);
},
);
},
loading:
() => SizedBox(
width: 320,
height: height ?? 320,
child: const Center(child: CircularProgressIndicator()),
),
error:
(err, _) => SizedBox(
width: 360,
height: height ?? 200,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Symbols.error, size: 28),
const Gap(8),
Text('Error: $err', textAlign: TextAlign.center),
const Gap(12),
FilledButton.icon(
onPressed: () => ref.invalidate(myStickerPacksProvider),
icon: const Icon(Symbols.refresh),
label: Text('retry').tr(),
),
],
).padding(all: 16),
),
);
}
}
class _EmbeddedPackSwitcher extends StatefulWidget {
final List<SnStickerPack> packs;
final void Function(SnStickerPack pack, SnSticker sticker) onPick;
final Future<void> Function() onRefresh;
const _EmbeddedPackSwitcher({
required this.packs,
required this.onPick,
required this.onRefresh,
});
@override
State<_EmbeddedPackSwitcher> createState() => _EmbeddedPackSwitcherState();
}
class _EmbeddedPackSwitcherState extends State<_EmbeddedPackSwitcher> {
int _index = 0;
@override
Widget build(BuildContext context) {
final packs = widget.packs;
_index = _index.clamp(0, packs.length - 1);
final selectedPack = packs[_index];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Gap(12),
// Vertical, scrollable packs rail like common emoji pickers
SizedBox(
height: 32,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 12),
scrollDirection: Axis.horizontal,
itemCount: packs.length,
separatorBuilder: (_, _) => const Gap(4),
itemBuilder: (context, i) {
final selected = _index == i;
return Tooltip(
message: packs[i].name,
child: FilterChip(
label: Text(packs[i].name, overflow: TextOverflow.ellipsis),
selected: selected,
onSelected: (_) {
setState(() => _index = i);
HapticFeedback.selectionClick();
},
),
);
},
),
),
// Content
Expanded(
child: ExtendedRefreshIndicator(
onRefresh: widget.onRefresh,
child: _StickersGrid(
pack: selectedPack,
onPick: (sticker) => widget.onPick(selectedPack, sticker),
).padding(horizontal: 2),
),
),
],
);
}
}
/// Helper to show sticker picker as an anchored popover near the trigger.
/// Provide the button's BuildContext (typically from the onPressed closure).
/// Fallbacks to dialog if overlay cannot be found (e.g., during tests).

View File

@@ -2,161 +2,215 @@ import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:island/models/thought.dart';
import 'package:styled_widget/styled_widget.dart';
class FunctionCallsSection extends StatefulWidget {
class FunctionCallsSection extends HookWidget {
const FunctionCallsSection({
super.key,
required this.isFinish,
required this.isStreaming,
required this.streamingFunctionCalls,
this.thought,
this.callData,
this.resultData,
});
final bool isFinish;
final bool isStreaming;
final List<String> streamingFunctionCalls;
final SnThinkingThought? thought;
@override
State<FunctionCallsSection> createState() => _FunctionCallsSectionState();
}
class _FunctionCallsSectionState extends State<FunctionCallsSection> {
bool _isExpanded = false;
bool get _hasFunctionCalls {
if (widget.isStreaming) {
return widget.streamingFunctionCalls.isNotEmpty;
} else {
return widget.thought!.parts.isNotEmpty &&
widget.thought!.parts.any(
(part) => part.type == ThinkingMessagePartType.functionCall,
);
}
}
final String? callData;
final String? resultData;
@override
Widget build(BuildContext context) {
if (!_hasFunctionCalls) {
return const SizedBox.shrink();
String functionCallName;
if (callData != null) {
final parsed = jsonDecode(callData!) as Map;
functionCallName = (parsed['name'] as String?) ?? 'unknown'.tr();
} else {
functionCallName = 'unknown'.tr();
}
if (functionCallName.isEmpty) functionCallName = 'unknown'.tr();
final showSpinner = isStreaming && !isFinish;
final isExpanded = useState(false);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.tertiaryContainer,
ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 8),
minTileHeight: 24,
backgroundColor: Theme.of(context).colorScheme.tertiaryContainer,
collapsedBackgroundColor:
Theme.of(context).colorScheme.tertiaryContainer,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
collapsedShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
trailing: SizedBox(
width: 30, // Specify desired width
height: 30, // Specify desired height
child: Icon(
isExpanded.value
? Icons.keyboard_arrow_down
: Icons.keyboard_arrow_up,
size: 16,
color:
isExpanded.value
? Theme.of(context).colorScheme.tertiary
: Theme.of(context).colorScheme.tertiaryFixedDim,
),
),
showTrailingIcon: !showSpinner,
title: Row(
spacing: 8,
children: [
InkWell(
onTap: () => setState(() => _isExpanded = !_isExpanded),
child: Row(
children: [
Icon(
Symbols.code,
size: 14,
color: Theme.of(context).colorScheme.tertiary,
),
const Gap(4),
Expanded(
child: Text(
'thoughtFunctionCall'.tr(),
Icon(
Symbols.hardware,
size: 16,
color: Theme.of(context).colorScheme.tertiary,
),
Expanded(
child: Text(
'thoughtFunctionCall'.tr(args: [functionCallName]),
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.tertiary,
),
),
),
if (showSpinner) ...[
AnimateWidgetExtensions(
Text(
'Calling',
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.tertiary,
color: Theme.of(context).colorScheme.primary,
),
),
),
Icon(
_isExpanded ? Symbols.expand_more : Symbols.expand_less,
size: 16,
color: Theme.of(context).colorScheme.tertiary,
),
],
),
),
Visibility(visible: _isExpanded, child: const Gap(4)),
Visibility(
visible: _isExpanded,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.isStreaming) ...[
...widget.streamingFunctionCalls.map(
(call) => Container(
width: double.infinity,
padding: const EdgeInsets.all(8),
margin: const EdgeInsets.only(bottom: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outline.withOpacity(0.3),
width: 1,
),
),
child: SelectableText(
call,
style: GoogleFonts.robotoMono(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurface,
height: 1.3,
),
),
),
),
] else ...[
...widget.thought!.parts
.where(
(part) =>
part.type ==
ThinkingMessagePartType.functionCall,
)
.map(
(part) => Container(
width: double.infinity,
padding: const EdgeInsets.all(8),
margin: const EdgeInsets.only(bottom: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outline.withOpacity(0.3),
width: 1,
),
),
child: SelectableText(
JsonEncoder.withIndent(
' ',
).convert(part.functionCall?.toJson() ?? {}),
style: GoogleFonts.robotoMono(
fontSize: 11,
color:
Theme.of(context).colorScheme.onSurface,
height: 1.3,
),
),
),
),
],
],
),
),
)
.animate(
autoPlay: true,
onPlay: (c) => c.repeat(reverse: true),
)
.fade(duration: 1000.ms, begin: 0, end: 1),
const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
padding: EdgeInsets.all(3),
),
).padding(right: 8),
],
],
),
childrenPadding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
children: [
if (callData != null)
_buildBlock(context, false, functionCallName, callData!),
if (resultData != null) ...[
if (callData != null && resultData != null) const Gap(8),
_buildBlock(context, true, functionCallName, resultData!),
],
],
),
],
);
}
Widget _buildBlock(
BuildContext context,
bool isResult,
String name,
String data,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
spacing: 8,
children: [
Icon(
isResult ? Symbols.check : Symbols.play_arrow_rounded,
size: 16,
fill: 1,
),
Text(
isResult
? "thoughtFunctionCallFinish".tr(args: [name])
: "thoughtFunctionCallBegin".tr(args: [name]),
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
),
const Gap(4),
if (isResult)
Opacity(
opacity: 0.8,
child: Row(
spacing: 8,
children: [
Icon(Symbols.update, size: 16),
Expanded(
child: Text(
'Generated ${utf8.encode(data).length} bytes',
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
SizedBox(
height: 16,
child: IconButton(
iconSize: 16,
icon: const Icon(Symbols.content_copy),
onPressed:
() => Clipboard.setData(ClipboardData(text: data)),
tooltip: 'Copy response',
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
),
),
],
),
)
else
Container(
width: double.infinity,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
width: 1,
),
),
child: SelectableText(
data,
style: GoogleFonts.robotoMono(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurface,
height: 1.3,
),
),
),
],
);
}
}

View File

@@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math' as math;
import 'package:dio/dio.dart';
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@@ -9,7 +11,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/thought.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/screens/thought/think.dart';
import 'package:island/screens/posts/compose.dart';
import 'package:island/talker.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/post/compose_sheet.dart';
import 'package:island/widgets/thought/function_calls_section.dart';
@@ -19,17 +23,32 @@ import 'package:island/widgets/thought/thought_content.dart';
import 'package:island/widgets/thought/thought_header.dart';
import 'package:island/widgets/thought/token_info.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
class StreamItem {
const StreamItem(this.type, this.data);
final String type;
final dynamic data;
}
class FunctionCallPair {
const FunctionCallPair(this.call, [this.result]);
final StreamItem? call;
final StreamItem? result;
}
class ThoughtChatState {
final ValueNotifier<String?> sequenceId;
final ValueNotifier<List<SnThinkingThought>> localThoughts;
final ValueNotifier<String?> currentTopic;
final ValueNotifier<List<ThoughtService>> services;
final ValueNotifier<String> selectedServiceId;
final TextEditingController messageController;
final ScrollController scrollController;
final ValueNotifier<bool> isStreaming;
final ValueNotifier<List<SnThinkingMessagePart>> streamingParts;
final ValueNotifier<List<String>> reasoningChunks;
final ValueNotifier<List<StreamItem>> streamingItems;
final ListController listController;
final ValueNotifier<ValueNotifier<double>> bottomGradientNotifier;
final Future<void> Function() sendMessage;
@@ -38,11 +57,12 @@ class ThoughtChatState {
required this.sequenceId,
required this.localThoughts,
required this.currentTopic,
required this.services,
required this.selectedServiceId,
required this.messageController,
required this.scrollController,
required this.isStreaming,
required this.streamingParts,
required this.reasoningChunks,
required this.streamingItems,
required this.listController,
required this.bottomGradientNotifier,
required this.sendMessage,
@@ -64,11 +84,29 @@ ThoughtChatState useThoughtChat(
);
final currentTopic = useState<String?>(initialTopic ?? 'aiThought'.tr());
// Watch the provider for services
final servicesAsync = ref.watch(thoughtServicesProvider);
// Initialize services and selected service from provider
final services = useState<List<ThoughtService>>([]);
final selectedServiceId = useState<String>('');
// Update state when provider data arrives
useEffect(() {
if (servicesAsync.hasValue) {
final response = servicesAsync.value!;
services.value = response.services;
if (selectedServiceId.value.isEmpty) {
selectedServiceId.value = response.defaultService;
}
}
return null;
}, [servicesAsync]);
final messageController = useTextEditingController();
final scrollController = useScrollController();
final isStreaming = useState(false);
final streamingParts = useState<List<SnThinkingMessagePart>>([]);
final reasoningChunks = useState<List<String>>([]);
final streamingItems = useState<List<StreamItem>>([]);
final listController = useMemoized(() => ListController(), []);
@@ -139,12 +177,13 @@ ThoughtChatState useThoughtChat(
accpetProposals: ['post_create'],
attachedMessages: attachedMessages,
attachedPosts: attachedPosts,
serviceId:
selectedServiceId.value.isNotEmpty ? selectedServiceId.value : null,
);
try {
isStreaming.value = true;
streamingParts.value = [];
reasoningChunks.value = [];
streamingItems.value = [];
final apiClient = ref.read(apiClientProvider);
final response = await apiClient.post(
@@ -152,8 +191,8 @@ ThoughtChatState useThoughtChat(
data: request.toJson(),
options: Options(
responseType: ResponseType.stream,
sendTimeout: Duration(minutes: 1),
receiveTimeout: Duration(minutes: 1),
sendTimeout: Duration(hours: 1),
receiveTimeout: Duration(hours: 1),
),
);
@@ -176,43 +215,43 @@ ThoughtChatState useThoughtChat(
final event = jsonDecode(jsonStr);
final type = event['type'];
final eventData = event['data'];
if (type == 'text') {
if (streamingParts.value.isNotEmpty &&
streamingParts.value.last.type ==
ThinkingMessagePartType.text) {
final last = streamingParts.value.last;
final newParts = [...streamingParts.value];
newParts[newParts.length - 1] = last.copyWith(
text: (last.text ?? '') + eventData,
);
streamingParts.value = newParts;
} else {
streamingParts.value = [
...streamingParts.value,
SnThinkingMessagePart(
type: ThinkingMessagePartType.text,
text: eventData,
if (type != 'text') {
talker.info('[Thought] Received event: $type');
}
switch (type) {
case 'text':
streamingItems.value = [
...streamingItems.value,
StreamItem('text', eventData),
];
break;
case 'function_call':
streamingItems.value = [
...streamingItems.value,
StreamItem(
'function_call',
SnFunctionCall.fromJson(eventData),
),
];
}
} else if (type == 'function_call') {
streamingParts.value = [
...streamingParts.value,
SnThinkingMessagePart(
type: ThinkingMessagePartType.functionCall,
functionCall: SnFunctionCall.fromJson(eventData),
),
];
} else if (type == 'function_result') {
streamingParts.value = [
...streamingParts.value,
SnThinkingMessagePart(
type: ThinkingMessagePartType.functionResult,
functionResult: SnFunctionResult.fromJson(eventData),
),
];
} else if (type == 'reasoning') {
reasoningChunks.value = [...reasoningChunks.value, eventData];
break;
case 'function_result':
streamingItems.value = [
...streamingItems.value,
StreamItem(
'function_result',
SnFunctionResult.fromJson(eventData),
),
];
break;
case 'reasoning':
streamingItems.value = [
...streamingItems.value,
StreamItem('reasoning', eventData),
];
break;
default:
// ignore unknown types
break;
}
} else if (line.startsWith('topic: ')) {
final jsonStr = line.substring(7);
@@ -333,11 +372,12 @@ ThoughtChatState useThoughtChat(
sequenceId: sequenceId,
localThoughts: localThoughts,
currentTopic: currentTopic,
services: services,
selectedServiceId: selectedServiceId,
messageController: messageController,
scrollController: scrollController,
isStreaming: isStreaming,
streamingParts: streamingParts,
reasoningChunks: reasoningChunks,
streamingItems: streamingItems,
listController: listController,
bottomGradientNotifier: bottomGradientNotifier,
sendMessage: sendMessage,
@@ -346,28 +386,54 @@ ThoughtChatState useThoughtChat(
class ThoughtChatInterface extends HookConsumerWidget {
final List<SnThinkingThought>? initialThoughts;
final String? initialSequenceId;
final String? initialTopic;
final List<Map<String, dynamic>> attachedMessages;
final List<String> attachedPosts;
final bool isDisabled;
const ThoughtChatInterface({
super.key,
this.initialThoughts,
this.initialSequenceId,
this.initialTopic,
this.attachedMessages = const [],
this.attachedPosts = const [],
this.isDisabled = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final inputKey = useMemoized(() => GlobalKey());
final inputHeight = useState<double>(80.0);
// Track previous height for smooth animations
final previousInputHeight = usePrevious<double>(inputHeight.value);
final chatState = useThoughtChat(
ref,
initialSequenceId: initialSequenceId,
initialThoughts: initialThoughts,
initialTopic: initialTopic,
attachedMessages: attachedMessages,
attachedPosts: attachedPosts,
);
// Periodic height measurement for dynamic sizing
useEffect(() {
final timer = Timer.periodic(const Duration(milliseconds: 50), (_) {
final renderBox =
inputKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox != null) {
final newHeight = renderBox.size.height;
if (newHeight != inputHeight.value) {
inputHeight.value = newHeight;
}
}
});
return timer.cancel;
}, []);
return Stack(
children: [
// Thoughts list
@@ -377,57 +443,86 @@ class ThoughtChatInterface extends HookConsumerWidget {
child: Column(
children: [
Expanded(
child: SuperListView.builder(
listController: chatState.listController,
controller: chatState.scrollController,
padding: EdgeInsets.only(
top: 16,
bottom:
MediaQuery.of(context).padding.bottom +
80, // Leave space for thought input
),
reverse: true,
itemCount:
chatState.localThoughts.value.length +
(chatState.isStreaming.value ? 1 : 0),
itemBuilder: (context, index) {
if (chatState.isStreaming.value && index == 0) {
final streamingText = chatState.streamingParts.value
.where(
(p) => p.type == ThinkingMessagePartType.text,
)
.map((p) => p.text ?? '')
.join('');
final streamingFunctionCalls =
chatState.streamingParts.value
.where(
(p) =>
p.type ==
ThinkingMessagePartType.functionCall,
)
.map(
(p) => JsonEncoder.withIndent(
' ',
).convert(p.functionCall?.toJson() ?? {}),
)
.toList();
return ThoughtItem(
isStreaming: true,
streamingText: streamingText,
reasoningChunks: chatState.reasoningChunks.value,
streamingFunctionCalls: streamingFunctionCalls,
);
}
final thoughtIndex =
chatState.isStreaming.value ? index - 1 : index;
final thought =
chatState.localThoughts.value[thoughtIndex];
return ThoughtItem(
thought: thought,
thoughtIndex: thoughtIndex,
);
},
),
child:
previousInputHeight != null &&
previousInputHeight != inputHeight.value
? TweenAnimationBuilder<double>(
tween: Tween<double>(
begin: previousInputHeight,
end: inputHeight.value,
),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
builder:
(context, height, child) =>
SuperListView.builder(
listController: chatState.listController,
controller: chatState.scrollController,
padding: EdgeInsets.only(
top: 16,
bottom:
MediaQuery.of(
context,
).padding.bottom +
8 +
height,
),
reverse: true,
itemCount:
chatState.localThoughts.value.length +
(chatState.isStreaming.value ? 1 : 0),
itemBuilder: (context, index) {
if (chatState.isStreaming.value &&
index == 0) {
return ThoughtItem(
isStreaming: true,
streamingItems:
chatState.streamingItems.value,
);
}
final thoughtIndex =
chatState.isStreaming.value
? index - 1
: index;
final thought =
chatState
.localThoughts
.value[thoughtIndex];
return ThoughtItem(thought: thought);
},
),
)
: SuperListView.builder(
listController: chatState.listController,
controller: chatState.scrollController,
padding: EdgeInsets.only(
top: 16,
bottom:
MediaQuery.of(context).padding.bottom +
8 +
inputHeight.value,
),
reverse: true,
itemCount:
chatState.localThoughts.value.length +
(chatState.isStreaming.value ? 1 : 0),
itemBuilder: (context, index) {
if (chatState.isStreaming.value && index == 0) {
return ThoughtItem(
isStreaming: true,
streamingItems:
chatState.streamingItems.value,
);
}
final thoughtIndex =
chatState.isStreaming.value
? index - 1
: index;
final thought =
chatState.localThoughts.value[thoughtIndex];
return ThoughtItem(thought: thought);
},
),
),
],
),
@@ -475,11 +570,15 @@ class ThoughtChatInterface extends HookConsumerWidget {
child: Container(
constraints: BoxConstraints(maxWidth: 640),
child: ThoughtInput(
key: inputKey,
messageController: chatState.messageController,
isStreaming: chatState.isStreaming.value,
onSend: chatState.sendMessage,
attachedMessages: attachedMessages,
attachedPosts: attachedPosts,
isDisabled: isDisabled,
services: chatState.services.value,
selectedServiceId: chatState.selectedServiceId,
),
),
),
@@ -523,6 +622,9 @@ class ThoughtInput extends HookWidget {
final VoidCallback onSend;
final List<Map<String, dynamic>>? attachedMessages;
final List<String>? attachedPosts;
final bool isDisabled;
final List<ThoughtService> services;
final ValueNotifier<String> selectedServiceId;
const ThoughtInput({
super.key,
@@ -531,6 +633,9 @@ class ThoughtInput extends HookWidget {
required this.onSend,
this.attachedMessages,
this.attachedPosts,
this.isDisabled = false,
required this.services,
required this.selectedServiceId,
});
@override
@@ -614,6 +719,7 @@ class ThoughtInput extends HookWidget {
],
),
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -621,11 +727,13 @@ class ThoughtInput extends HookWidget {
child: TextField(
controller: messageController,
keyboardType: TextInputType.multiline,
enabled: !isStreaming,
enabled: !isStreaming && !isDisabled,
decoration: InputDecoration(
hintText:
(isStreaming
? 'thoughtStreamingHint'
: isDisabled
? 'thoughtUnpaidHint'.tr()
: 'thoughtInputHint')
.tr(),
border: InputBorder.none,
@@ -638,16 +746,123 @@ class ThoughtInput extends HookWidget {
maxLines: 5,
minLines: 1,
textInputAction: TextInputAction.send,
onSubmitted: (_) => onSend(),
onSubmitted:
(!isStreaming && !isDisabled)
? (_) => onSend()
: null,
),
),
IconButton(
icon: Icon(isStreaming ? Symbols.stop : Icons.send),
color: Theme.of(context).colorScheme.primary,
onPressed: onSend,
onPressed: (!isStreaming && !isDisabled) ? onSend : null,
),
],
),
Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
child: Row(
children: [
if (services.isNotEmpty)
DropdownButtonHideUnderline(
child: DropdownButton2<String>(
value:
selectedServiceId.value.isEmpty
? null
: selectedServiceId.value,
customButton: Container(
padding: EdgeInsets.all(4),
decoration: BoxDecoration(
border: BoxBorder.all(
color: Theme.of(context).colorScheme.outline,
width: 1,
),
borderRadius: const BorderRadius.all(
Radius.circular(16),
),
),
child: Row(
spacing: 8,
children: [
const Icon(
Symbols.network_intelligence,
size: 20,
),
Text(selectedServiceId.value),
const Icon(
Symbols.keyboard_arrow_down,
size: 14,
).padding(right: 4),
],
).padding(vertical: 2, horizontal: 6),
),
items:
services
.map(
(service) => DropdownMenuItem<String>(
value: service.serviceId,
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
service.serviceId,
style: DefaultTextStyle.of(
context,
).style.copyWith(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
Text(
'Rate: ${service.billingMultiplier}x, Level: P${service.perkLevel}',
style: DefaultTextStyle.of(
context,
).style.copyWith(
fontSize: 12,
color:
Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
],
),
),
)
.toList(),
onChanged:
!isStreaming && !isDisabled
? (value) {
if (value != null) {
selectedServiceId.value = value;
}
}
: null,
hint: const Text('Select Service'),
isDense: true,
buttonStyleData: ButtonStyleData(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(
Radius.circular(16),
),
),
),
menuItemStyleData: MenuItemStyleData(
selectedMenuItemBuilder: (context, child) {
return child;
},
height: 56,
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 8,
),
),
),
),
],
),
),
],
),
),
@@ -661,39 +876,21 @@ class ThoughtItem extends StatelessWidget {
const ThoughtItem({
super.key,
this.thought,
this.thoughtIndex,
this.isStreaming = false,
this.streamingText = '',
this.reasoningChunks = const [],
this.streamingFunctionCalls = const [],
this.streamingItems,
}) : assert(
(thought != null && !isStreaming) || (thought == null && isStreaming),
'Either thought or streaming parameters must be provided',
(streamingItems != null && isStreaming) ||
(thought != null && !isStreaming),
'Either streamingItems or thought must be provided',
);
final SnThinkingThought? thought;
final int? thoughtIndex;
final bool isStreaming;
final String streamingText;
final List<String> reasoningChunks;
final List<String> streamingFunctionCalls;
final List<StreamItem>? streamingItems;
@override
Widget build(BuildContext context) {
final isUser = !isStreaming && thought!.role == ThinkingThoughtRole.user;
final isAI =
isStreaming ||
(!isStreaming && thought!.role == ThinkingThoughtRole.assistant);
final List<Map<String, String>> proposals =
!isStreaming
? _extractProposals(
thought!.parts
.where((p) => p.type == ThinkingMessagePartType.text)
.map((p) => p.text ?? '')
.join(''),
)
: [];
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -717,66 +914,173 @@ class ThoughtItem extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
// Main content
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Flexible(
child: ThoughtContent(
isStreaming: isStreaming,
streamingText: streamingText,
thought: thought,
),
),
if (isStreaming && isAI)
SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2.5,
padding: const EdgeInsets.all(4),
),
),
],
),
// Reasoning chunks (streaming only)
if (reasoningChunks.isNotEmpty)
ReasoningSection(reasoningChunks: reasoningChunks),
// Function calls
if (streamingFunctionCalls.isNotEmpty ||
(thought?.parts.isNotEmpty ?? false) &&
thought!.parts.any(
(part) =>
part.type == ThinkingMessagePartType.functionCall,
))
FunctionCallsSection(
isStreaming: isStreaming,
streamingFunctionCalls: streamingFunctionCalls,
thought: thought,
),
// Token count and model name (for completed AI thoughts only)
if (!isStreaming &&
isAI &&
thought != null &&
!thought!.id.startsWith('error-'))
TokenInfo(thought: thought!),
// Proposals (for completed AI thoughts only)
if (!isStreaming && proposals.isNotEmpty && isAI)
ProposalsSection(
proposals: proposals,
onProposalAction: _handleProposalAction,
),
],
children: buildWidgetsList(),
),
),
],
),
);
}
List<Widget> buildWidgetsList() {
final List<StreamItem> items =
isStreaming
? (streamingItems ?? [])
: thought!.parts.map((p) {
String type;
switch (p.type) {
case ThinkingMessagePartType.text:
type = 'text';
break;
case ThinkingMessagePartType.functionCall:
type = 'function_call';
break;
case ThinkingMessagePartType.functionResult:
type = 'function_result';
break;
}
return StreamItem(
type,
p.type == ThinkingMessagePartType.text
? p.text ?? ''
: p.functionCall ?? p.functionResult,
);
}).toList();
final isAI =
isStreaming ||
(!isStreaming && thought!.role == ThinkingThoughtRole.assistant);
final List<Map<String, String>> proposals =
!isStreaming
? _extractProposals(
thought!.parts
.where((p) => p.type == ThinkingMessagePartType.text)
.map((p) => p.text ?? '')
.join(),
)
: [];
final List<Widget> widgets = [];
String currentText = '';
bool hasOpenText = false;
int i = 0;
while (i < items.length) {
final item = items[i];
if (item.type == 'text') {
currentText += item.data as String;
hasOpenText = true;
} else if (item.type == 'function_call') {
if (hasOpenText) {
widgets.add(buildTextRow(currentText));
currentText = '';
hasOpenText = false;
}
// check next for result
StreamItem? result;
if (i + 1 < items.length && items[i + 1].type == 'function_result') {
result = items[i + 1];
i++; // skip it
}
widgets.add(
FunctionCallsSection(
isFinish: result != null,
isStreaming: isStreaming,
callData: JsonEncoder.withIndent(' ').convert(item.data.toJson()),
resultData:
result != null
? JsonEncoder.withIndent(' ').convert(result.data.toJson())
: null,
),
);
} else if (item.type == 'function_result') {
if (hasOpenText) {
widgets.add(buildTextRow(currentText));
currentText = '';
hasOpenText = false;
}
// orphan result, treat as finished with call
widgets.add(
FunctionCallsSection(
isFinish: true,
isStreaming: isStreaming,
callData: null,
resultData: JsonEncoder.withIndent(
' ',
).convert(item.data.toJson()),
),
);
} else if (item.type == 'reasoning') {
if (hasOpenText) {
widgets.add(buildTextRow(currentText));
currentText = '';
hasOpenText = false;
}
widgets.add(buildItemWidget(item));
} else {
// ignore
}
i++;
}
if (hasOpenText) {
widgets.add(buildTextRow(currentText));
}
// Add spinner at the end if streaming
if (isStreaming) {
widgets.add(
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2.5),
).padding(left: 8),
],
),
);
}
// The proposals and token info at the end
if (!isStreaming && proposals.isNotEmpty && isAI) {
widgets.add(
ProposalsSection(
proposals: proposals,
onProposalAction: _handleProposalAction,
),
);
}
if (!isStreaming &&
isAI &&
thought != null &&
!thought!.id.startsWith('error-')) {
widgets.add(TokenInfo(thought: thought!));
}
return widgets;
}
Widget buildTextRow(String text) {
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Flexible(
child: ThoughtContent(
isStreaming: isStreaming,
streamingText: text,
thought: thought,
),
),
],
);
}
Widget buildItemWidget(StreamItem item) {
switch (item.type) {
case 'reasoning':
return ReasoningSection(reasoningChunks: [item.data]);
default:
throw 'unknown item type ${item.type}';
}
}
}

View File

@@ -1,6 +1,7 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:island/utils/format.dart';
import 'package:styled_widget/styled_widget.dart';
class UsageOverviewWidget extends StatelessWidget {
@@ -26,7 +27,7 @@ class UsageOverviewWidget extends StatelessWidget {
Expanded(
child: _buildStatCard(
'All Uploads',
'${((nonNullUsage['total_usage_bytes'] as num) / (1024 * 1024 * 1024)).toStringAsFixed(3)} GiB',
formatFileSize(nonNullUsage['total_usage_bytes'] as int),
),
),
Expanded(
@@ -42,7 +43,9 @@ class UsageOverviewWidget extends StatelessWidget {
Expanded(
child: _buildStatCard(
'Quota',
'${nonNullUsage['total_quota']} MiB',
formatFileSize(
(nonNullUsage['total_quota'] as int) * 1024 * 1024,
),
),
),
Expanded(

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

@@ -37,10 +37,10 @@ packages:
dependency: "direct main"
description:
name: animations
sha256: a8031b276f0a7986ac907195f10ca7cd04ecf2a8a566bd6dbe03018a9b02b427
sha256: "18938cefd7dcc04e1ecac0db78973761a01e4bc2d6bfae0cfa596bfeac9e96ab"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
version: "2.1.1"
ansicolor:
dependency: transitive
description:
@@ -726,6 +726,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_animate:
dependency: "direct main"
description:
name: flutter_animate
sha256: "7befe2d3252728afb77aecaaea1dec88a89d35b9b1d2eea6d04479e8af9117b5"
url: "https://pub.dev"
source: hosted
version: "4.5.2"
flutter_app_update:
dependency: "direct main"
description:
@@ -1075,6 +1083,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.2"
flutter_shaders:
dependency: transitive
description:
name: flutter_shaders
sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2"
url: "https://pub.dev"
source: hosted
version: "0.1.3"
flutter_staggered_grid_view:
dependency: "direct main"
description:
@@ -1577,10 +1593,10 @@ packages:
dependency: "direct main"
description:
name: media_kit
sha256: "52a8e989babc431db0aa242f32a4a08e55f60662477ea09759a105d7cd6410da"
sha256: dfd5ab85d49a1806b1314a0b81f3d14da48f0db0a657336b2d77c5f17db28944
url: "https://pub.dev"
source: hosted
version: "1.2.1"
version: "1.2.2"
media_kit_libs_android_video:
dependency: transitive
description:

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
@@ -168,6 +168,7 @@ dependencies:
event_bus: ^2.0.1
convert: ^3.1.2
desktop_drop: ^0.7.0
flutter_animate: ^4.5.0
dev_dependencies:
flutter_test: