Compare commits
40 Commits
a9c8f49797
...
3.3.0+146
| Author | SHA1 | Date | |
|---|---|---|---|
|
24791b3293
|
|||
|
3ac263d483
|
|||
|
2445d8adf8
|
|||
|
d4f95bbbf4
|
|||
|
943e4b7b5c
|
|||
|
7edc02a1d3
|
|||
|
3f9881e943
|
|||
|
50c25e919c
|
|||
|
99fb08dd55
|
|||
|
e43bc6b8a8
|
|||
|
c247cdf81c
|
|||
|
3ffa730505
|
|||
|
1cc34d3073
|
|||
|
96a919cc4e
|
|||
|
e7e3bfcadf
|
|||
|
a8617a5040
|
|||
|
d94f8d004f
|
|||
|
d93b066979
|
|||
|
320664a547
|
|||
|
98f4698d5b
|
|||
|
82397dd087
|
|||
|
4ec10ceb47
|
|||
|
4b03b45a0d
|
|||
|
7a72d32649
|
|||
|
5152dd13ea
|
|||
|
fd377aa7af
|
|||
|
67044148f1
|
|||
|
92bc43e4df
|
|||
|
a1a7b34c86
|
|||
|
40c0e052cf
|
|||
|
9a75228e38
|
|||
|
a9fd75cc45
|
|||
|
a713b30d93
|
|||
|
e516f0a862
|
|||
|
429b966c4b
|
|||
|
f14da0d3a2
|
|||
|
d201182bd2
|
|||
|
6f6422c15e
|
|||
|
9f6ae639ee
|
|||
|
35f4d7d885
|
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "寻思因为有未支付的订单而被禁用"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'messages_notifier.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$messagesNotifierHash() => r'c009eb8598e8b5fbcece2d0b5213b2e434edb3b2';
|
||||
String _$messagesNotifierHash() => r'fc9c99024a0801efa4894f250aea8bdc6127a0b6';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
486
lib/screens/auth/create_account_content.dart
Normal file
486
lib/screens/auth/create_account_content.dart
Normal 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()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
19
lib/screens/auth/create_account_modal.dart
Normal file
19
lib/screens/auth/create_account_modal.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
814
lib/screens/auth/login_content.dart
Normal file
814
lib/screens/auth/login_content.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
19
lib/screens/auth/login_modal.dart
Normal file
19
lib/screens/auth/login_modal.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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},
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
208
lib/widgets/account/friends_overview.dart
Normal file
208
lib/widgets/account/friends_overview.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
30
lib/widgets/account/friends_overview.g.dart
Normal file
30
lib/widgets/account/friends_overview.g.dart
Normal 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
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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:
|
||||
(_) =>
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']}'),
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
388
lib/widgets/post/compose_fund.dart
Normal file
388
lib/widgets/post/compose_fund.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
(_) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
596
lib/widgets/wallet/fund_envelope.dart
Normal file
596
lib/widgets/wallet/fund_envelope.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
151
lib/widgets/wallet/fund_envelope.g.dart
Normal file
151
lib/widgets/wallet/fund_envelope.g.dart
Normal 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
|
||||
24
pubspec.lock
24
pubspec.lock
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user