Compare commits

..

41 Commits

Author SHA1 Message Date
ac4fa5eb85 🚀 Launch 3.3.0+144 2025-11-02 23:57:31 +08:00
8857718709 🐛 Fix compose toolbar safe area issue 2025-11-02 23:56:48 +08:00
dd17b2b9c1 Scroll gradiant to think as well 2025-11-02 23:55:00 +08:00
848439f664 Chat room scroll gradiant 2025-11-02 23:52:03 +08:00
f83117424d 🐛 Fix tag subscribe used wrong icon 2025-11-02 23:44:11 +08:00
8c19c32c76 Publisher profile collapsible pinned post 2025-11-02 23:36:42 +08:00
d62b2bed80 💄 Optimize publisher page filter select date 2025-11-02 23:34:08 +08:00
5a23eb1768 Stronger filter 2025-11-02 23:30:16 +08:00
5f6e4763d3 🐛 Fix app notification 2025-11-02 23:12:11 +08:00
580c36fb89 🐛 Fix mis placed safe area 2025-11-02 22:45:28 +08:00
6c25af3b30 Show publisher mentioned chip as well 2025-11-02 22:44:09 +08:00
a1da72d447 Show profile picture in mention chip 2025-11-02 22:41:50 +08:00
ab4120cc22 💄 Optimize cloud file list 2025-11-02 22:34:32 +08:00
52eff0fa25 🐛 Fix the NSE again... 2025-11-02 22:14:31 +08:00
beeb28abf2 💄 Optimize in-app notification style 2025-11-02 21:55:42 +08:00
c0ab3837ac 👽 Make poll load itself to match server updates 2025-11-02 21:47:37 +08:00
59d38c0d8d 💄 Refined developer hub 2025-11-02 21:19:58 +08:00
bd2247ce86 ♻️ Refactor the app management to use sheet 2025-11-02 21:12:55 +08:00
da2d3f7f17 ♻️ Make bot management into sheet 2025-11-02 21:04:35 +08:00
7497b77384 💄 Adjusted developer hub 2025-11-02 17:45:03 +08:00
f542d9fa97 🐛 Fix timezone error 2025-11-02 17:24:18 +08:00
e70439870e ♻️ Add event bus to more places 2025-11-02 17:13:10 +08:00
d764b042fe Shows account own activities on account page 2025-11-02 16:59:58 +08:00
a76b97d1d2 💄 Shows listening activities are from spotfiy 2025-11-02 16:55:16 +08:00
cfbe6e580b 👔 Add rpc prefix for activities generated from activity server 2025-11-02 16:50:31 +08:00
f08b9e057f Special display for spotify activity 2025-11-02 16:49:39 +08:00
0509f37c96 ♻️ Use system browser for OIDC 2025-11-02 16:32:29 +08:00
a7dc9ac6fa Add spotify in account connection 2025-11-02 15:49:44 +08:00
caf2f5f1f6 💄 Optimize the link embed 2025-11-02 15:43:40 +08:00
12b79af3a2 🐛 Fix bugs 2025-11-02 02:21:15 +08:00
88f149584e ♻️ Removed the post compose screen completely 2025-11-02 01:43:04 +08:00
877001b802 💄 Optimize publisher profile again 2025-11-02 01:36:14 +08:00
fec28f6223 💄 Optimize publisher page 2025-11-02 01:30:47 +08:00
85005ff9c3 💄 Optimize profile page 2025-11-02 01:20:14 +08:00
e3c92a3c55 💄 Optimize profile page styling 2025-11-02 01:05:40 +08:00
9e9fbc5d6a 💄 Optimize settings buttons 2025-11-02 01:04:10 +08:00
8d1d836b52 💄 Optimize the account page 2025-11-02 00:51:16 +08:00
bc60ce5d42 💄 Optimize the pfc and show the activities 2025-11-02 00:25:08 +08:00
c093123e3a Shows images, url from presense 2025-11-02 00:03:16 +08:00
3de73538c7 🐛 Activity refined 2025-11-01 23:36:05 +08:00
ba8d5cee09 Refined presense activity 2025-11-01 21:47:34 +08:00
67 changed files with 5571 additions and 3392 deletions

View File

@@ -162,6 +162,7 @@
"accountConnectionProviderGithub": "GitHub",
"accountConnectionProviderDiscord": "Discord",
"accountConnectionProviderAfdian": "Afdian",
"accountConnectionProviderSpotify": "Spotify",
"checkIn": "Check In",
"checkInNone": "Not checked-in yet",
"checkInNoneHint": "Get your fortune tips and daily rewards by checking in.",
@@ -469,6 +470,7 @@
"pronouns": "Pronouns",
"location": "Location",
"timeZone": "Time Zone",
"timezoneNotFound": "Time zone not found",
"birthday": "Birthday",
"selectADate": "Select a date",
"checkInResultT0": "Worst",
@@ -1302,5 +1304,20 @@
"aiThought": "AI Thought",
"aiThoughtTitle": "Let sn-chan think",
"postReferenceUnavailable": "Referenced post is unavailable",
"fabLocation": "FAB Location"
"fabLocation": "FAB Location",
"activities": "Activities",
"presenceTypeGaming": "Playing",
"presenceTypeMusic": "Listening to Music",
"presenceTypeWorkout": "Working out",
"articleCompose": "Compose Article",
"backToHub": "Back to Hub",
"advancedFilters": "Advanced Filters",
"searchPosts": "Search Posts",
"sortBy": "Sort by",
"fromDate": "From Date",
"toDate": "To Date",
"popularity": "Popularity",
"descendingOrder": "Descending Order",
"selectDate": "Select Date",
"pinnedPosts": "Pinned Posts"
}

View File

@@ -158,11 +158,11 @@
"checkIn": "签到",
"checkInNone": "尚未签到",
"checkInNoneHint": "通过签到获取您的财富提示和每日奖励。",
"checkInResultLevel0": "最差运气",
"checkInResultLevel1": "坏运气",
"checkInResultLevel2": "一个普通的日常",
"checkInResultLevel3": "好运",
"checkInResultLevel4": "最佳运气",
"checkInResultLevel0": "大凶",
"checkInResultLevel1": "",
"checkInResultLevel2": "中平",
"checkInResultLevel3": "",
"checkInResultLevel4": "大吉",
"checkInActivityTitle": "{} 在 {} 签到并获得了 {}",
"eventCalander": "活动日历",
"eventCalanderEmpty": "该日无活动。",
@@ -344,7 +344,7 @@
"accountSettingsHelpContent": "此页面允许您管理您的帐户安全性、隐私和其他设置。如果您需要帮助,请联系管理员。",
"unauthorized": "未授权",
"unauthorizedHint": "您未登录或会话已过期,请重新登录。",
"publisherBelongsTo": "属于",
"publisherBelongsTo": "属于 {}",
"postContent": "内容",
"postSettings": "设置",
"postPublisherUnselected": "未指定发布者",

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -9,6 +9,7 @@ import UserNotifications
import Intents
import Kingfisher
import UniformTypeIdentifiers
import KingfisherWebP
enum ParseNotificationPayloadError: Error {
case missingMetadata(String)
@@ -24,6 +25,11 @@ class NotificationService: UNNotificationServiceExtension {
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
KingfisherManager.shared.defaultOptions += [
.processor(WebPProcessor.default),
.cacheSerializer(WebPSerializer.default)
]
self.contentHandler = contentHandler
guard let bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent else {
contentHandler(request.content)
@@ -64,40 +70,12 @@ class NotificationService: UNNotificationServiceExtension {
let handle = INPersonHandle(value: "\(metaCopy["user_id"] ?? "")", type: .unknown)
if let pfpUrl = pfpUrl, let url = URL(string: pfpUrl) {
let targetSize = 512
let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit)
KingfisherManager.shared.retrieveImage(with: url, options: [.processor(scaleProcessor)], completionHandler: { result in
var image: Data?
switch result {
case .success(let value):
image = value.image.pngData()
case .failure(let error):
print("Unable to get pfp url: \(error)")
}
let sender = INPerson(
personHandle: handle,
nameComponents: PersonNameComponents(nickname: "\(metaCopy["sender_name"] ?? "")"),
displayName: content.title,
image: image == nil ? nil : INImage(imageData: image!),
contactIdentifier: nil,
customIdentifier: nil
)
let intent = self.createMessageIntent(with: sender, meta: metaCopy, body: content.body)
self.donateInteraction(for: intent)
content.categoryIdentifier = "CHAT_MESSAGE"
self.contentHandler?(content)
})
} else {
let completeNotificationProcessing: (Data?) -> Void = { imageData in
let sender = INPerson(
personHandle: handle,
nameComponents: PersonNameComponents(nickname: "\(metaCopy["sender_name"] ?? "")"),
displayName: content.title,
image: nil,
image: imageData == nil ? nil : INImage(imageData: imageData!),
contactIdentifier: nil,
customIdentifier: nil
)
@@ -105,8 +83,37 @@ class NotificationService: UNNotificationServiceExtension {
let intent = self.createMessageIntent(with: sender, meta: metaCopy, body: content.body)
self.donateInteraction(for: intent)
content.categoryIdentifier = "CHAT_MESSAGE"
self.contentHandler?(content)
if let updatedContent = try? request.content.updating(from: intent) {
if let mutableContent = updatedContent.mutableCopy() as? UNMutableNotificationContent {
mutableContent.categoryIdentifier = "CHAT_MESSAGE"
self.contentHandler?(mutableContent)
} else {
self.contentHandler?(updatedContent)
}
} else {
content.categoryIdentifier = "CHAT_MESSAGE"
self.contentHandler?(content)
}
}
if let pfpUrl = pfpUrl, let url = URL(string: pfpUrl) {
let targetSize = 512
let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit)
KingfisherManager.shared.retrieveImage(with: url, options: [
.processor(scaleProcessor)
], completionHandler: { result in
var image: Data?
switch result {
case .success(let value):
image = value.image.pngData()
case .failure(let error):
print("Unable to get pfp url: \(error)")
}
completeNotificationProcessing(image)
})
} else {
completeNotificationProcessing(nil)
}
}

View File

@@ -19,8 +19,8 @@ sealed class SnNotableDay with _$SnNotableDay {
}
@freezed
sealed class SnActivity with _$SnActivity {
const factory SnActivity({
sealed class SnTimelineEvent with _$SnTimelineEvent {
const factory SnTimelineEvent({
required String id,
required String type,
required String resourceIdentifier,
@@ -28,10 +28,10 @@ sealed class SnActivity with _$SnActivity {
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
}) = _SnActivity;
}) = _SnTimelineEvent;
factory SnActivity.fromJson(Map<String, dynamic> json) =>
_$SnActivityFromJson(json);
factory SnTimelineEvent.fromJson(Map<String, dynamic> json) =>
_$SnTimelineEventFromJson(json);
}
@freezed
@@ -79,11 +79,15 @@ sealed class SnEventCalendarEntry with _$SnEventCalendarEntry {
sealed class SnPresenceActivity with _$SnPresenceActivity {
const factory SnPresenceActivity({
required String id,
required String type,
required int type,
required String? manualId,
required String? title,
required String? subtitle,
required String? caption,
required String? titleUrl,
required String? subtitleUrl,
required String? smallImage,
required String? largeImage,
required Map<String, dynamic>? meta,
required int leaseMinutes,
required DateTime leaseExpiresAt,

View File

@@ -288,22 +288,22 @@ as List<int>,
/// @nodoc
mixin _$SnActivity {
mixin _$SnTimelineEvent {
String get id; String get type; String get resourceIdentifier; dynamic get data; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
/// Create a copy of SnActivity
/// Create a copy of SnTimelineEvent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnActivityCopyWith<SnActivity> get copyWith => _$SnActivityCopyWithImpl<SnActivity>(this as SnActivity, _$identity);
$SnTimelineEventCopyWith<SnTimelineEvent> get copyWith => _$SnTimelineEventCopyWithImpl<SnTimelineEvent>(this as SnTimelineEvent, _$identity);
/// Serializes this SnActivity to a JSON map.
/// Serializes this SnTimelineEvent to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnActivity&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.resourceIdentifier, resourceIdentifier) || other.resourceIdentifier == resourceIdentifier)&&const DeepCollectionEquality().equals(other.data, data)&&(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 SnTimelineEvent&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.resourceIdentifier, resourceIdentifier) || other.resourceIdentifier == resourceIdentifier)&&const DeepCollectionEquality().equals(other.data, data)&&(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)
@@ -312,15 +312,15 @@ int get hashCode => Object.hash(runtimeType,id,type,resourceIdentifier,const Dee
@override
String toString() {
return 'SnActivity(id: $id, type: $type, resourceIdentifier: $resourceIdentifier, data: $data, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
return 'SnTimelineEvent(id: $id, type: $type, resourceIdentifier: $resourceIdentifier, data: $data, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
}
/// @nodoc
abstract mixin class $SnActivityCopyWith<$Res> {
factory $SnActivityCopyWith(SnActivity value, $Res Function(SnActivity) _then) = _$SnActivityCopyWithImpl;
abstract mixin class $SnTimelineEventCopyWith<$Res> {
factory $SnTimelineEventCopyWith(SnTimelineEvent value, $Res Function(SnTimelineEvent) _then) = _$SnTimelineEventCopyWithImpl;
@useResult
$Res call({
String id, String type, String resourceIdentifier, dynamic data, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
@@ -331,14 +331,14 @@ $Res call({
}
/// @nodoc
class _$SnActivityCopyWithImpl<$Res>
implements $SnActivityCopyWith<$Res> {
_$SnActivityCopyWithImpl(this._self, this._then);
class _$SnTimelineEventCopyWithImpl<$Res>
implements $SnTimelineEventCopyWith<$Res> {
_$SnTimelineEventCopyWithImpl(this._self, this._then);
final SnActivity _self;
final $Res Function(SnActivity) _then;
final SnTimelineEvent _self;
final $Res Function(SnTimelineEvent) _then;
/// Create a copy of SnActivity
/// Create a copy of SnTimelineEvent
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? type = null,Object? resourceIdentifier = null,Object? data = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_self.copyWith(
@@ -356,8 +356,8 @@ as DateTime?,
}
/// Adds pattern-matching-related methods to [SnActivity].
extension SnActivityPatterns on SnActivity {
/// Adds pattern-matching-related methods to [SnTimelineEvent].
extension SnTimelineEventPatterns on SnTimelineEvent {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
@@ -370,10 +370,10 @@ extension SnActivityPatterns on SnActivity {
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _SnActivity value)? $default,{required TResult orElse(),}){
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _SnTimelineEvent value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _SnActivity() when $default != null:
case _SnTimelineEvent() when $default != null:
return $default(_that);case _:
return orElse();
@@ -392,10 +392,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _SnActivity value) $default,){
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _SnTimelineEvent value) $default,){
final _that = this;
switch (_that) {
case _SnActivity():
case _SnTimelineEvent():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
@@ -410,10 +410,10 @@ return $default(_that);}
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _SnActivity value)? $default,){
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _SnTimelineEvent value)? $default,){
final _that = this;
switch (_that) {
case _SnActivity() when $default != null:
case _SnTimelineEvent() when $default != null:
return $default(_that);case _:
return null;
@@ -433,7 +433,7 @@ return $default(_that);case _:
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String type, String resourceIdentifier, dynamic data, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SnActivity() when $default != null:
case _SnTimelineEvent() when $default != null:
return $default(_that.id,_that.type,_that.resourceIdentifier,_that.data,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return orElse();
@@ -454,7 +454,7 @@ return $default(_that.id,_that.type,_that.resourceIdentifier,_that.data,_that.cr
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String type, String resourceIdentifier, dynamic data, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
switch (_that) {
case _SnActivity():
case _SnTimelineEvent():
return $default(_that.id,_that.type,_that.resourceIdentifier,_that.data,_that.createdAt,_that.updatedAt,_that.deletedAt);}
}
/// A variant of `when` that fallback to returning `null`
@@ -471,7 +471,7 @@ return $default(_that.id,_that.type,_that.resourceIdentifier,_that.data,_that.cr
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String type, String resourceIdentifier, dynamic data, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
switch (_that) {
case _SnActivity() when $default != null:
case _SnTimelineEvent() when $default != null:
return $default(_that.id,_that.type,_that.resourceIdentifier,_that.data,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return null;
@@ -483,9 +483,9 @@ return $default(_that.id,_that.type,_that.resourceIdentifier,_that.data,_that.cr
/// @nodoc
@JsonSerializable()
class _SnActivity implements SnActivity {
const _SnActivity({required this.id, required this.type, required this.resourceIdentifier, required this.data, required this.createdAt, required this.updatedAt, required this.deletedAt});
factory _SnActivity.fromJson(Map<String, dynamic> json) => _$SnActivityFromJson(json);
class _SnTimelineEvent implements SnTimelineEvent {
const _SnTimelineEvent({required this.id, required this.type, required this.resourceIdentifier, required this.data, required this.createdAt, required this.updatedAt, required this.deletedAt});
factory _SnTimelineEvent.fromJson(Map<String, dynamic> json) => _$SnTimelineEventFromJson(json);
@override final String id;
@override final String type;
@@ -495,20 +495,20 @@ class _SnActivity implements SnActivity {
@override final DateTime updatedAt;
@override final DateTime? deletedAt;
/// Create a copy of SnActivity
/// Create a copy of SnTimelineEvent
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnActivityCopyWith<_SnActivity> get copyWith => __$SnActivityCopyWithImpl<_SnActivity>(this, _$identity);
_$SnTimelineEventCopyWith<_SnTimelineEvent> get copyWith => __$SnTimelineEventCopyWithImpl<_SnTimelineEvent>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnActivityToJson(this, );
return _$SnTimelineEventToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnActivity&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.resourceIdentifier, resourceIdentifier) || other.resourceIdentifier == resourceIdentifier)&&const DeepCollectionEquality().equals(other.data, data)&&(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 _SnTimelineEvent&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.resourceIdentifier, resourceIdentifier) || other.resourceIdentifier == resourceIdentifier)&&const DeepCollectionEquality().equals(other.data, data)&&(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)
@@ -517,15 +517,15 @@ int get hashCode => Object.hash(runtimeType,id,type,resourceIdentifier,const Dee
@override
String toString() {
return 'SnActivity(id: $id, type: $type, resourceIdentifier: $resourceIdentifier, data: $data, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
return 'SnTimelineEvent(id: $id, type: $type, resourceIdentifier: $resourceIdentifier, data: $data, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
}
/// @nodoc
abstract mixin class _$SnActivityCopyWith<$Res> implements $SnActivityCopyWith<$Res> {
factory _$SnActivityCopyWith(_SnActivity value, $Res Function(_SnActivity) _then) = __$SnActivityCopyWithImpl;
abstract mixin class _$SnTimelineEventCopyWith<$Res> implements $SnTimelineEventCopyWith<$Res> {
factory _$SnTimelineEventCopyWith(_SnTimelineEvent value, $Res Function(_SnTimelineEvent) _then) = __$SnTimelineEventCopyWithImpl;
@override @useResult
$Res call({
String id, String type, String resourceIdentifier, dynamic data, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
@@ -536,17 +536,17 @@ $Res call({
}
/// @nodoc
class __$SnActivityCopyWithImpl<$Res>
implements _$SnActivityCopyWith<$Res> {
__$SnActivityCopyWithImpl(this._self, this._then);
class __$SnTimelineEventCopyWithImpl<$Res>
implements _$SnTimelineEventCopyWith<$Res> {
__$SnTimelineEventCopyWithImpl(this._self, this._then);
final _SnActivity _self;
final $Res Function(_SnActivity) _then;
final _SnTimelineEvent _self;
final $Res Function(_SnTimelineEvent) _then;
/// Create a copy of SnActivity
/// Create a copy of SnTimelineEvent
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? type = null,Object? resourceIdentifier = null,Object? data = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_SnActivity(
return _then(_SnTimelineEvent(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as String,resourceIdentifier: null == resourceIdentifier ? _self.resourceIdentifier : resourceIdentifier // ignore: cast_nullable_to_non_nullable
@@ -1429,7 +1429,7 @@ $SnCheckInResultCopyWith<$Res>? get checkInResult {
/// @nodoc
mixin _$SnPresenceActivity {
String get id; String get type; String? get manualId; String? get title; String? get subtitle; String? get caption; Map<String, dynamic>? get meta; int get leaseMinutes; DateTime get leaseExpiresAt; String get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
String get id; int get type; String? get manualId; String? get title; String? get subtitle; String? get caption; String? get titleUrl; String? get subtitleUrl; String? get smallImage; String? get largeImage; Map<String, dynamic>? get meta; int get leaseMinutes; DateTime get leaseExpiresAt; String get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
/// Create a copy of SnPresenceActivity
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -1442,16 +1442,16 @@ $SnPresenceActivityCopyWith<SnPresenceActivity> get copyWith => _$SnPresenceActi
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnPresenceActivity&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.manualId, manualId) || other.manualId == manualId)&&(identical(other.title, title) || other.title == title)&&(identical(other.subtitle, subtitle) || other.subtitle == subtitle)&&(identical(other.caption, caption) || other.caption == caption)&&const DeepCollectionEquality().equals(other.meta, meta)&&(identical(other.leaseMinutes, leaseMinutes) || other.leaseMinutes == leaseMinutes)&&(identical(other.leaseExpiresAt, leaseExpiresAt) || other.leaseExpiresAt == leaseExpiresAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(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 SnPresenceActivity&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.manualId, manualId) || other.manualId == manualId)&&(identical(other.title, title) || other.title == title)&&(identical(other.subtitle, subtitle) || other.subtitle == subtitle)&&(identical(other.caption, caption) || other.caption == caption)&&(identical(other.titleUrl, titleUrl) || other.titleUrl == titleUrl)&&(identical(other.subtitleUrl, subtitleUrl) || other.subtitleUrl == subtitleUrl)&&(identical(other.smallImage, smallImage) || other.smallImage == smallImage)&&(identical(other.largeImage, largeImage) || other.largeImage == largeImage)&&const DeepCollectionEquality().equals(other.meta, meta)&&(identical(other.leaseMinutes, leaseMinutes) || other.leaseMinutes == leaseMinutes)&&(identical(other.leaseExpiresAt, leaseExpiresAt) || other.leaseExpiresAt == leaseExpiresAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(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,type,manualId,title,subtitle,caption,const DeepCollectionEquality().hash(meta),leaseMinutes,leaseExpiresAt,accountId,createdAt,updatedAt,deletedAt);
int get hashCode => Object.hash(runtimeType,id,type,manualId,title,subtitle,caption,titleUrl,subtitleUrl,smallImage,largeImage,const DeepCollectionEquality().hash(meta),leaseMinutes,leaseExpiresAt,accountId,createdAt,updatedAt,deletedAt);
@override
String toString() {
return 'SnPresenceActivity(id: $id, type: $type, manualId: $manualId, title: $title, subtitle: $subtitle, caption: $caption, meta: $meta, leaseMinutes: $leaseMinutes, leaseExpiresAt: $leaseExpiresAt, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
return 'SnPresenceActivity(id: $id, type: $type, manualId: $manualId, title: $title, subtitle: $subtitle, caption: $caption, titleUrl: $titleUrl, subtitleUrl: $subtitleUrl, smallImage: $smallImage, largeImage: $largeImage, meta: $meta, leaseMinutes: $leaseMinutes, leaseExpiresAt: $leaseExpiresAt, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
@@ -1462,7 +1462,7 @@ abstract mixin class $SnPresenceActivityCopyWith<$Res> {
factory $SnPresenceActivityCopyWith(SnPresenceActivity value, $Res Function(SnPresenceActivity) _then) = _$SnPresenceActivityCopyWithImpl;
@useResult
$Res call({
String id, String type, String? manualId, String? title, String? subtitle, String? caption, Map<String, dynamic>? meta, int leaseMinutes, DateTime leaseExpiresAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
String id, int type, String? manualId, String? title, String? subtitle, String? caption, String? titleUrl, String? subtitleUrl, String? smallImage, String? largeImage, Map<String, dynamic>? meta, int leaseMinutes, DateTime leaseExpiresAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
@@ -1479,14 +1479,18 @@ class _$SnPresenceActivityCopyWithImpl<$Res>
/// Create a copy of SnPresenceActivity
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? type = null,Object? manualId = freezed,Object? title = freezed,Object? subtitle = freezed,Object? caption = freezed,Object? meta = freezed,Object? leaseMinutes = null,Object? leaseExpiresAt = null,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? type = null,Object? manualId = freezed,Object? title = freezed,Object? subtitle = freezed,Object? caption = freezed,Object? titleUrl = freezed,Object? subtitleUrl = freezed,Object? smallImage = freezed,Object? largeImage = freezed,Object? meta = freezed,Object? leaseMinutes = null,Object? leaseExpiresAt = null,Object? accountId = 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,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as String,manualId: freezed == manualId ? _self.manualId : manualId // ignore: cast_nullable_to_non_nullable
as int,manualId: freezed == manualId ? _self.manualId : manualId // ignore: cast_nullable_to_non_nullable
as String?,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
as String?,subtitle: freezed == subtitle ? _self.subtitle : subtitle // ignore: cast_nullable_to_non_nullable
as String?,caption: freezed == caption ? _self.caption : caption // ignore: cast_nullable_to_non_nullable
as String?,titleUrl: freezed == titleUrl ? _self.titleUrl : titleUrl // ignore: cast_nullable_to_non_nullable
as String?,subtitleUrl: freezed == subtitleUrl ? _self.subtitleUrl : subtitleUrl // ignore: cast_nullable_to_non_nullable
as String?,smallImage: freezed == smallImage ? _self.smallImage : smallImage // ignore: cast_nullable_to_non_nullable
as String?,largeImage: freezed == largeImage ? _self.largeImage : largeImage // ignore: cast_nullable_to_non_nullable
as String?,meta: freezed == meta ? _self.meta : meta // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,leaseMinutes: null == leaseMinutes ? _self.leaseMinutes : leaseMinutes // ignore: cast_nullable_to_non_nullable
as int,leaseExpiresAt: null == leaseExpiresAt ? _self.leaseExpiresAt : leaseExpiresAt // ignore: cast_nullable_to_non_nullable
@@ -1576,10 +1580,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String type, String? manualId, String? title, String? subtitle, String? caption, Map<String, dynamic>? meta, int leaseMinutes, DateTime leaseExpiresAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, int type, String? manualId, String? title, String? subtitle, String? caption, String? titleUrl, String? subtitleUrl, String? smallImage, String? largeImage, Map<String, dynamic>? meta, int leaseMinutes, DateTime leaseExpiresAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SnPresenceActivity() when $default != null:
return $default(_that.id,_that.type,_that.manualId,_that.title,_that.subtitle,_that.caption,_that.meta,_that.leaseMinutes,_that.leaseExpiresAt,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return $default(_that.id,_that.type,_that.manualId,_that.title,_that.subtitle,_that.caption,_that.titleUrl,_that.subtitleUrl,_that.smallImage,_that.largeImage,_that.meta,_that.leaseMinutes,_that.leaseExpiresAt,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return orElse();
}
@@ -1597,10 +1601,10 @@ return $default(_that.id,_that.type,_that.manualId,_that.title,_that.subtitle,_t
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String type, String? manualId, String? title, String? subtitle, String? caption, Map<String, dynamic>? meta, int leaseMinutes, DateTime leaseExpiresAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, int type, String? manualId, String? title, String? subtitle, String? caption, String? titleUrl, String? subtitleUrl, String? smallImage, String? largeImage, Map<String, dynamic>? meta, int leaseMinutes, DateTime leaseExpiresAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
switch (_that) {
case _SnPresenceActivity():
return $default(_that.id,_that.type,_that.manualId,_that.title,_that.subtitle,_that.caption,_that.meta,_that.leaseMinutes,_that.leaseExpiresAt,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);}
return $default(_that.id,_that.type,_that.manualId,_that.title,_that.subtitle,_that.caption,_that.titleUrl,_that.subtitleUrl,_that.smallImage,_that.largeImage,_that.meta,_that.leaseMinutes,_that.leaseExpiresAt,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);}
}
/// A variant of `when` that fallback to returning `null`
///
@@ -1614,10 +1618,10 @@ return $default(_that.id,_that.type,_that.manualId,_that.title,_that.subtitle,_t
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String type, String? manualId, String? title, String? subtitle, String? caption, Map<String, dynamic>? meta, int leaseMinutes, DateTime leaseExpiresAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, int type, String? manualId, String? title, String? subtitle, String? caption, String? titleUrl, String? subtitleUrl, String? smallImage, String? largeImage, Map<String, dynamic>? meta, int leaseMinutes, DateTime leaseExpiresAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
switch (_that) {
case _SnPresenceActivity() when $default != null:
return $default(_that.id,_that.type,_that.manualId,_that.title,_that.subtitle,_that.caption,_that.meta,_that.leaseMinutes,_that.leaseExpiresAt,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return $default(_that.id,_that.type,_that.manualId,_that.title,_that.subtitle,_that.caption,_that.titleUrl,_that.subtitleUrl,_that.smallImage,_that.largeImage,_that.meta,_that.leaseMinutes,_that.leaseExpiresAt,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return null;
}
@@ -1629,15 +1633,19 @@ return $default(_that.id,_that.type,_that.manualId,_that.title,_that.subtitle,_t
@JsonSerializable()
class _SnPresenceActivity implements SnPresenceActivity {
const _SnPresenceActivity({required this.id, required this.type, required this.manualId, required this.title, required this.subtitle, required this.caption, required final Map<String, dynamic>? meta, required this.leaseMinutes, required this.leaseExpiresAt, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt}): _meta = meta;
const _SnPresenceActivity({required this.id, required this.type, required this.manualId, required this.title, required this.subtitle, required this.caption, required this.titleUrl, required this.subtitleUrl, required this.smallImage, required this.largeImage, required final Map<String, dynamic>? meta, required this.leaseMinutes, required this.leaseExpiresAt, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt}): _meta = meta;
factory _SnPresenceActivity.fromJson(Map<String, dynamic> json) => _$SnPresenceActivityFromJson(json);
@override final String id;
@override final String type;
@override final int type;
@override final String? manualId;
@override final String? title;
@override final String? subtitle;
@override final String? caption;
@override final String? titleUrl;
@override final String? subtitleUrl;
@override final String? smallImage;
@override final String? largeImage;
final Map<String, dynamic>? _meta;
@override Map<String, dynamic>? get meta {
final value = _meta;
@@ -1667,16 +1675,16 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnPresenceActivity&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.manualId, manualId) || other.manualId == manualId)&&(identical(other.title, title) || other.title == title)&&(identical(other.subtitle, subtitle) || other.subtitle == subtitle)&&(identical(other.caption, caption) || other.caption == caption)&&const DeepCollectionEquality().equals(other._meta, _meta)&&(identical(other.leaseMinutes, leaseMinutes) || other.leaseMinutes == leaseMinutes)&&(identical(other.leaseExpiresAt, leaseExpiresAt) || other.leaseExpiresAt == leaseExpiresAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(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 _SnPresenceActivity&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.manualId, manualId) || other.manualId == manualId)&&(identical(other.title, title) || other.title == title)&&(identical(other.subtitle, subtitle) || other.subtitle == subtitle)&&(identical(other.caption, caption) || other.caption == caption)&&(identical(other.titleUrl, titleUrl) || other.titleUrl == titleUrl)&&(identical(other.subtitleUrl, subtitleUrl) || other.subtitleUrl == subtitleUrl)&&(identical(other.smallImage, smallImage) || other.smallImage == smallImage)&&(identical(other.largeImage, largeImage) || other.largeImage == largeImage)&&const DeepCollectionEquality().equals(other._meta, _meta)&&(identical(other.leaseMinutes, leaseMinutes) || other.leaseMinutes == leaseMinutes)&&(identical(other.leaseExpiresAt, leaseExpiresAt) || other.leaseExpiresAt == leaseExpiresAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(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,type,manualId,title,subtitle,caption,const DeepCollectionEquality().hash(_meta),leaseMinutes,leaseExpiresAt,accountId,createdAt,updatedAt,deletedAt);
int get hashCode => Object.hash(runtimeType,id,type,manualId,title,subtitle,caption,titleUrl,subtitleUrl,smallImage,largeImage,const DeepCollectionEquality().hash(_meta),leaseMinutes,leaseExpiresAt,accountId,createdAt,updatedAt,deletedAt);
@override
String toString() {
return 'SnPresenceActivity(id: $id, type: $type, manualId: $manualId, title: $title, subtitle: $subtitle, caption: $caption, meta: $meta, leaseMinutes: $leaseMinutes, leaseExpiresAt: $leaseExpiresAt, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
return 'SnPresenceActivity(id: $id, type: $type, manualId: $manualId, title: $title, subtitle: $subtitle, caption: $caption, titleUrl: $titleUrl, subtitleUrl: $subtitleUrl, smallImage: $smallImage, largeImage: $largeImage, meta: $meta, leaseMinutes: $leaseMinutes, leaseExpiresAt: $leaseExpiresAt, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
@@ -1687,7 +1695,7 @@ abstract mixin class _$SnPresenceActivityCopyWith<$Res> implements $SnPresenceAc
factory _$SnPresenceActivityCopyWith(_SnPresenceActivity value, $Res Function(_SnPresenceActivity) _then) = __$SnPresenceActivityCopyWithImpl;
@override @useResult
$Res call({
String id, String type, String? manualId, String? title, String? subtitle, String? caption, Map<String, dynamic>? meta, int leaseMinutes, DateTime leaseExpiresAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
String id, int type, String? manualId, String? title, String? subtitle, String? caption, String? titleUrl, String? subtitleUrl, String? smallImage, String? largeImage, Map<String, dynamic>? meta, int leaseMinutes, DateTime leaseExpiresAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
@@ -1704,14 +1712,18 @@ class __$SnPresenceActivityCopyWithImpl<$Res>
/// Create a copy of SnPresenceActivity
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? type = null,Object? manualId = freezed,Object? title = freezed,Object? subtitle = freezed,Object? caption = freezed,Object? meta = freezed,Object? leaseMinutes = null,Object? leaseExpiresAt = null,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? type = null,Object? manualId = freezed,Object? title = freezed,Object? subtitle = freezed,Object? caption = freezed,Object? titleUrl = freezed,Object? subtitleUrl = freezed,Object? smallImage = freezed,Object? largeImage = freezed,Object? meta = freezed,Object? leaseMinutes = null,Object? leaseExpiresAt = null,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_SnPresenceActivity(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as String,manualId: freezed == manualId ? _self.manualId : manualId // ignore: cast_nullable_to_non_nullable
as int,manualId: freezed == manualId ? _self.manualId : manualId // ignore: cast_nullable_to_non_nullable
as String?,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
as String?,subtitle: freezed == subtitle ? _self.subtitle : subtitle // ignore: cast_nullable_to_non_nullable
as String?,caption: freezed == caption ? _self.caption : caption // ignore: cast_nullable_to_non_nullable
as String?,titleUrl: freezed == titleUrl ? _self.titleUrl : titleUrl // ignore: cast_nullable_to_non_nullable
as String?,subtitleUrl: freezed == subtitleUrl ? _self.subtitleUrl : subtitleUrl // ignore: cast_nullable_to_non_nullable
as String?,smallImage: freezed == smallImage ? _self.smallImage : smallImage // ignore: cast_nullable_to_non_nullable
as String?,largeImage: freezed == largeImage ? _self.largeImage : largeImage // ignore: cast_nullable_to_non_nullable
as String?,meta: freezed == meta ? _self._meta : meta // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,leaseMinutes: null == leaseMinutes ? _self.leaseMinutes : leaseMinutes // ignore: cast_nullable_to_non_nullable
as int,leaseExpiresAt: null == leaseExpiresAt ? _self.leaseExpiresAt : leaseExpiresAt // ignore: cast_nullable_to_non_nullable

View File

@@ -27,20 +27,21 @@ Map<String, dynamic> _$SnNotableDayToJson(_SnNotableDay instance) =>
'holidays': instance.holidays,
};
_SnActivity _$SnActivityFromJson(Map<String, dynamic> json) => _SnActivity(
id: json['id'] as String,
type: json['type'] as String,
resourceIdentifier: json['resource_identifier'] as String,
data: json['data'],
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt:
json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
);
_SnTimelineEvent _$SnTimelineEventFromJson(Map<String, dynamic> json) =>
_SnTimelineEvent(
id: json['id'] as String,
type: json['type'] as String,
resourceIdentifier: json['resource_identifier'] as String,
data: json['data'],
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt:
json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
);
Map<String, dynamic> _$SnActivityToJson(_SnActivity instance) =>
Map<String, dynamic> _$SnTimelineEventToJson(_SnTimelineEvent instance) =>
<String, dynamic>{
'id': instance.id,
'type': instance.type,
@@ -125,11 +126,15 @@ Map<String, dynamic> _$SnEventCalendarEntryToJson(
_SnPresenceActivity _$SnPresenceActivityFromJson(Map<String, dynamic> json) =>
_SnPresenceActivity(
id: json['id'] as String,
type: json['type'] as String,
type: (json['type'] as num).toInt(),
manualId: json['manual_id'] as String?,
title: json['title'] as String?,
subtitle: json['subtitle'] as String?,
caption: json['caption'] as String?,
titleUrl: json['title_url'] as String?,
subtitleUrl: json['subtitle_url'] as String?,
smallImage: json['small_image'] as String?,
largeImage: json['large_image'] as String?,
meta: json['meta'] as Map<String, dynamic>?,
leaseMinutes: (json['lease_minutes'] as num).toInt(),
leaseExpiresAt: DateTime.parse(json['lease_expires_at'] as String),
@@ -150,6 +155,10 @@ Map<String, dynamic> _$SnPresenceActivityToJson(_SnPresenceActivity instance) =>
'title': instance.title,
'subtitle': instance.subtitle,
'caption': instance.caption,
'title_url': instance.titleUrl,
'subtitle_url': instance.subtitleUrl,
'small_image': instance.smallImage,
'large_image': instance.largeImage,
'meta': instance.meta,
'lease_minutes': instance.leaseMinutes,
'lease_expires_at': instance.leaseExpiresAt.toIso8601String(),

View File

@@ -8,7 +8,7 @@ part 'poll.g.dart';
@freezed
sealed class SnPollWithStats with _$SnPollWithStats {
const factory SnPollWithStats({
required Map<String, dynamic>? userAnswer,
required SnPollAnswer? userAnswer,
@Default({}) Map<String, dynamic> stats,
required String id,
required List<SnPollQuestion> questions,

View File

@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$SnPollWithStats {
Map<String, dynamic>? get userAnswer; Map<String, dynamic> get stats; String get id; List<SnPollQuestion> get questions; String? get title; String? get description; DateTime? get endedAt; String get publisherId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
SnPollAnswer? get userAnswer; Map<String, dynamic> get stats; String get id; List<SnPollQuestion> get questions; String? get title; String? get description; DateTime? get endedAt; String get publisherId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
/// Create a copy of SnPollWithStats
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -28,12 +28,12 @@ $SnPollWithStatsCopyWith<SnPollWithStats> get copyWith => _$SnPollWithStatsCopyW
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnPollWithStats&&const DeepCollectionEquality().equals(other.userAnswer, userAnswer)&&const DeepCollectionEquality().equals(other.stats, stats)&&(identical(other.id, id) || other.id == id)&&const DeepCollectionEquality().equals(other.questions, questions)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.endedAt, endedAt) || other.endedAt == endedAt)&&(identical(other.publisherId, publisherId) || other.publisherId == publisherId)&&(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 SnPollWithStats&&(identical(other.userAnswer, userAnswer) || other.userAnswer == userAnswer)&&const DeepCollectionEquality().equals(other.stats, stats)&&(identical(other.id, id) || other.id == id)&&const DeepCollectionEquality().equals(other.questions, questions)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.endedAt, endedAt) || other.endedAt == endedAt)&&(identical(other.publisherId, publisherId) || other.publisherId == publisherId)&&(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,const DeepCollectionEquality().hash(userAnswer),const DeepCollectionEquality().hash(stats),id,const DeepCollectionEquality().hash(questions),title,description,endedAt,publisherId,createdAt,updatedAt,deletedAt);
int get hashCode => Object.hash(runtimeType,userAnswer,const DeepCollectionEquality().hash(stats),id,const DeepCollectionEquality().hash(questions),title,description,endedAt,publisherId,createdAt,updatedAt,deletedAt);
@override
String toString() {
@@ -48,11 +48,11 @@ abstract mixin class $SnPollWithStatsCopyWith<$Res> {
factory $SnPollWithStatsCopyWith(SnPollWithStats value, $Res Function(SnPollWithStats) _then) = _$SnPollWithStatsCopyWithImpl;
@useResult
$Res call({
Map<String, dynamic>? userAnswer, Map<String, dynamic> stats, String id, List<SnPollQuestion> questions, String? title, String? description, DateTime? endedAt, String publisherId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
SnPollAnswer? userAnswer, Map<String, dynamic> stats, String id, List<SnPollQuestion> questions, String? title, String? description, DateTime? endedAt, String publisherId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
$SnPollAnswerCopyWith<$Res>? get userAnswer;
}
/// @nodoc
@@ -68,7 +68,7 @@ class _$SnPollWithStatsCopyWithImpl<$Res>
@pragma('vm:prefer-inline') @override $Res call({Object? userAnswer = freezed,Object? stats = null,Object? id = null,Object? questions = null,Object? title = freezed,Object? description = freezed,Object? endedAt = freezed,Object? publisherId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_self.copyWith(
userAnswer: freezed == userAnswer ? _self.userAnswer : userAnswer // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,stats: null == stats ? _self.stats : stats // ignore: cast_nullable_to_non_nullable
as SnPollAnswer?,stats: null == stats ? _self.stats : stats // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,questions: null == questions ? _self.questions : questions // ignore: cast_nullable_to_non_nullable
as List<SnPollQuestion>,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
@@ -81,7 +81,19 @@ as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ign
as DateTime?,
));
}
/// Create a copy of SnPollWithStats
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnPollAnswerCopyWith<$Res>? get userAnswer {
if (_self.userAnswer == null) {
return null;
}
return $SnPollAnswerCopyWith<$Res>(_self.userAnswer!, (value) {
return _then(_self.copyWith(userAnswer: value));
});
}
}
@@ -160,7 +172,7 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( Map<String, dynamic>? userAnswer, Map<String, dynamic> stats, String id, List<SnPollQuestion> questions, String? title, String? description, DateTime? endedAt, String publisherId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( SnPollAnswer? userAnswer, Map<String, dynamic> stats, String id, List<SnPollQuestion> questions, String? title, String? description, DateTime? endedAt, String publisherId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SnPollWithStats() when $default != null:
return $default(_that.userAnswer,_that.stats,_that.id,_that.questions,_that.title,_that.description,_that.endedAt,_that.publisherId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
@@ -181,7 +193,7 @@ return $default(_that.userAnswer,_that.stats,_that.id,_that.questions,_that.titl
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( Map<String, dynamic>? userAnswer, Map<String, dynamic> stats, String id, List<SnPollQuestion> questions, String? title, String? description, DateTime? endedAt, String publisherId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( SnPollAnswer? userAnswer, Map<String, dynamic> stats, String id, List<SnPollQuestion> questions, String? title, String? description, DateTime? endedAt, String publisherId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
switch (_that) {
case _SnPollWithStats():
return $default(_that.userAnswer,_that.stats,_that.id,_that.questions,_that.title,_that.description,_that.endedAt,_that.publisherId,_that.createdAt,_that.updatedAt,_that.deletedAt);}
@@ -198,7 +210,7 @@ return $default(_that.userAnswer,_that.stats,_that.id,_that.questions,_that.titl
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( Map<String, dynamic>? userAnswer, Map<String, dynamic> stats, String id, List<SnPollQuestion> questions, String? title, String? description, DateTime? endedAt, String publisherId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( SnPollAnswer? userAnswer, Map<String, dynamic> stats, String id, List<SnPollQuestion> questions, String? title, String? description, DateTime? endedAt, String publisherId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
switch (_that) {
case _SnPollWithStats() when $default != null:
return $default(_that.userAnswer,_that.stats,_that.id,_that.questions,_that.title,_that.description,_that.endedAt,_that.publisherId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
@@ -213,18 +225,10 @@ return $default(_that.userAnswer,_that.stats,_that.id,_that.questions,_that.titl
@JsonSerializable()
class _SnPollWithStats implements SnPollWithStats {
const _SnPollWithStats({required final Map<String, dynamic>? userAnswer, final Map<String, dynamic> stats = const {}, required this.id, required final List<SnPollQuestion> questions, this.title, this.description, this.endedAt, required this.publisherId, required this.createdAt, required this.updatedAt, this.deletedAt}): _userAnswer = userAnswer,_stats = stats,_questions = questions;
const _SnPollWithStats({required this.userAnswer, final Map<String, dynamic> stats = const {}, required this.id, required final List<SnPollQuestion> questions, this.title, this.description, this.endedAt, required this.publisherId, required this.createdAt, required this.updatedAt, this.deletedAt}): _stats = stats,_questions = questions;
factory _SnPollWithStats.fromJson(Map<String, dynamic> json) => _$SnPollWithStatsFromJson(json);
final Map<String, dynamic>? _userAnswer;
@override Map<String, dynamic>? get userAnswer {
final value = _userAnswer;
if (value == null) return null;
if (_userAnswer is EqualUnmodifiableMapView) return _userAnswer;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(value);
}
@override final SnPollAnswer? userAnswer;
final Map<String, dynamic> _stats;
@override@JsonKey() Map<String, dynamic> get stats {
if (_stats is EqualUnmodifiableMapView) return _stats;
@@ -261,12 +265,12 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnPollWithStats&&const DeepCollectionEquality().equals(other._userAnswer, _userAnswer)&&const DeepCollectionEquality().equals(other._stats, _stats)&&(identical(other.id, id) || other.id == id)&&const DeepCollectionEquality().equals(other._questions, _questions)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.endedAt, endedAt) || other.endedAt == endedAt)&&(identical(other.publisherId, publisherId) || other.publisherId == publisherId)&&(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 _SnPollWithStats&&(identical(other.userAnswer, userAnswer) || other.userAnswer == userAnswer)&&const DeepCollectionEquality().equals(other._stats, _stats)&&(identical(other.id, id) || other.id == id)&&const DeepCollectionEquality().equals(other._questions, _questions)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.endedAt, endedAt) || other.endedAt == endedAt)&&(identical(other.publisherId, publisherId) || other.publisherId == publisherId)&&(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,const DeepCollectionEquality().hash(_userAnswer),const DeepCollectionEquality().hash(_stats),id,const DeepCollectionEquality().hash(_questions),title,description,endedAt,publisherId,createdAt,updatedAt,deletedAt);
int get hashCode => Object.hash(runtimeType,userAnswer,const DeepCollectionEquality().hash(_stats),id,const DeepCollectionEquality().hash(_questions),title,description,endedAt,publisherId,createdAt,updatedAt,deletedAt);
@override
String toString() {
@@ -281,11 +285,11 @@ abstract mixin class _$SnPollWithStatsCopyWith<$Res> implements $SnPollWithStats
factory _$SnPollWithStatsCopyWith(_SnPollWithStats value, $Res Function(_SnPollWithStats) _then) = __$SnPollWithStatsCopyWithImpl;
@override @useResult
$Res call({
Map<String, dynamic>? userAnswer, Map<String, dynamic> stats, String id, List<SnPollQuestion> questions, String? title, String? description, DateTime? endedAt, String publisherId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
SnPollAnswer? userAnswer, Map<String, dynamic> stats, String id, List<SnPollQuestion> questions, String? title, String? description, DateTime? endedAt, String publisherId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
@override $SnPollAnswerCopyWith<$Res>? get userAnswer;
}
/// @nodoc
@@ -300,8 +304,8 @@ class __$SnPollWithStatsCopyWithImpl<$Res>
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? userAnswer = freezed,Object? stats = null,Object? id = null,Object? questions = null,Object? title = freezed,Object? description = freezed,Object? endedAt = freezed,Object? publisherId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_SnPollWithStats(
userAnswer: freezed == userAnswer ? _self._userAnswer : userAnswer // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,stats: null == stats ? _self._stats : stats // ignore: cast_nullable_to_non_nullable
userAnswer: freezed == userAnswer ? _self.userAnswer : userAnswer // ignore: cast_nullable_to_non_nullable
as SnPollAnswer?,stats: null == stats ? _self._stats : stats // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,questions: null == questions ? _self._questions : questions // ignore: cast_nullable_to_non_nullable
as List<SnPollQuestion>,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
@@ -315,7 +319,19 @@ as DateTime?,
));
}
/// Create a copy of SnPollWithStats
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnPollAnswerCopyWith<$Res>? get userAnswer {
if (_self.userAnswer == null) {
return null;
}
return $SnPollAnswerCopyWith<$Res>(_self.userAnswer!, (value) {
return _then(_self.copyWith(userAnswer: value));
});
}
}

View File

@@ -8,7 +8,12 @@ part of 'poll.dart';
_SnPollWithStats _$SnPollWithStatsFromJson(Map<String, dynamic> json) =>
_SnPollWithStats(
userAnswer: json['user_answer'] as Map<String, dynamic>?,
userAnswer:
json['user_answer'] == null
? null
: SnPollAnswer.fromJson(
json['user_answer'] as Map<String, dynamic>,
),
stats: json['stats'] as Map<String, dynamic>? ?? const {},
id: json['id'] as String,
questions:
@@ -32,7 +37,7 @@ _SnPollWithStats _$SnPollWithStatsFromJson(Map<String, dynamic> json) =>
Map<String, dynamic> _$SnPollWithStatsToJson(_SnPollWithStats instance) =>
<String, dynamic>{
'user_answer': instance.userAnswer,
'user_answer': instance.userAnswer?.toJson(),
'stats': instance.stats,
'id': instance.id,
'questions': instance.questions.map((e) => e.toJson()).toList(),

View File

@@ -1,24 +1,23 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart' hide Response;
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/account.dart';
import 'package:island/models/activity.dart';
import 'package:island/pods/network.dart';
import 'package:island/talker.dart';
import 'package:island/widgets/account/status.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_web_socket/shelf_web_socket.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
part 'activity_rpc.g.dart';
// Conditional imports for IPC server - use web stubs on web platform
import 'ipc_server.dart' if (dart.library.html) 'ipc_server.web.dart';
part 'activity_rpc.g.dart';
const String kRpcLogPrefix = 'arRPC.websocket';
const String kRpcIpcLogPrefix = 'arRPC.ipc';
@@ -120,12 +119,17 @@ class ActivityRpcServer {
_handleIpcPacket(socket, packet);
};
// Set up IPC close handler
_ipcServer!.onSocketClose = (socket) {
handlers['close']?.call(socket);
};
await _ipcServer!.start();
} catch (e) {
talker.log('[$kRpcLogPrefix] IPC server error: $e');
}
} else {
talker.log('IPC server disabled on macOS or web in production mode');
talker.log('IPC server disabled on macOS or web');
}
}
@@ -299,33 +303,41 @@ class ServerState {
final String status;
final List<String> activities;
final String? currentActivityManualId;
final Map<String, dynamic>? currentActivityData;
ServerState({
required this.status,
this.activities = const [],
this.currentActivityManualId,
this.currentActivityData,
});
ServerState copyWith({
String? status,
List<String>? activities,
String? currentActivityManualId,
Map<String, dynamic>? currentActivityData,
}) {
return ServerState(
status: status ?? this.status,
activities: activities ?? this.activities,
currentActivityManualId:
currentActivityManualId ?? this.currentActivityManualId,
currentActivityData: currentActivityData ?? this.currentActivityData,
);
}
}
class ServerStateNotifier extends StateNotifier<ServerState> {
final ActivityRpcServer server;
final Dio apiClient;
Timer? _renewalTimer;
ServerStateNotifier(this.server)
ServerStateNotifier(this.apiClient, this.server)
: super(ServerState(status: 'Server not started'));
String? get currentActivityManualId => state.currentActivityManualId;
Future<void> start() async {
if (!kIsWeb && !Platform.isAndroid && !Platform.isIOS) {
try {
@@ -349,119 +361,164 @@ class ServerStateNotifier extends StateNotifier<ServerState> {
state = state.copyWith(activities: [...state.activities, activity]);
}
void setCurrentActivityManualId(String? id) {
state = state.copyWith(currentActivityManualId: id);
void setCurrentActivity(String? id, Map<String, dynamic>? data) {
state = state.copyWith(
currentActivityManualId: id,
currentActivityData: data,
);
if (id != null && data != null) {
_startRenewal();
} else {
_stopRenewal();
}
}
void _startRenewal() {
_renewalTimer?.cancel();
const int renewalIntervalSeconds = kPresenceActivityLease * 60 - 30;
_renewalTimer = Timer.periodic(Duration(seconds: renewalIntervalSeconds), (
timer,
) {
_renewActivity();
});
}
void _stopRenewal() {
_renewalTimer?.cancel();
_renewalTimer = null;
}
Future<void> _renewActivity() async {
if (state.currentActivityData != null) {
try {
await apiClient.post(
'/pass/activities',
data: state.currentActivityData,
);
talker.log('Activity lease renewed');
} catch (e) {
talker.log('Failed to renew activity lease: $e');
}
}
}
}
const kPresenceActivityLease = 5;
// Providers
final rpcServerStateProvider =
StateNotifierProvider<ServerStateNotifier, ServerState>((ref) {
final server = ActivityRpcServer({});
final notifier = ServerStateNotifier(server);
server.updateHandlers({
'connection': (socket) {
final clientId =
socket is _WsSocketWrapper
? socket.clientId
: (socket as IpcSocketWrapper).clientId;
notifier.updateStatus('Client connected (ID: $clientId)');
socket.send({
'cmd': 'DISPATCH',
'data': {
'v': 1,
'config': {
'cdn_host': 'fake.cdn',
'api_endpoint': '//fake.api',
'environment': 'dev',
},
'user': {
'id': 'fake_user_id',
'username': 'FakeUser',
'discriminator': '0001',
'avatar': null,
'bot': false,
},
},
'evt': 'READY',
'nonce': '12345',
});
},
'message': (socket, dynamic data) async {
if (data['cmd'] == 'SET_ACTIVITY') {
notifier.addActivity(
'Activity: ${data['args']['activity']['details'] ?? ''}',
);
final label = data['args']['activity']['details'] ?? '';
final appId = socket.clientId;
final meta = data['args']['activity'];
try {
final apiClient = ref.watch(apiClientProvider);
final currentId = notifier.state.currentActivityManualId;
final isUpdate = currentId == appId;
final activityData = {
'type': 'Gaming',
'manualId': appId,
'title': label,
'meta': meta,
'leaseMinutes': 30,
};
if (isUpdate) {
await apiClient.put(
'/pass/activities',
queryParameters: {'manual_id': appId},
data: {'leaseMinutes': 30},
);
} else {
await apiClient.post('/pass/activities', data: activityData);
notifier.setCurrentActivityManualId(appId);
}
final now = DateTime.now();
final status = SnAccountStatus(
id: 'local_$appId',
attitude: 0,
isOnline: true,
isInvisible: false,
isNotDisturb: false,
isCustomized: true,
label: label,
meta: meta,
clearedAt: null,
accountId: 'me',
createdAt: now,
updatedAt: now,
deletedAt: null,
);
ref.read(currentAccountStatusProvider.notifier).setStatus(status);
} catch (e) {
talker.log('Failed to set remote activity status: $e');
}
socket.send({
'cmd': 'SET_ACTIVITY',
'data': data['args']['activity'],
'evt': null,
'nonce': data['nonce'],
});
}
},
'close': (socket) async {
notifier.updateStatus('Client disconnected');
final appId = socket.clientId;
try {
final apiClient = ref.watch(apiClientProvider);
await apiClient.delete(
'/pass/activities',
queryParameters: {'manual_id': appId},
);
notifier.setCurrentActivityManualId(null);
ref.read(currentAccountStatusProvider.notifier).clearStatus();
} catch (e) {
talker.log('Failed to unset remote activity status: $e');
}
final rpcServerStateProvider = StateNotifierProvider<
ServerStateNotifier,
ServerState
>((ref) {
final apiClient = ref.watch(apiClientProvider);
final server = ActivityRpcServer({});
final notifier = ServerStateNotifier(apiClient, server);
server.updateHandlers({
'connection': (socket) {
final clientId =
socket is _WsSocketWrapper
? socket.clientId
: (socket as IpcSocketWrapper).clientId;
notifier.updateStatus('Client connected (ID: $clientId)');
socket.send({
'cmd': 'DISPATCH',
'data': {
'v': 1,
'config': {
'cdn_host': 'fake.cdn',
'api_endpoint': '//fake.api',
'environment': 'dev',
},
'user': {
'id': 'fake_user_id',
'username': 'FakeUser',
'discriminator': '0001',
'avatar': null,
'bot': false,
},
},
'evt': 'READY',
'nonce': '12345',
});
return notifier;
});
},
'message': (socket, dynamic data) async {
if (data['cmd'] == 'SET_ACTIVITY') {
final activity = data['args']['activity'];
final appId = 'rpc:${socket.clientId}';
final currentId = notifier.currentActivityManualId;
if (currentId != null && currentId != appId) {
talker.info(
'Skipped the new SET_ACTIVITY command due to there is one existing...',
);
return;
}
notifier.addActivity('Activity: ${activity['details'] ?? 'Untitled'}');
// https://discord.com/developers/docs/topics/rpc#setactivity-set-activity-argument-structure
final type = switch (activity['type']) {
0 => 1, // Discord Playing -> Playing
2 => 2, // Discord Music -> Listening
3 => 2, // Discord Watching -> Listening
_ => 1, // Discord Competing (or null) -> Playing
};
final title = activity['name'] ?? activity['assets']?['small_text'];
final subtitle =
activity['details'] ?? activity['assets']?['large_text'];
var imageSmall = activity['assets']?['small_image'];
var imageLarge = activity['assets']?['large_image'];
if (imageSmall != null && !imageSmall!.contains(':')) {
imageSmall = 'discord:$imageSmall';
}
if (imageLarge != null && !imageLarge!.contains(':')) {
imageLarge = 'discord:$imageLarge';
}
try {
final apiClient = ref.watch(apiClientProvider);
final activityData = {
'type': type,
'manual_id': appId,
'title': title,
'subtitle': subtitle,
'caption': activity['state'],
'title_url': activity['assets']?['small_text_url'],
'subtitle_url': activity['assets']?['large_text_url'],
'small_image': imageSmall,
'large_image': imageLarge,
'meta': activity,
'lease_minutes': kPresenceActivityLease,
};
await apiClient.post('/pass/activities', data: activityData);
notifier.setCurrentActivity(appId, activityData);
} catch (e) {
talker.log('Failed to set remote activity status: $e');
}
socket.send({
'cmd': 'SET_ACTIVITY',
'data': data['args']['activity'],
'evt': null,
'nonce': data['nonce'],
});
}
},
'close': (socket) async {
notifier.updateStatus('Client disconnected');
final currentId = notifier.currentActivityManualId;
try {
final apiClient = ref.watch(apiClientProvider);
await apiClient.delete(
'/pass/activities',
queryParameters: {'manualId': currentId},
);
notifier.setCurrentActivity(null, null);
} catch (e) {
talker.log('Failed to unset remote activity status: $e');
}
},
});
return notifier;
});
final rpcServerProvider = Provider<ActivityRpcServer>((ref) {
final notifier = ref.watch(rpcServerStateProvider.notifier);
@@ -473,8 +530,15 @@ Future<List<SnPresenceActivity>> presenceActivities(
Ref ref,
String uname,
) async {
ref.keepAlive();
final timer = Timer.periodic(
const Duration(minutes: 1),
(_) => ref.invalidateSelf(),
);
ref.onDispose(() => timer.cancel());
final apiClient = ref.watch(apiClientProvider);
final response = await apiClient.get('/pass/accounts/$uname/activities');
final response = await apiClient.get('/pass/activities/$uname');
final data = response.data as List<dynamic>;
return data.map((json) => SnPresenceActivity.fromJson(json)).toList();
}

View File

@@ -0,0 +1,157 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'activity_rpc.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$presenceActivitiesHash() =>
r'3bfaa638eeb961ecd62a32d6a7760a6a7e7bf6f2';
/// 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 [presenceActivities].
@ProviderFor(presenceActivities)
const presenceActivitiesProvider = PresenceActivitiesFamily();
/// See also [presenceActivities].
class PresenceActivitiesFamily
extends Family<AsyncValue<List<SnPresenceActivity>>> {
/// See also [presenceActivities].
const PresenceActivitiesFamily();
/// See also [presenceActivities].
PresenceActivitiesProvider call(String uname) {
return PresenceActivitiesProvider(uname);
}
@override
PresenceActivitiesProvider getProviderOverride(
covariant PresenceActivitiesProvider provider,
) {
return call(provider.uname);
}
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'presenceActivitiesProvider';
}
/// See also [presenceActivities].
class PresenceActivitiesProvider
extends AutoDisposeFutureProvider<List<SnPresenceActivity>> {
/// See also [presenceActivities].
PresenceActivitiesProvider(String uname)
: this._internal(
(ref) => presenceActivities(ref as PresenceActivitiesRef, uname),
from: presenceActivitiesProvider,
name: r'presenceActivitiesProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$presenceActivitiesHash,
dependencies: PresenceActivitiesFamily._dependencies,
allTransitiveDependencies:
PresenceActivitiesFamily._allTransitiveDependencies,
uname: uname,
);
PresenceActivitiesProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.uname,
}) : super.internal();
final String uname;
@override
Override overrideWith(
FutureOr<List<SnPresenceActivity>> Function(PresenceActivitiesRef provider)
create,
) {
return ProviderOverride(
origin: this,
override: PresenceActivitiesProvider._internal(
(ref) => create(ref as PresenceActivitiesRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
uname: uname,
),
);
}
@override
AutoDisposeFutureProviderElement<List<SnPresenceActivity>> createElement() {
return _PresenceActivitiesProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is PresenceActivitiesProvider && other.uname == uname;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, uname.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin PresenceActivitiesRef
on AutoDisposeFutureProviderRef<List<SnPresenceActivity>> {
/// The parameter `uname` of this provider.
String get uname;
}
class _PresenceActivitiesProviderElement
extends AutoDisposeFutureProviderElement<List<SnPresenceActivity>>
with PresenceActivitiesRef {
_PresenceActivitiesProviderElement(super.provider);
@override
String get uname => (origin as PresenceActivitiesProvider).uname;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -79,6 +79,8 @@ abstract class IpcServer {
Map<String, Function> handlers,
)?
handlePacket;
void Function(IpcSocketWrapper socket)? onSocketClose;
}
// Abstract base class for IPC socket wrapper
@@ -178,6 +180,8 @@ class MultiPlatformIpcServer extends IpcServer {
},
onDone: () {
talker.log('IPC connection closed');
removeSocket(socket);
onSocketClose?.call(socket);
socket.close();
},
onError: (e) {

View File

@@ -8,11 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/screens/about.dart';
import 'package:island/screens/developers/app_detail.dart';
import 'package:island/screens/developers/bot_detail.dart';
import 'package:island/screens/developers/edit_app.dart';
import 'package:island/screens/developers/edit_bot.dart';
import 'package:island/screens/developers/hub.dart';
import 'package:island/screens/developers/new_app.dart';
import 'package:island/screens/developers/new_bot.dart';
import 'package:island/screens/developers/edit_project.dart';
import 'package:island/screens/developers/new_project.dart';
import 'package:island/screens/discovery/articles.dart';
@@ -50,8 +46,9 @@ import 'package:island/screens/creators/publishers_form.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';
import 'package:island/screens/posts/pub_profile.dart';
import 'package:island/screens/posts/publisher_profile.dart';
import 'package:island/screens/auth/login.dart';
import 'package:island/screens/auth/create_account.dart';
import 'package:island/screens/settings.dart';
@@ -106,22 +103,19 @@ final routerProvider = Provider<GoRouter>((ref) {
routes: [
// Standalone routes without bottom navigation
GoRoute(
name: 'postCompose',
path: '/posts/compose',
name: 'articleCompose',
path: '/articles/compose',
builder:
(context, state) => PostComposeScreen(
(context, state) => ArticleComposeScreen(
initialState: state.extra as PostComposeInitialState?,
type:
int.tryParse(state.uri.queryParameters['type'] ?? '0') ??
0,
),
),
GoRoute(
name: 'postEdit',
path: '/posts/:id/edit',
name: 'articleEdit',
path: '/articles/:id/edit',
builder: (context, state) {
final id = state.pathParameters['id']!;
return PostEditScreen(id: id);
return ArticleEditScreen(id: id);
},
),
GoRoute(
@@ -572,25 +566,6 @@ final routerProvider = Provider<GoRouter>((ref) {
return const SizedBox.shrink(); // Temporary placeholder
},
routes: [
GoRoute(
name: 'developerAppNew',
path: 'apps/new',
builder:
(context, state) => NewCustomAppScreen(
publisherName: state.pathParameters['name']!,
projectId: state.pathParameters['projectId']!,
),
),
GoRoute(
name: 'developerAppEdit',
path: 'apps/:id/edit',
builder:
(context, state) => EditAppScreen(
publisherName: state.pathParameters['name']!,
projectId: state.pathParameters['projectId']!,
id: state.pathParameters['id']!,
),
),
GoRoute(
name: 'developerAppDetail',
path: 'apps/:appId',
@@ -601,15 +576,6 @@ final routerProvider = Provider<GoRouter>((ref) {
appId: state.pathParameters['appId']!,
),
),
GoRoute(
name: 'developerBotNew',
path: 'bots/new',
builder:
(context, state) => NewBotScreen(
publisherName: state.pathParameters['name']!,
projectId: state.pathParameters['projectId']!,
),
),
GoRoute(
name: 'developerBotDetail',
path: 'bots/:botId',
@@ -620,16 +586,6 @@ final routerProvider = Provider<GoRouter>((ref) {
botId: state.pathParameters['botId']!,
),
),
GoRoute(
name: 'developerBotEdit',
path: 'bots/:id/edit',
builder:
(context, state) => EditBotScreen(
publisherName: state.pathParameters['name']!,
projectId: state.pathParameters['projectId']!,
id: state.pathParameters['id']!,
),
),
],
),
],

View File

@@ -8,6 +8,7 @@ import 'package:island/pods/userinfo.dart';
import 'package:island/screens/notification.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/account/account_name.dart';
import 'package:island/widgets/account/activity_presence.dart';
import 'package:island/widgets/account/status.dart';
import 'package:island/widgets/account/leveling_progress.dart';
import 'package:island/widgets/alert.dart';
@@ -74,72 +75,125 @@ class AccountScreen extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (user.value?.profile.background?.id != null)
ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(8),
topRight: Radius.circular(8),
),
child: AspectRatio(
aspectRatio: 16 / 7,
child: CloudImageWidget(
file: user.value?.profile.background,
fit: BoxFit.cover,
Stack(
clipBehavior: Clip.none,
children: [
ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(8),
topRight: Radius.circular(8),
),
child: AspectRatio(
aspectRatio: 16 / 7,
child: CloudImageWidget(
file: user.value?.profile.background,
fit: BoxFit.cover,
),
),
),
),
Positioned(
bottom: -24,
left: 16,
child: GestureDetector(
child: ProfilePictureWidget(
file: user.value?.profile.picture,
radius: 32,
),
onTap: () {
context.pushNamed(
'accountProfile',
pathParameters: {'name': user.value!.name},
);
},
),
),
],
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 16,
children: [
GestureDetector(
child: ProfilePictureWidget(
file: user.value?.profile.picture,
radius: 24,
),
onTap: () {
context.pushNamed(
'accountProfile',
pathParameters: {'name': user.value!.name},
);
},
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
spacing: 4,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
Builder(
builder: (context) {
final hasBackground =
user.value?.profile.background?.id != null;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: hasBackground ? 0 : 16,
children: [
if (!hasBackground)
GestureDetector(
child: ProfilePictureWidget(
file: user.value?.profile.picture,
radius: 24,
),
onTap: () {
context.pushNamed(
'accountProfile',
pathParameters: {'name': user.value!.name},
);
},
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: AccountName(
account: user.value!,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
Row(
spacing: 4,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Flexible(
child: AccountName(
account: user.value!,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
Flexible(
child: Text(
'@${user.value!.name}',
).fontSize(11).padding(bottom: 2.5),
),
],
),
Flexible(child: Text('@${user.value!.name}')),
Text(
(user.value!.profile.bio.isNotEmpty)
? user.value!.profile.bio
: 'descriptionNone'.tr(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const Gap(12),
],
),
Text(
(user.value!.profile.bio.isNotEmpty)
? user.value!.profile.bio
: 'descriptionNone'.tr(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
).padding(horizontal: 16, top: 16),
AccountStatusCreationWidget(uname: user.value!.name),
),
],
).padding(
left: 16,
right: 16,
top: 16 + (hasBackground ? 16 : 0),
);
},
),
],
),
).padding(horizontal: 8),
Card(
margin: EdgeInsets.zero,
child: Column(
children: [
AccountStatusCreationWidget(uname: user.value!.name),
ActivityPresenceWidget(
uname: user.value!.name,
isCompact: true,
compactPadding: const EdgeInsets.only(
left: 16,
right: 16,
bottom: 8,
top: 4,
),
),
],
),
).padding(horizontal: 12, bottom: 4),
LevelingProgressCard(
isCompact: true,
level: user.value!.profile.level,
@@ -211,62 +265,93 @@ class AccountScreen extends HookConsumerWidget {
],
).padding(horizontal: 12),
const SizedBox.shrink(),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
spacing: 8,
children: [
Card(
margin: EdgeInsets.zero,
child: InkWell(
borderRadius: BorderRadius.circular(8),
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width,
),
child: LayoutBuilder(
builder: (context, constraints) {
const minWidth = 160.0;
const spacing = 8.0;
const padding = 24.0; // 12 * 2
final totalMin = 3 * minWidth + 2 * spacing;
final availableWidth = constraints.maxWidth - padding;
final children = [
Card(
margin: EdgeInsets.zero,
child: InkWell(
borderRadius: BorderRadius.circular(8),
child: Row(
spacing: 8,
children: [
Icon(Symbols.settings, size: 20),
Text('appSettings').tr().fontSize(13).bold(),
],
).padding(horizontal: 16, vertical: 12),
onTap: () {
context.pushNamed('settings');
},
),
),
Card(
margin: EdgeInsets.zero,
child: InkWell(
borderRadius: BorderRadius.circular(8),
child: Row(
spacing: 8,
children: [
Icon(Symbols.person_edit, size: 20),
Text('updateYourProfile').tr().fontSize(13).bold(),
],
).padding(horizontal: 16, vertical: 12),
onTap: () {
context.pushNamed('profileUpdate');
},
),
),
Card(
margin: EdgeInsets.zero,
child: InkWell(
borderRadius: BorderRadius.circular(8),
child: Row(
spacing: 8,
children: [
Icon(Symbols.manage_accounts, size: 20),
Text('accountSettings').tr().fontSize(13).bold(),
],
).padding(horizontal: 16, vertical: 12),
onTap: () {
context.pushNamed('accountSettings');
},
),
),
];
if (availableWidth > totalMin) {
return Row(
spacing: 8,
children:
children
.map((child) => Expanded(child: child))
.toList(),
).padding(horizontal: 12).height(48);
} else {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
spacing: 8,
children: [
Icon(Symbols.settings, size: 20),
Text('appSettings').tr().fontSize(13).bold(),
],
).padding(horizontal: 16, vertical: 12),
onTap: () {
context.pushNamed('settings');
},
),
),
Card(
margin: EdgeInsets.zero,
child: InkWell(
borderRadius: BorderRadius.circular(8),
child: Row(
spacing: 8,
children: [
Icon(Symbols.person_edit, size: 20),
Text('updateYourProfile').tr().fontSize(13).bold(),
],
).padding(horizontal: 16, vertical: 12),
onTap: () {
context.pushNamed('profileUpdate');
},
),
),
Card(
margin: EdgeInsets.zero,
child: InkWell(
borderRadius: BorderRadius.circular(8),
child: Row(
spacing: 8,
children: [
Icon(Symbols.manage_accounts, size: 20),
Text('accountSettings').tr().fontSize(13).bold(),
],
).padding(horizontal: 16, vertical: 12),
onTap: () {
context.pushNamed('accountSettings');
},
),
),
],
).padding(horizontal: 12),
).height(48),
children:
children
.map(
(child) =>
SizedBox(width: minWidth, child: child),
)
.toList(),
).padding(horizontal: 12),
).height(48);
}
},
),
),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.notifications),

View File

@@ -1,5 +1,4 @@
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_svg/flutter_svg.dart';
@@ -9,7 +8,6 @@ import 'package:island/models/auth.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart';
import 'package:island/screens/account/me/account_settings.dart';
import 'package:island/screens/auth/oidc.native.dart';
import 'package:island/utils/text.dart';
import 'package:island/services/time.dart';
import 'package:island/widgets/alert.dart';
@@ -38,6 +36,13 @@ Widget getProviderIcon(String provider, {double size = 24, Color? color}) {
height: size,
color: color,
);
case 'spotify':
return Image.asset(
'assets/images/oidc/spotify.png',
width: size,
height: size,
color: color,
);
default:
return Icon(Symbols.link, size: size);
}
@@ -57,6 +62,8 @@ String getLocalizedProviderName(String provider) {
return 'accountConnectionProviderDiscord'.tr();
case 'afdian':
return 'accountConnectionProviderAfdian'.tr();
case 'spotify':
return 'accountConnectionProviderSpotify'.tr();
default:
return provider;
}
@@ -156,6 +163,7 @@ class AccountConnectionNewSheet extends HookConsumerWidget {
'github',
'discord',
'afdian',
'spotify',
];
Future<void> addConnection() async {
@@ -196,25 +204,13 @@ class AccountConnectionNewSheet extends HookConsumerWidget {
case 'github':
case 'discord':
case 'afdian':
if (kIsWeb) {
final serverUrl = ref.watch(serverUrlProvider);
final accessToken = ref.watch(tokenProvider);
launchUrlString(
'$serverUrl/pass/auth/login/${selectedProvider.value}?tk=${accessToken!.token}',
);
} else {
await Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(
builder:
(context) => OidcScreen(
provider: selectedProvider.value.toLowerCase(),
title:
'Connect with ${selectedProvider.value.capitalizeEachWord()}',
),
),
);
if (context.mounted) Navigator.pop(context, true);
}
case 'spotify':
final serverUrl = ref.watch(serverUrlProvider);
final accessToken = ref.watch(tokenProvider);
launchUrlString(
'$serverUrl/pass/auth/login/${selectedProvider.value}?tk=${accessToken!.token}',
);
if (context.mounted) Navigator.pop(context, true);
break;
default:
showSnackBar('accountConnectionAddError'.tr());

View File

@@ -54,56 +54,134 @@ class _AccountBasicInfo extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ProfilePictureWidget(file: data.profile.picture, radius: 32),
const Gap(20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
return Card(
child: Builder(
builder: (context) {
final hasBackground = data.profile.background?.id != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isWideScreen(context) && hasBackground)
Stack(
clipBehavior: Clip.none,
children: [
AccountName(account: data, style: TextStyle(fontSize: 20)),
const Gap(6),
Flexible(
child: Text(
'@${data.name}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
).fontSize(14).opacity(0.85),
ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(8),
topRight: Radius.circular(8),
),
child: AspectRatio(
aspectRatio: 16 / 7,
child: CloudImageWidget(
file: data.profile.background,
fit: BoxFit.cover,
),
),
),
Positioned(
bottom: -24,
left: 16,
child: ProfilePictureWidget(
file: data.profile.picture,
radius: 32,
),
),
],
),
if (accountDeveloper.value != null)
Row(
spacing: 7,
Builder(
builder: (context) {
final showBackground = isWideScreen(context) && hasBackground;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: showBackground ? 0 : 20,
children: [
const Icon(Symbols.smart_toy, size: 18),
Text(
'botAutomatedBy'.tr(
args: [accountDeveloper.value!.publisher!.nick],
if (!showBackground)
ProfilePictureWidget(
file: data.profile.picture,
radius: 32,
),
).fontSize(13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
spacing: 4,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Flexible(
child: AccountName(
account: data,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
if (isWideScreen(context))
Flexible(
child: Text(
'@${data.name}',
).fontSize(11).padding(bottom: 2.5),
),
],
),
if (!isWideScreen(context))
Text(
'@${data.name}',
).fontSize(11).padding(bottom: 2.5),
Text(
(data.profile.bio.isNotEmpty)
? data.profile.bio
: 'descriptionNone'.tr(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (accountDeveloper.value != null)
Row(
spacing: 7,
children: [
const Icon(Symbols.smart_toy, size: 18),
Text(
'botAutomatedBy'.tr(
args: [
accountDeveloper.value!.publisher!.nick,
],
),
).fontSize(13),
],
).opacity(0.75).padding(top: 4),
const Gap(4),
AccountStatusWidget(
uname: uname,
padding: EdgeInsets.zero,
),
const Gap(8),
],
),
),
IconButton(
onPressed: () {
SharePlus.instance.share(
ShareParams(
uri: Uri.parse(
'https://solian.app/@${data.name}',
),
),
);
},
icon: const Icon(Symbols.share),
),
],
).opacity(0.75),
const Gap(4),
AccountStatusWidget(uname: uname, padding: EdgeInsets.zero),
],
),
),
IconButton(
onPressed: () {
SharePlus.instance.share(
ShareParams(uri: Uri.parse('https://solian.app/@${data.name}')),
);
},
icon: const Icon(Symbols.share),
),
],
).padding(
left: 16,
right: 16,
top: 16 + (showBackground ? 16 : 0),
);
},
),
],
);
},
),
);
}
@@ -206,7 +284,7 @@ class _AccountProfileDetail extends StatelessWidget {
child: Row(
spacing: 6,
children: [
Icon(Symbols.star, size: 17, fill: 1).padding(right: 2),
Icon(Symbols.attribution, size: 17, fill: 1).padding(right: 2),
Text('${data.profile.socialCredits.toStringAsFixed(2)} pts'),
Text('·').bold(),
switch (data.profile.socialCreditsLevel) {
@@ -254,30 +332,34 @@ class _AccountProfileDetail extends StatelessWidget {
children: _buildSubcolumn(),
),
if (data.profile.timeZone.isNotEmpty && !kIsWeb)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('timeZone').tr().bold(),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
spacing: 6,
children: [
Text(data.profile.timeZone),
Text(
getTzInfo(
data.profile.timeZone,
).$2.formatCustomGlobal('HH:mm'),
),
Text(
getTzInfo(data.profile.timeZone).$1.formatOffsetLocal(),
).fontSize(11),
Text(
'UTC${getTzInfo(data.profile.timeZone).$1.formatOffset()}',
).fontSize(11).opacity(0.75),
],
),
],
Builder(
builder: (context) {
try {
final tzInfo = getTzInfo(data.profile.timeZone);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('timeZone').tr().bold(),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
spacing: 6,
children: [
Text(data.profile.timeZone),
Text(tzInfo.$2.formatCustomGlobal('HH:mm')),
Text(tzInfo.$1.formatOffsetLocal()).fontSize(11),
Text(
'UTC${tzInfo.$1.formatOffset()}',
).fontSize(11).opacity(0.75),
],
),
],
);
} catch (e) {
// Hide timezone section if timezone is invalid
return const SizedBox.shrink();
}
},
),
],
).padding(horizontal: 24, vertical: 16),
@@ -764,33 +846,14 @@ class AccountProfileScreen extends HookConsumerWidget {
color: appbarColor.value,
shadows: [appbarShadow],
),
flexibleSpace: Stack(
children: [
Positioned.fill(
child:
data.profile.background?.id != null
? CloudImageWidget(
file: data.profile.background,
)
: Container(
color:
Theme.of(
context,
).appBarTheme.backgroundColor,
),
),
FlexibleSpaceBar(
title: Text(
data.nick,
style: TextStyle(
color:
appbarColor.value ??
Theme.of(context).appBarTheme.foregroundColor,
shadows: [appbarShadow],
),
),
),
],
title: Text(
data.nick,
style: TextStyle(
color:
appbarColor.value ??
Theme.of(context).appBarTheme.foregroundColor,
shadows: [appbarShadow],
),
),
)
: null,
@@ -806,7 +869,7 @@ class AccountProfileScreen extends HookConsumerWidget {
data: data,
uname: name,
accountDeveloper: accountDeveloper,
),
).padding(horizontal: 4, top: 20),
),
if (data.badges.isNotEmpty)
SliverToBoxAdapter(
@@ -857,7 +920,12 @@ class AccountProfileScreen extends HookConsumerWidget {
Flexible(
child: CustomScrollView(
slivers: [
SliverGap(24),
SliverGap(18),
SliverToBoxAdapter(
child: ActivityPresenceWidget(
uname: name,
).padding(horizontal: 4, top: 4, bottom: 4),
),
SliverToBoxAdapter(
child: _AccountPublisherList(
publishers: accountPublishers.value ?? [],
@@ -883,9 +951,6 @@ class AccountProfileScreen extends HookConsumerWidget {
),
),
),
SliverToBoxAdapter(
child: ActivityPresenceWidget(uname: name),
),
],
),
),
@@ -937,7 +1002,7 @@ class AccountProfileScreen extends HookConsumerWidget {
data: data,
uname: name,
accountDeveloper: accountDeveloper,
),
).padding(horizontal: 4, top: 8),
),
if (data.badges.isNotEmpty)
SliverToBoxAdapter(
@@ -981,6 +1046,11 @@ class AccountProfileScreen extends HookConsumerWidget {
data: data,
).padding(horizontal: 4),
),
SliverToBoxAdapter(
child: ActivityPresenceWidget(
uname: name,
).padding(horizontal: 8, top: 4, bottom: 4),
),
SliverToBoxAdapter(
child: _AccountPublisherList(
publishers: accountPublishers.value ?? [],
@@ -1010,11 +1080,6 @@ class AccountProfileScreen extends HookConsumerWidget {
),
).padding(horizontal: 4),
),
SliverToBoxAdapter(
child: ActivityPresenceWidget(
uname: name,
).padding(horizontal: 4),
),
],
),
);

View File

@@ -1,4 +1,5 @@
import "dart:async";
import "dart:math" as math;
import "package:easy_localization/easy_localization.dart";
import "package:file_picker/file_picker.dart";
import "package:flutter/material.dart";
@@ -140,6 +141,9 @@ class ChatRoomScreen extends HookConsumerWidget {
final messageController = useTextEditingController();
final scrollController = useScrollController();
// 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);
@@ -164,6 +168,12 @@ class ChatRoomScreen extends HookConsumerWidget {
isLoading = true;
messagesNotifier.loadMore().then((_) => isLoading = false);
}
// Update gradient animations
final pixels = scrollController.position.pixels;
// Bottom gradient: appears when not at bottom (pixels > 0)
bottomGradientNotifier.value.value = (pixels / 500.0).clamp(0.0, 1.0);
}
scrollController.addListener(onScroll);
@@ -589,7 +599,9 @@ class ChatRoomScreen extends HookConsumerWidget {
listController: listController,
padding: EdgeInsets.only(
top: 16,
bottom: MediaQuery.of(context).padding.bottom + 16,
bottom:
MediaQuery.of(context).padding.bottom +
80, // Leave space for chat input
),
controller: scrollController,
reverse: true, // Show newest messages at the bottom
@@ -828,7 +840,7 @@ class ChatRoomScreen extends HookConsumerWidget {
),
body: Stack(
children: [
// Messages and Input in Column
// Messages only in Column
Positioned.fill(
child: Column(
children: [
@@ -872,73 +884,6 @@ class ChatRoomScreen extends HookConsumerWidget {
),
),
),
if (!isSelectionMode.value)
chatRoom.when(
data:
(room) => Column(
mainAxisSize: MainAxisSize.min,
children: [
ChatInput(
messageController: messageController,
chatRoom: room!,
onSend: sendMessage,
onClear: () {
if (messageEditingTo.value != null) {
attachments.value.clear();
messageController.clear();
}
messageEditingTo.value = null;
messageReplyingTo.value = null;
messageForwardingTo.value = null;
},
messageEditingTo: messageEditingTo.value,
messageReplyingTo: messageReplyingTo.value,
messageForwardingTo: messageForwardingTo.value,
onPickFile: (bool isPhoto) {
if (isPhoto) {
pickPhotoMedia();
} else {
pickVideoMedia();
}
},
onPickAudio: pickAudioMedia,
onPickGeneralFile: pickGeneralFile,
onLinkAttachment: linkAttachment,
attachments: attachments.value,
onUploadAttachment: uploadAttachment,
onDeleteAttachment: (index) async {
final attachment = attachments.value[index];
if (attachment.isOnCloud &&
!attachment.isLink) {
final client = ref.watch(apiClientProvider);
await client.delete(
'/drive/files/${attachment.data.id}',
);
}
final clone = List.of(attachments.value);
clone.removeAt(index);
attachments.value = clone;
},
onMoveAttachment: (idx, delta) {
if (idx + delta < 0 ||
idx + delta >= attachments.value.length) {
return;
}
final clone = List.of(attachments.value);
clone.insert(idx + delta, clone.removeAt(idx));
attachments.value = clone;
},
onAttachmentsChanged: (newAttachments) {
attachments.value = newAttachments;
},
attachmentProgress: attachmentProgress.value,
),
Gap(MediaQuery.of(context).padding.bottom),
],
),
error: (_, _) => const SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
),
],
),
),
@@ -977,6 +922,112 @@ class ChatRoomScreen extends HookConsumerWidget {
),
),
),
// Bottom gradient - appears when scrolling towards newer messages (behind chat input)
if (!isSelectionMode.value)
AnimatedBuilder(
animation: bottomGradientNotifier.value,
builder:
(context, child) => Positioned(
left: 0,
right: 0,
bottom: 0,
child: Opacity(
opacity: bottomGradientNotifier.value.value,
child: Container(
height: math.min(
MediaQuery.of(context).size.height * 0.1,
128,
),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Theme.of(
context,
).colorScheme.surfaceContainer.withOpacity(0.8),
Theme.of(
context,
).colorScheme.surfaceContainer.withOpacity(0.0),
],
),
),
),
),
),
),
// Chat Input positioned above gradient (higher z-index)
if (!isSelectionMode.value)
Positioned(
left: 0,
right: 0,
bottom: 0, // At the very bottom, above gradient
child: chatRoom.when(
data:
(room) => Column(
mainAxisSize: MainAxisSize.min,
children: [
ChatInput(
messageController: messageController,
chatRoom: room!,
onSend: sendMessage,
onClear: () {
if (messageEditingTo.value != null) {
attachments.value.clear();
messageController.clear();
}
messageEditingTo.value = null;
messageReplyingTo.value = null;
messageForwardingTo.value = null;
},
messageEditingTo: messageEditingTo.value,
messageReplyingTo: messageReplyingTo.value,
messageForwardingTo: messageForwardingTo.value,
onPickFile: (bool isPhoto) {
if (isPhoto) {
pickPhotoMedia();
} else {
pickVideoMedia();
}
},
onPickAudio: pickAudioMedia,
onPickGeneralFile: pickGeneralFile,
onLinkAttachment: linkAttachment,
attachments: attachments.value,
onUploadAttachment: uploadAttachment,
onDeleteAttachment: (index) async {
final attachment = attachments.value[index];
if (attachment.isOnCloud && !attachment.isLink) {
final client = ref.watch(apiClientProvider);
await client.delete(
'/drive/files/${attachment.data.id}',
);
}
final clone = List.of(attachments.value);
clone.removeAt(index);
attachments.value = clone;
},
onMoveAttachment: (idx, delta) {
if (idx + delta < 0 ||
idx + delta >= attachments.value.length) {
return;
}
final clone = List.of(attachments.value);
clone.insert(idx + delta, clone.removeAt(idx));
attachments.value = clone;
},
onAttachmentsChanged: (newAttachments) {
attachments.value = newAttachments;
},
attachmentProgress: attachmentProgress.value,
),
Gap(MediaQuery.of(context).padding.bottom),
],
),
error: (_, _) => const SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
),
),
// Selection mode toolbar
if (isSelectionMode.value)
Positioned(

View File

@@ -177,8 +177,14 @@ class PublisherSelector extends StatelessWidget {
iconStyleData: IconStyleData(
icon: Icon(Icons.arrow_drop_down),
iconSize: 19,
iconEnabledColor: Theme.of(context).appBarTheme.foregroundColor!,
iconDisabledColor: Theme.of(context).appBarTheme.foregroundColor!,
iconEnabledColor:
isWideScreen(context)
? null
: Theme.of(context).appBarTheme.foregroundColor!,
iconDisabledColor:
isWideScreen(context)
? null
: Theme.of(context).appBarTheme.foregroundColor!,
),
),
);
@@ -561,6 +567,7 @@ class CreatorHubScreen extends HookConsumerWidget {
? Column(
spacing: 8,
children: [
const SizedBox.shrink(),
PublisherSelector(
currentPublisher: currentPublisher.value,
publishersMenu: publishersMenu,

View File

@@ -1,12 +1,17 @@
import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/custom_app.dart';
import 'package:island/pods/network.dart';
import 'package:island/screens/developers/edit_app.dart';
import 'package:island/screens/developers/new_app.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/extended_refresh_indicator.dart';
import 'package:island/widgets/response.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -68,12 +73,18 @@ class CustomAppsScreen extends HookConsumerWidget {
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () {
context.pushNamed(
'developerAppNew',
pathParameters: {
'name': publisherName,
'projectId': projectId,
},
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(context) => SheetScaffold(
titleText: 'createCustomApp'.tr(),
child: NewCustomAppScreen(
publisherName: publisherName,
projectId: projectId,
isModal: true,
),
),
);
},
icon: const Icon(Symbols.add),
@@ -83,129 +94,171 @@ class CustomAppsScreen extends HookConsumerWidget {
),
);
}
return RefreshIndicator(
return ExtendedRefreshIndicator(
onRefresh:
() => ref.refresh(
customAppsProvider(publisherName, projectId).future,
),
child: ListView.builder(
padding: EdgeInsets.only(top: 4),
itemCount: data.length,
itemBuilder: (context, index) {
final app = data[index];
return Card(
margin: const EdgeInsets.all(8.0),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {
context.pushNamed(
'developerAppDetail',
pathParameters: {
'name': publisherName,
'projectId': projectId,
'appId': app.id,
},
);
},
child: Column(
children: [
SizedBox(
height: 150,
child: Stack(
fit: StackFit.expand,
children: [
if (app.background != null)
CloudFileWidget(
item: app.background!,
fit: BoxFit.cover,
).clipRRect(topLeft: 8, topRight: 8),
if (app.picture != null)
Positioned(
left: 16,
bottom: 16,
child: ProfilePictureWidget(
fileId: app.picture!.id,
radius: 40,
fallbackIcon: Symbols.apps,
),
child: Column(
children: [
const Gap(8),
Card(
child: ListTile(
title: Text('customApps').tr().padding(horizontal: 8),
trailing: IconButton(
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(context) => SheetScaffold(
titleText: 'createCustomApp'.tr(),
child: NewCustomAppScreen(
publisherName: publisherName,
projectId: projectId,
isModal: true,
),
),
);
},
icon: const Icon(Symbols.add),
),
),
),
Expanded(
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: data.length,
itemBuilder: (context, index) {
final app = data[index];
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {
context.pushNamed(
'developerAppDetail',
pathParameters: {
'name': publisherName,
'projectId': projectId,
'appId': app.id,
},
);
},
child: Column(
children: [
SizedBox(
height: 150,
child: Stack(
fit: StackFit.expand,
children: [
if (app.background != null)
CloudFileWidget(
item: app.background!,
fit: BoxFit.cover,
).clipRRect(topLeft: 8, topRight: 8),
if (app.picture != null)
Positioned(
left: 16,
bottom: 16,
child: ProfilePictureWidget(
fileId: app.picture!.id,
radius: 40,
fallbackIcon: Symbols.apps,
),
),
],
),
),
ListTile(
title: Text(app.name),
subtitle: Text(
app.slug,
style: GoogleFonts.robotoMono(fontSize: 12),
),
contentPadding: EdgeInsets.only(
left: 20,
right: 12,
),
trailing: PopupMenuButton(
itemBuilder:
(context) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Symbols.edit),
const SizedBox(width: 12),
Text('edit').tr(),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(
Symbols.delete,
color: Colors.red,
),
const SizedBox(width: 12),
Text(
'delete',
style: TextStyle(
color: Colors.red,
),
).tr(),
],
),
),
],
onSelected: (value) {
if (value == 'edit') {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(context) => SheetScaffold(
titleText: 'editCustomApp'.tr(),
child: EditAppScreen(
publisherName: publisherName,
projectId: projectId,
id: app.id,
isModal: true,
),
),
);
} else if (value == 'delete') {
showConfirmAlert(
'deleteCustomAppHint'.tr(),
'deleteCustomApp'.tr(),
).then((confirm) {
if (confirm) {
final client = ref.read(
apiClientProvider,
);
client.delete(
'/develop/developers/$publisherName/projects/$projectId/apps/${app.id}',
);
ref.invalidate(
customAppsProvider(
publisherName,
projectId,
),
);
}
});
}
},
),
),
],
),
),
ListTile(
title: Text(app.name),
subtitle: Text(
app.slug,
style: GoogleFonts.robotoMono(fontSize: 12),
),
contentPadding: EdgeInsets.only(left: 20, right: 12),
trailing: PopupMenuButton(
itemBuilder:
(context) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Symbols.edit),
const SizedBox(width: 12),
Text('edit').tr(),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(
Symbols.delete,
color: Colors.red,
),
const SizedBox(width: 12),
Text(
'delete',
style: TextStyle(color: Colors.red),
).tr(),
],
),
),
],
onSelected: (value) {
if (value == 'edit') {
context.pushNamed(
'developerAppEdit',
pathParameters: {
'name': publisherName,
'projectId': projectId,
'id': app.id,
},
);
} else if (value == 'delete') {
showConfirmAlert(
'deleteCustomAppHint'.tr(),
'deleteCustomApp'.tr(),
).then((confirm) {
if (confirm) {
final client = ref.read(apiClientProvider);
client.delete(
'/develop/developers/$publisherName/projects/$projectId/apps/${app.id}',
);
ref.invalidate(
customAppsProvider(
publisherName,
projectId,
),
);
}
});
}
},
),
),
],
),
);
},
),
);
},
),
],
),
);
},

View File

@@ -1,15 +1,20 @@
import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/bot.dart';
import 'package:island/pods/network.dart';
import 'package:island/screens/developers/edit_bot.dart';
import 'package:island/screens/developers/new_bot.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/response.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/widgets/extended_refresh_indicator.dart';
import 'package:styled_widget/styled_widget.dart';
part 'bots.g.dart';
@@ -46,12 +51,18 @@ class BotsScreen extends HookConsumerWidget {
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () {
context.pushNamed(
'developerBotNew',
pathParameters: {
'name': publisherName,
'projectId': projectId,
},
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(context) => SheetScaffold(
titleText: 'createBot'.tr(),
child: NewBotScreen(
publisherName: publisherName,
projectId: projectId,
isModal: true,
),
),
);
},
icon: const Icon(Symbols.add),
@@ -64,95 +75,133 @@ class BotsScreen extends HookConsumerWidget {
return ExtendedRefreshIndicator(
onRefresh:
() => ref.refresh(botsProvider(publisherName, projectId).future),
child: ListView.builder(
padding: const EdgeInsets.only(top: 4),
itemCount: data.length,
itemBuilder: (context, index) {
final bot = data[index];
return Card(
margin: const EdgeInsets.all(8.0),
child: Column(
children: [
const Gap(8),
Card(
child: ListTile(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8.0)),
),
leading: CircleAvatar(
child:
bot.account.profile.picture != null
? ProfilePictureWidget(
file: bot.account.profile.picture!,
)
: const Icon(Symbols.smart_toy),
),
title: Text(bot.account.nick),
subtitle: Text(bot.account.name),
trailing: PopupMenuButton(
itemBuilder:
(context) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Symbols.edit),
const SizedBox(width: 12),
Text('edit').tr(),
],
title: Text('bots').tr().padding(horizontal: 8),
trailing: IconButton(
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(context) => SheetScaffold(
titleText: 'createBot'.tr(),
child: NewBotScreen(
publisherName: publisherName,
projectId: projectId,
isModal: true,
),
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(Symbols.delete, color: Colors.red),
const SizedBox(width: 12),
Text(
'delete',
style: TextStyle(color: Colors.red),
).tr(),
],
),
),
],
onSelected: (value) {
if (value == 'edit') {
context.pushNamed(
'developerBotEdit',
pathParameters: {
'name': publisherName,
'projectId': projectId,
'id': bot.id,
},
);
} else if (value == 'delete') {
showConfirmAlert(
'deleteBotHint'.tr(),
'deleteBot'.tr(),
).then((confirm) {
if (confirm) {
final client = ref.read(apiClientProvider);
client.delete(
'/develop/developers/$publisherName/projects/$projectId/bots/${bot.id}',
);
ref.invalidate(
botsProvider(publisherName, projectId),
);
}
});
}
);
},
icon: const Icon(Symbols.add),
),
onTap: () {
context.pushNamed(
'developerBotDetail',
pathParameters: {
'name': publisherName,
'projectId': projectId,
'botId': bot.id,
},
),
),
Expanded(
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: data.length,
itemBuilder: (context, index) {
final bot = data[index];
return Card(
child: ListTile(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8.0)),
),
leading: CircleAvatar(
child:
bot.account.profile.picture != null
? ProfilePictureWidget(
file: bot.account.profile.picture!,
)
: const Icon(Symbols.smart_toy),
),
title: Text(bot.account.nick),
subtitle: Text(bot.account.name),
trailing: PopupMenuButton(
itemBuilder:
(context) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Symbols.edit),
const SizedBox(width: 12),
Text('edit').tr(),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(
Symbols.delete,
color: Colors.red,
),
const SizedBox(width: 12),
Text(
'delete',
style: TextStyle(color: Colors.red),
).tr(),
],
),
),
],
onSelected: (value) {
if (value == 'edit') {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(context) => SheetScaffold(
titleText: 'editBot'.tr(),
child: EditBotScreen(
publisherName: publisherName,
projectId: projectId,
id: bot.id,
isModal: true,
),
),
);
} else if (value == 'delete') {
showConfirmAlert(
'deleteBotHint'.tr(),
'deleteBot'.tr(),
).then((confirm) {
if (confirm) {
final client = ref.read(apiClientProvider);
client.delete(
'/develop/developers/$publisherName/projects/$projectId/bots/${bot.id}',
);
ref.invalidate(
botsProvider(publisherName, projectId),
);
}
});
}
},
),
onTap: () {
context.pushNamed(
'developerBotDetail',
pathParameters: {
'name': publisherName,
'projectId': projectId,
'botId': bot.id,
},
);
},
),
);
},
),
);
},
),
],
),
);
},

View File

@@ -39,11 +39,13 @@ class EditAppScreen extends HookConsumerWidget {
final String publisherName;
final String projectId;
final String? id;
final bool isModal;
const EditAppScreen({
super.key,
required this.publisherName,
required this.projectId,
this.id,
this.isModal = false,
});
@override
@@ -177,7 +179,12 @@ class EditAppScreen extends HookConsumerWidget {
children: [
TextFormField(
controller: scopeController,
decoration: InputDecoration(labelText: 'scopeName'.tr()),
decoration: InputDecoration(
labelText: 'scopeName'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
),
const SizedBox(height: 20),
FilledButton.tonalIcon(
@@ -220,6 +227,9 @@ class EditAppScreen extends HookConsumerWidget {
hintText: 'https://example.com/auth/callback',
helperText: 'redirectUriHint'.tr(),
helperMaxLines: 3,
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
keyboardType: TextInputType.url,
validator: (value) {
@@ -316,270 +326,298 @@ class EditAppScreen extends HookConsumerWidget {
}
}
final bodyContent =
app == null && !isNew
? const Center(child: CircularProgressIndicator())
: app?.hasError == true && !isNew
? ResponseErrorWidget(
error: app!.error,
onRetry:
() => ref.invalidate(
customAppProvider(publisherName, projectId, id!),
),
)
: SingleChildScrollView(
child: Column(
children: [
AspectRatio(
aspectRatio: 16 / 7,
child: Stack(
clipBehavior: Clip.none,
fit: StackFit.expand,
children: [
GestureDetector(
child: Container(
color:
Theme.of(
context,
).colorScheme.surfaceContainerHigh,
child:
background.value != null
? CloudFileWidget(
item: background.value!,
fit: BoxFit.cover,
)
: const SizedBox.shrink(),
),
onTap: () {
setPicture('background');
},
),
Positioned(
left: 20,
bottom: -32,
child: GestureDetector(
child: ProfilePictureWidget(
fileId: picture.value?.id,
radius: 40,
fallbackIcon: Symbols.apps,
),
onTap: () {
setPicture('picture');
},
),
),
],
),
).padding(bottom: 32),
Form(
key: formKey,
child: Column(
children: [
TextFormField(
controller: nameController,
decoration: InputDecoration(
labelText: 'name'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
),
onTapOutside:
(_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
TextFormField(
controller: slugController,
decoration: InputDecoration(
labelText: 'slug'.tr(),
helperText: 'slugHint'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
),
onTapOutside:
(_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
TextFormField(
controller: descriptionController,
decoration: InputDecoration(
labelText: 'description'.tr(),
alignLabelWithHint: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
),
maxLines: 3,
onTapOutside:
(_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
ExpansionPanelList(
expansionCallback: (index, isExpanded) {
switch (index) {
case 0:
enableLinks.value = isExpanded;
break;
case 1:
oauthEnabled.value = isExpanded;
break;
}
},
children: [
ExpansionPanel(
headerBuilder:
(context, isExpanded) =>
ListTile(title: Text('appLinks').tr()),
body: Column(
spacing: 16,
children: [
TextFormField(
controller: homePageController,
decoration: InputDecoration(
labelText: 'homePageUrl'.tr(),
hintText: 'https://example.com',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
),
keyboardType: TextInputType.url,
),
TextFormField(
controller: privacyPolicyController,
decoration: InputDecoration(
labelText: 'privacyPolicyUrl'.tr(),
hintText: 'https://example.com/privacy',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
),
keyboardType: TextInputType.url,
),
TextFormField(
controller: termsController,
decoration: InputDecoration(
labelText: 'termsOfServiceUrl'.tr(),
hintText: 'https://example.com/terms',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
),
keyboardType: TextInputType.url,
),
],
).padding(horizontal: 16, bottom: 24),
isExpanded: enableLinks.value,
),
ExpansionPanel(
headerBuilder:
(context, isExpanded) =>
ListTile(title: Text('oauthConfig').tr()),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('redirectUris'.tr()),
Card(
margin: const EdgeInsets.symmetric(
vertical: 8,
),
child: Column(
children: [
...redirectUris.value.map(
(uri) => ListTile(
title: Text(uri),
trailing: IconButton(
icon: const Icon(Symbols.delete),
onPressed: () {
redirectUris.value =
redirectUris.value
.where((u) => u != uri)
.toList();
},
),
),
),
if (redirectUris.value.isNotEmpty)
const Divider(height: 1),
ListTile(
leading: const Icon(Symbols.add),
title: Text('addRedirectUri'.tr()),
onTap: showAddRedirectUriDialog,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
8,
),
),
),
],
),
),
const SizedBox(height: 16),
Text('allowedScopes'.tr()),
Card(
margin: const EdgeInsets.symmetric(
vertical: 8,
),
child: Column(
children: [
...allowedScopes.value.map(
(scope) => ListTile(
title: Text(scope),
trailing: IconButton(
icon: const Icon(Symbols.delete),
onPressed: () {
allowedScopes.value =
allowedScopes.value
.where(
(s) => s != scope,
)
.toList();
},
),
),
),
if (allowedScopes.value.isNotEmpty)
const Divider(height: 1),
ListTile(
leading: const Icon(Symbols.add),
title: Text('add').tr(),
onTap: showAddScopeDialog,
),
],
),
),
const SizedBox(height: 16),
SwitchListTile(
title: Text('requirePkce'.tr()),
value: requirePkce.value,
onChanged:
(value) => requirePkce.value = value,
),
SwitchListTile(
title: Text('allowOfflineAccess'.tr()),
value: allowOfflineAccess.value,
onChanged:
(value) =>
allowOfflineAccess.value = value,
),
],
).padding(horizontal: 16, bottom: 24),
isExpanded: oauthEnabled.value,
),
],
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: submitting.value ? null : performAction,
label: Text('saveChanges'.tr()),
icon: const Icon(Symbols.save),
),
),
],
).padding(all: 24),
),
],
),
);
if (isModal) {
return bodyContent;
}
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
title: Text(isNew ? 'createCustomApp'.tr() : 'editCustomApp'.tr()),
),
body:
app == null && !isNew
? const Center(child: CircularProgressIndicator())
: app?.hasError == true && !isNew
? ResponseErrorWidget(
error: app!.error,
onRetry:
() => ref.invalidate(
customAppProvider(publisherName, projectId, id!),
),
)
: SingleChildScrollView(
child: Column(
children: [
AspectRatio(
aspectRatio: 16 / 7,
child: Stack(
clipBehavior: Clip.none,
fit: StackFit.expand,
children: [
GestureDetector(
child: Container(
color:
Theme.of(
context,
).colorScheme.surfaceContainerHigh,
child:
background.value != null
? CloudFileWidget(
item: background.value!,
fit: BoxFit.cover,
)
: const SizedBox.shrink(),
),
onTap: () {
setPicture('background');
},
),
Positioned(
left: 20,
bottom: -32,
child: GestureDetector(
child: ProfilePictureWidget(
fileId: picture.value?.id,
radius: 40,
fallbackIcon: Symbols.apps,
),
onTap: () {
setPicture('picture');
},
),
),
],
),
).padding(bottom: 32),
Form(
key: formKey,
child: Column(
children: [
TextFormField(
controller: nameController,
decoration: InputDecoration(labelText: 'name'.tr()),
onTapOutside:
(_) =>
FocusManager.instance.primaryFocus
?.unfocus(),
),
const SizedBox(height: 16),
TextFormField(
controller: slugController,
decoration: InputDecoration(
labelText: 'slug'.tr(),
helperText: 'slugHint'.tr(),
),
onTapOutside:
(_) =>
FocusManager.instance.primaryFocus
?.unfocus(),
),
const SizedBox(height: 16),
TextFormField(
controller: descriptionController,
decoration: InputDecoration(
labelText: 'description'.tr(),
alignLabelWithHint: true,
),
maxLines: 3,
onTapOutside:
(_) =>
FocusManager.instance.primaryFocus
?.unfocus(),
),
const SizedBox(height: 16),
ExpansionPanelList(
expansionCallback: (index, isExpanded) {
switch (index) {
case 0:
enableLinks.value = isExpanded;
break;
case 1:
oauthEnabled.value = isExpanded;
break;
}
},
children: [
ExpansionPanel(
headerBuilder:
(context, isExpanded) =>
ListTile(title: Text('appLinks').tr()),
body: Column(
spacing: 16,
children: [
TextFormField(
controller: homePageController,
decoration: InputDecoration(
labelText: 'homePageUrl'.tr(),
hintText: 'https://example.com',
),
keyboardType: TextInputType.url,
),
TextFormField(
controller: privacyPolicyController,
decoration: InputDecoration(
labelText: 'privacyPolicyUrl'.tr(),
hintText: 'https://example.com/privacy',
),
keyboardType: TextInputType.url,
),
TextFormField(
controller: termsController,
decoration: InputDecoration(
labelText: 'termsOfServiceUrl'.tr(),
hintText: 'https://example.com/terms',
),
keyboardType: TextInputType.url,
),
],
).padding(horizontal: 16, bottom: 24),
isExpanded: enableLinks.value,
),
ExpansionPanel(
headerBuilder:
(context, isExpanded) => ListTile(
title: Text('oauthConfig').tr(),
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('redirectUris'.tr()),
Card(
margin: const EdgeInsets.symmetric(
vertical: 8,
),
child: Column(
children: [
...redirectUris.value.map(
(uri) => ListTile(
title: Text(uri),
trailing: IconButton(
icon: const Icon(
Symbols.delete,
),
onPressed: () {
redirectUris.value =
redirectUris.value
.where(
(u) => u != uri,
)
.toList();
},
),
),
),
if (redirectUris.value.isNotEmpty)
const Divider(height: 1),
ListTile(
leading: const Icon(Symbols.add),
title: Text('addRedirectUri'.tr()),
onTap: showAddRedirectUriDialog,
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(8),
),
),
],
),
),
const SizedBox(height: 16),
Text('allowedScopes'.tr()),
Card(
margin: const EdgeInsets.symmetric(
vertical: 8,
),
child: Column(
children: [
...allowedScopes.value.map(
(scope) => ListTile(
title: Text(scope),
trailing: IconButton(
icon: const Icon(
Symbols.delete,
),
onPressed: () {
allowedScopes.value =
allowedScopes.value
.where(
(s) => s != scope,
)
.toList();
},
),
),
),
if (allowedScopes.value.isNotEmpty)
const Divider(height: 1),
ListTile(
leading: const Icon(Symbols.add),
title: Text('add').tr(),
onTap: showAddScopeDialog,
),
],
),
),
const SizedBox(height: 16),
SwitchListTile(
title: Text('requirePkce'.tr()),
value: requirePkce.value,
onChanged:
(value) => requirePkce.value = value,
),
SwitchListTile(
title: Text('allowOfflineAccess'.tr()),
value: allowOfflineAccess.value,
onChanged:
(value) =>
allowOfflineAccess.value = value,
),
],
).padding(horizontal: 16, bottom: 24),
isExpanded: oauthEnabled.value,
),
],
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed:
submitting.value ? null : performAction,
label: Text('saveChanges'.tr()),
icon: const Icon(Symbols.save),
),
),
],
).padding(all: 24),
),
],
),
),
body: bodyContent,
);
}
}

View File

@@ -38,11 +38,13 @@ class EditBotScreen extends HookConsumerWidget {
final String publisherName;
final String projectId;
final String? id;
final bool isModal;
const EditBotScreen({
super.key,
required this.publisherName,
required this.projectId,
this.id,
this.isModal = false,
});
@override
@@ -191,230 +193,293 @@ class EditBotScreen extends HookConsumerWidget {
}
}
final bodyContent =
botData == null && !isNew
? const Center(child: CircularProgressIndicator())
: botData?.hasError == true && !isNew
? ResponseErrorWidget(
error: botData!.error,
onRetry:
() => ref.invalidate(
botProvider(publisherName, projectId, id!),
),
)
: SingleChildScrollView(
child: Column(
children: [
AspectRatio(
aspectRatio: 16 / 7,
child: Stack(
clipBehavior: Clip.none,
fit: StackFit.expand,
children: [
GestureDetector(
child: Container(
color:
Theme.of(
context,
).colorScheme.surfaceContainerHigh,
child:
background.value != null
? CloudFileWidget(
item: background.value!,
fit: BoxFit.cover,
)
: const SizedBox.shrink(),
),
onTap: () {
setPicture('background');
},
),
Positioned(
left: 20,
bottom: -32,
child: GestureDetector(
child: ProfilePictureWidget(
fileId: picture.value?.id,
radius: 40,
fallbackIcon: Symbols.smart_toy,
),
onTap: () {
setPicture('picture');
},
),
),
],
),
).padding(bottom: 32),
Form(
key: formKey,
child: Column(
children: [
TextFormField(
controller: nameController,
decoration: InputDecoration(
labelText: 'name'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
),
),
const SizedBox(height: 16),
TextFormField(
controller: nickController,
decoration: InputDecoration(
labelText: 'nickname'.tr(),
alignLabelWithHint: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
),
),
const SizedBox(height: 16),
TextFormField(
controller: slugController,
decoration: InputDecoration(
labelText: 'slug'.tr(),
helperText: 'slugHint'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
),
),
const SizedBox(height: 16),
TextFormField(
controller: bioController,
decoration: InputDecoration(
labelText: 'bio'.tr(),
alignLabelWithHint: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
),
maxLines: 3,
),
const SizedBox(height: 16),
Row(
spacing: 16,
children: [
Expanded(
child: TextFormField(
controller: firstNameController,
decoration: InputDecoration(
labelText: 'firstName'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
),
),
),
Expanded(
child: TextFormField(
controller: middleNameController,
decoration: InputDecoration(
labelText: 'middleName'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
),
),
),
Expanded(
child: TextFormField(
controller: lastNameController,
decoration: InputDecoration(
labelText: 'lastName'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
),
),
),
],
),
const SizedBox(height: 16),
Row(
spacing: 16,
children: [
Expanded(
child: TextFormField(
controller: genderController,
decoration: InputDecoration(
labelText: 'gender'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
),
),
),
Expanded(
child: TextFormField(
controller: pronounsController,
decoration: InputDecoration(
labelText: 'pronouns'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
),
),
),
],
),
const SizedBox(height: 16),
Row(
spacing: 16,
children: [
Expanded(
child: TextFormField(
controller: locationController,
decoration: InputDecoration(
labelText: 'location'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
),
),
),
Expanded(
child: TextFormField(
controller: timeZoneController,
decoration: InputDecoration(
labelText: 'timeZone'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
),
),
),
],
),
const SizedBox(height: 16),
GestureDetector(
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: birthday.value ?? DateTime.now(),
firstDate: DateTime(1900),
lastDate: DateTime.now(),
);
if (date != null) {
birthday.value = date;
}
},
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
),
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'birthday'.tr(),
style: TextStyle(
color: Theme.of(context).hintColor,
),
),
Text(
birthday.value != null
? DateFormat.yMMMd().format(
birthday.value!,
)
: 'Select a date'.tr(),
),
],
),
),
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: submitting.value ? null : performAction,
label: Text('saveChanges').tr(),
icon: const Icon(Symbols.save),
),
),
],
).padding(all: 24),
),
],
),
);
if (isModal) {
return bodyContent;
}
return AppScaffold(
isNoBackground: false,
appBar: AppBar(title: Text(isNew ? 'createBot'.tr() : 'editBot'.tr())),
body:
botData == null && !isNew
? const Center(child: CircularProgressIndicator())
: botData?.hasError == true && !isNew
? ResponseErrorWidget(
error: botData!.error,
onRetry:
() => ref.invalidate(
botProvider(publisherName, projectId, id!),
),
)
: SingleChildScrollView(
child: Column(
children: [
AspectRatio(
aspectRatio: 16 / 7,
child: Stack(
clipBehavior: Clip.none,
fit: StackFit.expand,
children: [
GestureDetector(
child: Container(
color:
Theme.of(
context,
).colorScheme.surfaceContainerHigh,
child:
background.value != null
? CloudFileWidget(
item: background.value!,
fit: BoxFit.cover,
)
: const SizedBox.shrink(),
),
onTap: () {
setPicture('background');
},
),
Positioned(
left: 20,
bottom: -32,
child: GestureDetector(
child: ProfilePictureWidget(
fileId: picture.value?.id,
radius: 40,
fallbackIcon: Symbols.smart_toy,
),
onTap: () {
setPicture('picture');
},
),
),
],
),
).padding(bottom: 32),
Form(
key: formKey,
child: Column(
children: [
TextFormField(
controller: nameController,
decoration: InputDecoration(labelText: 'name'.tr()),
),
const SizedBox(height: 16),
TextFormField(
controller: nickController,
decoration: InputDecoration(
labelText: 'nickname'.tr(),
alignLabelWithHint: true,
),
),
const SizedBox(height: 16),
TextFormField(
controller: slugController,
decoration: InputDecoration(
labelText: 'slug'.tr(),
helperText: 'slugHint'.tr(),
),
),
const SizedBox(height: 16),
TextFormField(
controller: bioController,
decoration: InputDecoration(
labelText: 'bio'.tr(),
alignLabelWithHint: true,
),
maxLines: 3,
),
const SizedBox(height: 16),
Row(
spacing: 16,
children: [
Expanded(
child: TextFormField(
controller: firstNameController,
decoration: InputDecoration(
labelText: 'firstName'.tr(),
),
),
),
Expanded(
child: TextFormField(
controller: middleNameController,
decoration: InputDecoration(
labelText: 'middleName'.tr(),
),
),
),
Expanded(
child: TextFormField(
controller: lastNameController,
decoration: InputDecoration(
labelText: 'lastName'.tr(),
),
),
),
],
),
const SizedBox(height: 16),
Row(
spacing: 16,
children: [
Expanded(
child: TextFormField(
controller: genderController,
decoration: InputDecoration(
labelText: 'gender'.tr(),
),
),
),
Expanded(
child: TextFormField(
controller: pronounsController,
decoration: InputDecoration(
labelText: 'pronouns'.tr(),
),
),
),
],
),
const SizedBox(height: 16),
Row(
spacing: 16,
children: [
Expanded(
child: TextFormField(
controller: locationController,
decoration: InputDecoration(
labelText: 'location'.tr(),
),
),
),
Expanded(
child: TextFormField(
controller: timeZoneController,
decoration: InputDecoration(
labelText: 'timeZone'.tr(),
),
),
),
],
),
const SizedBox(height: 16),
GestureDetector(
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: birthday.value ?? DateTime.now(),
firstDate: DateTime(1900),
lastDate: DateTime.now(),
);
if (date != null) {
birthday.value = date;
}
},
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'birthday'.tr(),
style: TextStyle(
color: Theme.of(context).hintColor,
),
),
Text(
birthday.value != null
? DateFormat.yMMMd().format(
birthday.value!,
)
: 'Select a date'.tr(),
),
],
),
),
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed:
submitting.value ? null : performAction,
label: Text('saveChanges').tr(),
icon: const Icon(Symbols.save),
),
),
],
).padding(all: 24),
),
],
),
),
body: bodyContent,
);
}
}

View File

@@ -1,5 +1,3 @@
import 'dart:math';
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@@ -21,7 +19,6 @@ import 'package:island/widgets/response.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
part 'hub.g.dart';
@@ -100,15 +97,6 @@ class DeveloperHubScreen extends HookConsumerWidget {
),
body: Column(
children: [
if (currentProject.value == null)
...([
// Welcome Section
_WelcomeSection(currentDeveloper: currentDeveloper.value),
// Navigation Tabs
_NavigationTabs(),
]),
// Main Content
if (currentProject.value != null)
Expanded(
@@ -190,174 +178,12 @@ class _ConsoleAppBar extends StatelessWidget implements PreferredSizeWidget {
currentProject: currentProject,
onProjectChanged: onProjectChanged,
),
IconButton(
icon: const Icon(Symbols.help, color: Color(0xFF5F6368)),
onPressed: () {
launchUrlString('https://kb.solsynth.dev');
},
),
const Gap(12),
const Gap(8),
],
);
}
}
// Welcome Section
class _WelcomeSection extends StatelessWidget {
final SnDeveloper? currentDeveloper;
const _WelcomeSection({required this.currentDeveloper});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Stack(
children: [
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors:
isDark
? [
Theme.of(context).colorScheme.surfaceContainerHighest,
Theme.of(context).colorScheme.surfaceContainerLow,
]
: [const Color(0xFFE8F0FE), const Color(0xFFF1F3F4)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
),
),
Positioned(
right: 16,
top: 0,
bottom: 0,
child: _RandomStickerImage(
width: 180,
height: 180,
).opacity(isWideScreen(context) ? 1 : 0.5),
),
Container(
height: 180,
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Good morning!',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.w400,
color: Theme.of(context).colorScheme.onSurface,
),
),
const Gap(4),
Text(
currentDeveloper != null
? "You're working as ${currentDeveloper!.publisher!.nick}"
: "Choose a developer and continue.",
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
],
);
}
}
// Random Sticker Image Widget
class _RandomStickerImage extends StatelessWidget {
final double? width;
final double? height;
const _RandomStickerImage({this.width, this.height});
static const List<String> _stickers = [
'assets/images/stickers/clap.png',
'assets/images/stickers/confuse.png',
'assets/images/stickers/pray.png',
'assets/images/stickers/thumb_up.png',
];
String _getRandomSticker() {
final random = Random();
return _stickers[random.nextInt(_stickers.length)];
}
@override
Widget build(BuildContext context) {
return Image.asset(
_getRandomSticker(),
width: width ?? 80,
height: height ?? 80,
fit: BoxFit.contain,
);
}
}
// Navigation Tabs
class _NavigationTabs extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface),
child: Row(
children: [
const Gap(24),
_NavTabItem(title: 'Dashboard', isActive: true),
],
),
);
}
}
class _NavTabItem extends StatelessWidget {
final String title;
final bool isActive;
const _NavTabItem({required this.title, this.isActive = false});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color:
isActive
? Theme.of(context).colorScheme.primary
: Colors.transparent,
width: 2,
),
),
),
child: Row(
children: [
Text(
title,
style: TextStyle(
color:
isActive
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
fontWeight: isActive ? FontWeight.w500 : FontWeight.w400,
),
),
],
),
);
}
}
// Main Content Section
class _MainContentSection extends HookConsumerWidget {
final SnDeveloper? currentDeveloper;

View File

@@ -4,10 +4,20 @@ import 'package:island/screens/developers/edit_app.dart';
class NewCustomAppScreen extends StatelessWidget {
final String publisherName;
final String projectId;
const NewCustomAppScreen({super.key, required this.publisherName, required this.projectId});
final bool isModal;
const NewCustomAppScreen({
super.key,
required this.publisherName,
required this.projectId,
this.isModal = false,
});
@override
Widget build(BuildContext context) {
return EditAppScreen(publisherName: publisherName, projectId: projectId);
return EditAppScreen(
publisherName: publisherName,
projectId: projectId,
isModal: isModal,
);
}
}

View File

@@ -1,14 +1,23 @@
import 'package:flutter/material.dart';
import 'package:island/screens/developers/edit_bot.dart';
class NewBotScreen extends StatelessWidget {
final String publisherName;
final String projectId;
const NewBotScreen({super.key, required this.publisherName, required this.projectId});
final bool isModal;
const NewBotScreen({
super.key,
required this.publisherName,
required this.projectId,
this.isModal = false,
});
@override
Widget build(BuildContext context) {
return EditBotScreen(publisherName: publisherName, projectId: projectId);
return EditBotScreen(
publisherName: publisherName,
projectId: projectId,
isModal: isModal,
);
}
}

View File

@@ -1,11 +1,13 @@
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/dev_project.dart';
import 'package:island/screens/developers/apps.dart';
import 'package:island/screens/developers/bots.dart';
import 'package:island/services/responsive.dart';
import 'package:styled_widget/styled_widget.dart';
class ProjectDetailView extends HookConsumerWidget {
final String publisherName;
@@ -54,6 +56,37 @@ class ProjectDetailView extends HookConsumerWidget {
label: Text('bots'.tr()),
),
],
leading: Container(
width: isWiderScreen(context) ? 256 : 80,
padding: const EdgeInsets.only(
left: 24,
right: 24,
bottom: 8,
top: 2,
),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.12),
),
),
),
child: Row(
spacing: 8,
children: [
IconButton(
onPressed: onBackToHub,
icon: const Icon(Icons.arrow_back),
iconSize: 16,
visualDensity: VisualDensity.compact,
),
if (isWiderScreen(context))
Expanded(child: Text("backToHub").tr()),
],
),
),
),
),
),
@@ -69,6 +102,7 @@ class ProjectDetailView extends HookConsumerWidget {
],
),
),
const Gap(4),
],
);
} else {
@@ -97,7 +131,7 @@ class ProjectDetailView extends HookConsumerWidget {
),
BotsScreen(publisherName: publisherName, projectId: project.id),
],
),
).padding(horizontal: 8),
),
],
);

View File

@@ -16,6 +16,7 @@ import 'package:island/services/responsive.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/models/post.dart';
import 'package:island/widgets/check_in.dart';
import 'package:island/widgets/navigation/fab_menu.dart';
import 'package:island/widgets/post/post_featured.dart';
import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/post/compose_card.dart';
@@ -72,6 +73,22 @@ class ExploreScreen extends HookConsumerWidget {
final tabController = useTabController(initialLength: 3);
final currentFilter = useState<String?>(null);
useEffect(() {
// Set FAB type to chat
final fabMenuNotifier = ref.read(fabMenuTypeProvider.notifier);
Future(() {
fabMenuNotifier.state = FabMenuType.compose;
});
return () {
// Clean up: reset FAB type to main
WidgetsBinding.instance.addPostFrameCallback((_) {
if (fabMenuNotifier.state == FabMenuType.compose) {
fabMenuNotifier.state = FabMenuType.main;
}
});
};
}, []);
useEffect(() {
void listener() {
switch (tabController.index) {
@@ -130,6 +147,7 @@ class ExploreScreen extends HookConsumerWidget {
tabAlignment: TabAlignment.start,
isScrollable: true,
dividerColor: Colors.transparent,
labelPadding: const EdgeInsets.symmetric(horizontal: 12),
tabs: [
Tab(
icon: Tooltip(
@@ -375,6 +393,7 @@ class ExploreScreen extends HookConsumerWidget {
tabAlignment: TabAlignment.start,
isScrollable: true,
dividerColor: Colors.transparent,
labelPadding: const EdgeInsets.symmetric(horizontal: 12),
tabs: [
Tab(
icon: Tooltip(
@@ -634,7 +653,7 @@ class _DiscoveryActivityItem extends StatelessWidget {
}
class _ActivityListView extends HookConsumerWidget {
final CursorPagingData<SnActivity> data;
final CursorPagingData<SnTimelineEvent> data;
final int widgetCount;
final Widget endItemView;
final ActivityListNotifier activitiesNotifier;
@@ -697,13 +716,15 @@ class _ActivityListView extends HookConsumerWidget {
@riverpod
class ActivityListNotifier extends _$ActivityListNotifier
with CursorPagingNotifierMixin<SnActivity> {
with CursorPagingNotifierMixin<SnTimelineEvent> {
@override
Future<CursorPagingData<SnActivity>> build(String? filter) =>
Future<CursorPagingData<SnTimelineEvent>> build(String? filter) =>
fetch(cursor: null);
@override
Future<CursorPagingData<SnActivity>> fetch({required String? cursor}) async {
Future<CursorPagingData<SnTimelineEvent>> fetch({
required String? cursor,
}) async {
final client = ref.read(apiClientProvider);
final take = 20;
@@ -716,13 +737,13 @@ class ActivityListNotifier extends _$ActivityListNotifier
};
final response = await client.get(
'/sphere/activities',
'/sphere/timeline',
queryParameters: queryParameters,
);
final List<SnActivity> items =
final List<SnTimelineEvent> items =
(response.data as List)
.map((e) => SnActivity.fromJson(e as Map<String, dynamic>))
.map((e) => SnTimelineEvent.fromJson(e as Map<String, dynamic>))
.toList();
final hasMore = (items.firstOrNull?.type ?? 'empty') != 'empty';
@@ -742,7 +763,7 @@ class ActivityListNotifier extends _$ActivityListNotifier
);
}
void updateOne(int index, SnActivity activity) {
void updateOne(int index, SnTimelineEvent activity) {
final currentState = state.valueOrNull;
if (currentState == null) return;

View File

@@ -7,7 +7,7 @@ part of 'explore.dart';
// **************************************************************************
String _$activityListNotifierHash() =>
r'167021cada54da7c8d8437eef1ffb387a92ea2e3';
r'77ffc7852feffa5438b56fa26123d453b7c310cf';
/// Copied from Dart SDK
class _SystemHash {
@@ -31,10 +31,11 @@ class _SystemHash {
}
abstract class _$ActivityListNotifier
extends BuildlessAutoDisposeAsyncNotifier<CursorPagingData<SnActivity>> {
extends
BuildlessAutoDisposeAsyncNotifier<CursorPagingData<SnTimelineEvent>> {
late final String? filter;
FutureOr<CursorPagingData<SnActivity>> build(String? filter);
FutureOr<CursorPagingData<SnTimelineEvent>> build(String? filter);
}
/// See also [ActivityListNotifier].
@@ -43,7 +44,7 @@ const activityListNotifierProvider = ActivityListNotifierFamily();
/// See also [ActivityListNotifier].
class ActivityListNotifierFamily
extends Family<AsyncValue<CursorPagingData<SnActivity>>> {
extends Family<AsyncValue<CursorPagingData<SnTimelineEvent>>> {
/// See also [ActivityListNotifier].
const ActivityListNotifierFamily();
@@ -79,7 +80,7 @@ class ActivityListNotifierProvider
extends
AutoDisposeAsyncNotifierProviderImpl<
ActivityListNotifier,
CursorPagingData<SnActivity>
CursorPagingData<SnTimelineEvent>
> {
/// See also [ActivityListNotifier].
ActivityListNotifierProvider(String? filter)
@@ -110,7 +111,7 @@ class ActivityListNotifierProvider
final String? filter;
@override
FutureOr<CursorPagingData<SnActivity>> runNotifierBuild(
FutureOr<CursorPagingData<SnTimelineEvent>> runNotifierBuild(
covariant ActivityListNotifier notifier,
) {
return notifier.build(filter);
@@ -135,7 +136,7 @@ class ActivityListNotifierProvider
@override
AutoDisposeAsyncNotifierProviderElement<
ActivityListNotifier,
CursorPagingData<SnActivity>
CursorPagingData<SnTimelineEvent>
>
createElement() {
return _ActivityListNotifierProviderElement(this);
@@ -158,7 +159,7 @@ class ActivityListNotifierProvider
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin ActivityListNotifierRef
on AutoDisposeAsyncNotifierProviderRef<CursorPagingData<SnActivity>> {
on AutoDisposeAsyncNotifierProviderRef<CursorPagingData<SnTimelineEvent>> {
/// The parameter `filter` of this provider.
String? get filter;
}
@@ -167,7 +168,7 @@ class _ActivityListNotifierProviderElement
extends
AutoDisposeAsyncNotifierProviderElement<
ActivityListNotifier,
CursorPagingData<SnActivity>
CursorPagingData<SnTimelineEvent>
>
with ActivityListNotifierRef {
_ActivityListNotifierProviderElement(super.provider);

View File

@@ -1,27 +1,6 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/models/post.dart';
import 'package:island/screens/creators/publishers_form.dart';
import 'package:island/screens/posts/compose_article.dart';
import 'package:island/screens/posts/post_detail.dart';
import 'package:island/services/compose_storage_db.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.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_toolbar.dart';
import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/post/publishers_modal.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
part 'compose.freezed.dart';
part 'compose.g.dart';
@@ -41,358 +20,3 @@ sealed class PostComposeInitialState with _$PostComposeInitialState {
factory PostComposeInitialState.fromJson(Map<String, dynamic> json) =>
_$PostComposeInitialStateFromJson(json);
}
class PostEditScreen extends HookConsumerWidget {
final String id;
const PostEditScreen({super.key, required this.id});
@override
Widget build(BuildContext context, WidgetRef ref) {
final post = ref.watch(postProvider(id));
return post.when(
data: (post) => PostComposeScreen(originalPost: post),
loading:
() => AppScaffold(
isNoBackground: false,
appBar: AppBar(leading: const PageBackButton()),
body: const Center(child: CircularProgressIndicator()),
),
error:
(e, _) => AppScaffold(
isNoBackground: false,
appBar: AppBar(leading: const PageBackButton()),
body: Text('Error: $e', textAlign: TextAlign.center),
),
);
}
}
class PostComposeScreen extends HookConsumerWidget {
final SnPost? originalPost;
final int? type;
final PostComposeInitialState? initialState;
const PostComposeScreen({
super.key,
this.type,
this.initialState,
this.originalPost,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Determine the compose type: auto-detect from edited post or use query parameter
final composeType = originalPost?.type ?? type ?? 0;
final repliedPost = initialState?.replyingTo ?? originalPost?.repliedPost;
final forwardedPost =
initialState?.forwardingTo ?? originalPost?.forwardedPost;
// If type is 1 (article), return ArticleComposeScreen
if (composeType == 1) {
return ArticleComposeScreen(originalPost: originalPost);
}
// When editing, preserve the original replied/forwarded post references
final effectiveRepliedPost = repliedPost ?? originalPost?.repliedPost;
final effectiveForwardedPost = forwardedPost ?? originalPost?.forwardedPost;
final publishers = ref.watch(publishersManagedProvider);
final state = useMemoized(
() => ComposeLogic.createState(
originalPost: originalPost,
forwardedPost: effectiveForwardedPost,
repliedPost: effectiveRepliedPost,
postType: 0, // Regular post type
),
[originalPost, effectiveForwardedPost, effectiveRepliedPost],
);
// Add a listener to the entire state to trigger rebuilds
final stateNotifier = useMemoized(
() => Listenable.merge([
state.titleController,
state.descriptionController,
state.contentController,
state.visibility,
state.attachments,
state.attachmentProgress,
state.currentPublisher,
state.submitting,
]),
[state],
);
useListenable(stateNotifier);
// Start auto-save when component mounts
useEffect(() {
if (originalPost == null) {
// Only auto-save for new posts, not edits
state.startAutoSave(ref);
}
return () => state.stopAutoSave();
}, [state]);
// Initialize publisher once when data is available
useEffect(() {
if (publishers.value?.isNotEmpty ?? false) {
if (state.currentPublisher.value == null) {
// If no publisher is set, use the first available one
state.currentPublisher.value = publishers.value!.first;
}
}
return null;
}, [publishers]);
// Load initial state if provided (for sharing functionality)
useEffect(() {
if (initialState != null) {
state.titleController.text = initialState!.title ?? '';
state.descriptionController.text = initialState!.description ?? '';
state.contentController.text = initialState!.content ?? '';
if (initialState!.visibility != null) {
state.visibility.value = initialState!.visibility!;
}
if (initialState!.attachments.isNotEmpty) {
state.attachments.value = List.from(initialState!.attachments);
}
}
return null;
}, [initialState]);
// Load draft if available (only for new posts without initial state)
useEffect(() {
if (originalPost == null &&
effectiveForwardedPost == null &&
effectiveRepliedPost == null &&
initialState == null) {
// Try to load the most recent draft
final drafts = ref.read(composeStorageNotifierProvider);
if (drafts.isNotEmpty) {
final mostRecentDraft = drafts.values.reduce(
(a, b) =>
(a.updatedAt ?? DateTime(0)).isAfter(b.updatedAt ?? DateTime(0))
? a
: b,
);
// Only load if the draft has meaningful content
if (mostRecentDraft.content?.isNotEmpty == true ||
mostRecentDraft.title?.isNotEmpty == true) {
state.titleController.text = mostRecentDraft.title ?? '';
state.descriptionController.text =
mostRecentDraft.description ?? '';
state.contentController.text = mostRecentDraft.content ?? '';
state.visibility.value = mostRecentDraft.visibility;
}
}
}
return null;
}, []);
// Dispose state when widget is disposed
useEffect(() {
return () {
state.stopAutoSave();
ComposeLogic.dispose(state);
};
}, []);
// Helper methods
void showSettingsSheet() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => ComposeSettingsSheet(state: state),
);
}
return PopScope(
onPopInvoked: (_) {
if (originalPost == null) {
ComposeLogic.saveDraft(ref, state);
}
},
child: AppScaffold(
isNoBackground: false,
appBar: AppBar(
leading: const PageBackButton(),
actions: [
IconButton(
icon: const Icon(Symbols.settings),
onPressed: showSettingsSheet,
tooltip: 'postSettings'.tr(),
),
IconButton(
onPressed:
state.submitting.value
? null
: () => ComposeLogic.performAction(
ref,
state,
context,
originalPost: originalPost,
repliedPost: repliedPost,
forwardedPost: forwardedPost,
),
icon:
state.submitting.value
? SizedBox(
width: 28,
height: 28,
child: const CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2.5,
),
).center()
: Icon(
originalPost != null ? Symbols.edit : Symbols.upload,
),
),
const Gap(8),
],
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Reply/Forward info section
ComposeInfoBanner(
originalPost: originalPost,
replyingTo: repliedPost,
forwardingTo: forwardedPost,
onReferencePostTap: (context, post) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder:
(context) => DraggableScrollableSheet(
initialChildSize: 0.7,
maxChildSize: 0.9,
minChildSize: 0.5,
builder:
(context, scrollController) => Container(
decoration: BoxDecoration(
color:
Theme.of(context).scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(16),
),
),
child: Column(
children: [
Container(
width: 40,
height: 4,
margin: const EdgeInsets.symmetric(
vertical: 8,
),
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.outline,
borderRadius: BorderRadius.circular(2),
),
),
Expanded(
child: SingleChildScrollView(
controller: scrollController,
padding: const EdgeInsets.all(16),
child: PostItem(item: post),
),
),
],
),
),
),
);
},
),
// Main content area
Expanded(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: Row(
spacing: 12,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Publisher profile picture
GestureDetector(
child: ProfilePictureWidget(
fileId: state.currentPublisher.value?.picture?.id,
radius: 20,
fallbackIcon:
state.currentPublisher.value == null
? Symbols.question_mark
: null,
),
onTap: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) => const PublisherModal(),
).then((value) {
if (value != null) {
state.currentPublisher.value = value;
}
});
},
).padding(top: 16),
// Post content form
Expanded(
child: KeyboardListener(
focusNode: FocusNode(),
onKeyEvent:
(event) => ComposeLogic.handleKeyPress(
event,
state,
ref,
context,
originalPost: originalPost,
repliedPost: repliedPost,
forwardedPost: forwardedPost,
),
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ComposeFormFields(
state: state,
showPublisherAvatar: false,
onPublisherTap: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder:
(context) => const PublisherModal(),
).then((value) {
if (value != null) {
state.currentPublisher.value = value;
}
});
},
),
const Gap(8),
ComposeAttachments(
state: state,
isCompact: false,
),
],
),
),
),
),
],
).padding(horizontal: 16),
).alignment(Alignment.topCenter),
),
// Bottom toolbar
ComposeToolbar(state: state, originalPost: originalPost),
],
),
),
);
}
}

View File

@@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/models/post.dart';
import 'package:island/screens/creators/publishers_form.dart';
import 'package:island/screens/posts/compose.dart';
import 'package:island/screens/posts/post_detail.dart';
import 'package:island/services/compose_storage_db.dart';
import 'package:island/services/responsive.dart';
@@ -49,8 +50,9 @@ class ArticleEditScreen extends HookConsumerWidget {
class ArticleComposeScreen extends HookConsumerWidget {
final SnPost? originalPost;
final PostComposeInitialState? initialState;
const ArticleComposeScreen({super.key, this.originalPost});
const ArticleComposeScreen({super.key, this.originalPost, this.initialState});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -100,9 +102,25 @@ class ArticleComposeScreen extends HookConsumerWidget {
return null;
}, [publishers]);
// Load initial state if provided (for sharing functionality)
useEffect(() {
if (initialState != null) {
state.titleController.text = initialState!.title ?? '';
state.descriptionController.text = initialState!.description ?? '';
state.contentController.text = initialState!.content ?? '';
if (initialState!.visibility != null) {
state.visibility.value = initialState!.visibility!;
}
if (initialState!.attachments.isNotEmpty) {
state.attachments.value = List.from(initialState!.attachments);
}
}
return null;
}, [initialState]);
// Load draft if available (only for new articles)
useEffect(() {
if (originalPost == null) {
if (originalPost == null && initialState == null) {
// Try to load the most recent article draft
final drafts = ref.read(composeStorageNotifierProvider);
if (drafts.isNotEmpty) {
@@ -199,6 +217,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
border: Border.all(color: colorScheme.outline.withOpacity(0.3)),
borderRadius: BorderRadius.circular(8),
),
margin: const EdgeInsets.symmetric(vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -219,7 +238,12 @@ class ArticleComposeScreen extends HookConsumerWidget {
],
),
),
Expanded(child: widgetItem),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: widgetItem,
),
),
],
),
);
@@ -246,7 +270,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
}
});
},
),
).padding(top: 16),
// Attachments preview
ValueListenableBuilder<List<UniversalFile>>(

View File

@@ -201,7 +201,7 @@ class PostCategoryDetailScreen extends HookConsumerWidget {
);
},
icon: const Icon(
Symbols.add_circle,
Symbols.remove_circle,
),
label: Text('unsubscribe'.tr()),
)
@@ -214,7 +214,7 @@ class PostCategoryDetailScreen extends HookConsumerWidget {
);
},
icon: const Icon(
Symbols.remove_circle,
Symbols.add_circle,
),
label: Text('subscribe'.tr()),
),

View File

@@ -18,6 +18,7 @@ import 'package:island/widgets/post/post_award_history_sheet.dart';
import 'package:island/widgets/post/post_pin_sheet.dart';
import 'package:island/widgets/post/post_quick_reply.dart';
import 'package:island/widgets/post/post_replies.dart';
import 'package:island/widgets/post/compose_sheet.dart';
import 'package:island/widgets/response.dart';
import 'package:island/utils/share_utils.dart';
import 'package:island/widgets/safety/abuse_report_helper.dart';
@@ -107,13 +108,21 @@ class PostActionButtons extends HookConsumerWidget {
final editButtons = <Widget>[
FilledButton.tonal(
onPressed: () {
context.pushNamed('postEdit', pathParameters: {'id': post.id}).then(
(value) {
if (value != null) {
if (post.type == 1) {
context
.pushNamed('articleEdit', pathParameters: {'id': post.id})
.then((value) {
if (value != null) {
onRefresh?.call();
}
});
} else {
PostComposeSheet.show(context, originalPost: post).then((value) {
if (value == true) {
onRefresh?.call();
}
},
);
});
}
},
style: FilledButton.styleFrom(
shape: const RoundedRectangleBorder(
@@ -229,9 +238,9 @@ class PostActionButtons extends HookConsumerWidget {
final replyButtons = <Widget>[
FilledButton.tonal(
onPressed: () {
context.pushNamed(
'postCompose',
extra: PostComposeInitialState(replyingTo: post),
PostComposeSheet.show(
context,
initialState: PostComposeInitialState(replyingTo: post),
);
},
style: FilledButton.styleFrom(
@@ -255,9 +264,9 @@ class PostActionButtons extends HookConsumerWidget {
message: 'forward'.tr(),
child: FilledButton.tonal(
onPressed: () {
context.pushNamed(
'postCompose',
extra: PostComposeInitialState(forwardingTo: post),
PostComposeSheet.show(
context,
initialState: PostComposeInitialState(forwardingTo: post),
);
},
style: FilledButton.styleFrom(

View File

@@ -1,599 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart';
import 'package:island/models/publisher.dart';
import 'package:island/models/account.dart';
import 'package:island/models/heatmap.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart';
import 'package:island/services/color.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/account/account_name.dart';
import 'package:island/widgets/account/badge.dart';
import 'package:island/widgets/account/status.dart';
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/markdown.dart';
import 'package:island/widgets/post/post_list.dart';
import 'package:island/widgets/activity_heatmap.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:island/services/color_extraction.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart';
part 'pub_profile.g.dart';
class _PublisherBasisWidget extends StatelessWidget {
final SnPublisher data;
final AsyncValue<SnSubscriptionStatus> subStatus;
final ValueNotifier<bool> subscribing;
final VoidCallback subscribe;
final VoidCallback unsubscribe;
const _PublisherBasisWidget({
required this.data,
required this.subStatus,
required this.subscribing,
required this.subscribe,
required this.unsubscribe,
});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 20,
children: [
GestureDetector(
child: Badge(
isLabelVisible: data.type == 0,
padding: EdgeInsets.all(4),
label: Icon(
Symbols.launch,
size: 16,
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},
);
}
},
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
spacing: 6,
children: [
Text(data.nick).fontSize(20),
if (data.verification != null)
VerificationMark(mark: data.verification!),
Expanded(
child: Text(
'@${data.name}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
).fontSize(14).opacity(0.85),
),
],
),
if (data.type == 0 && data.account != null)
Row(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 6,
children: [
Icon(
data.type == 0 ? Symbols.person : Symbols.workspaces,
fill: 1,
size: 17,
),
Text(
'publisherBelongsTo'.tr(args: ['@${data.account!.name}']),
).fontSize(14),
],
).opacity(0.85),
const Gap(4),
if (data.type == 0 && data.account != null)
AccountStatusWidget(
uname: data.account!.name,
padding: EdgeInsets.zero,
),
subStatus
.when(
data:
(status) => FilledButton.icon(
onPressed:
subscribing.value
? null
: (status.isSubscribed
? unsubscribe
: subscribe),
icon: Icon(
status.isSubscribed
? Symbols.remove_circle
: Symbols.add_circle,
),
label:
Text(
status.isSubscribed
? 'unsubscribe'
: 'subscribe',
).tr(),
style: ButtonStyle(
visualDensity: VisualDensity(vertical: -2),
),
),
error: (_, _) => const SizedBox(),
loading:
() => const SizedBox(
height: 36,
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
)
.padding(top: 8),
],
),
),
],
).padding(horizontal: 24, top: 24);
}
}
class _PublisherBadgesWidget extends StatelessWidget {
final SnPublisher data;
final AsyncValue<List<SnAccountBadge>> badges;
const _PublisherBadgesWidget({required this.data, required this.badges});
@override
Widget build(BuildContext context) {
return (badges.value?.isNotEmpty ?? false)
? Card(
child: BadgeList(
badges: badges.value!,
).padding(horizontal: 26, vertical: 20),
).padding(horizontal: 4)
: const SizedBox.shrink();
}
}
class _PublisherVerificationWidget extends StatelessWidget {
final SnPublisher data;
const _PublisherVerificationWidget({required this.data});
@override
Widget build(BuildContext context) {
return (data.verification != null)
? Card(
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: VerificationStatusCard(mark: data.verification!),
)
: const SizedBox.shrink();
}
}
class _PublisherBioWidget extends StatelessWidget {
final SnPublisher data;
const _PublisherBioWidget({required this.data});
@override
Widget build(BuildContext context) {
return Card(
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('bio').tr().bold().fontSize(15).padding(bottom: 8),
if (data.bio.isEmpty)
Text('descriptionNone').tr().italic()
else
MarkdownTextContent(
content: data.bio,
linesMargin: EdgeInsets.zero,
),
],
).padding(horizontal: 20, vertical: 16),
);
}
}
class _PublisherHeatmapWidget extends StatelessWidget {
final AsyncValue<SnHeatmap?> heatmap;
final bool forceDense;
const _PublisherHeatmapWidget({
required this.heatmap,
this.forceDense = false,
});
@override
Widget build(BuildContext context) {
return heatmap.when(
data:
(data) =>
data != null
? ActivityHeatmapWidget(
heatmap: data,
forceDense: forceDense,
).padding(horizontal: 8)
: const SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
);
}
}
class _PublisherCategoryTabWidget extends StatelessWidget {
final TabController categoryTabController;
const _PublisherCategoryTabWidget({required this.categoryTabController});
@override
Widget build(BuildContext context) {
return Card(
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: TabBar(
controller: categoryTabController,
dividerColor: Colors.transparent,
splashBorderRadius: const BorderRadius.all(Radius.circular(8)),
tabs: [
Tab(text: 'all'.tr()),
Tab(text: 'postTypePost'.tr()),
Tab(text: 'postArticle'.tr()),
],
),
);
}
}
@riverpod
Future<SnPublisher> publisher(Ref ref, String uname) async {
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get("/sphere/publishers/$uname");
return SnPublisher.fromJson(resp.data);
}
@riverpod
Future<List<SnAccountBadge>> publisherBadges(Ref ref, String pubName) async {
final pub = await ref.watch(publisherProvider(pubName).future);
if (pub.type != 0 || pub.account == null) return [];
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get(
"/pass/accounts/${pub.account!.name}/badges",
);
return List<SnAccountBadge>.from(
resp.data.map((x) => SnAccountBadge.fromJson(x)),
);
}
@riverpod
Future<SnSubscriptionStatus> publisherSubscriptionStatus(
Ref ref,
String pubName,
) async {
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get("/sphere/publishers/$pubName/subscription");
return SnSubscriptionStatus.fromJson(resp.data);
}
@riverpod
Future<Color?> publisherAppbarForcegroundColor(Ref ref, String pubName) async {
try {
final publisher = await ref.watch(publisherProvider(pubName).future);
if (publisher.background == null) return null;
final colors = await ColorExtractionService.getColorsFromImage(
CloudImageWidget.provider(
fileId: publisher.background!.id,
serverUrl: ref.watch(serverUrlProvider),
),
);
if (colors.isEmpty) return null;
final dominantColor = colors.first;
return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white;
} catch (_) {
return null;
}
}
@riverpod
Future<SnHeatmap?> publisherHeatmap(Ref ref, String uname) async {
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get('/sphere/publishers/$uname/heatmap');
return SnHeatmap.fromJson(resp.data);
}
class PublisherProfileScreen extends HookConsumerWidget {
final String name;
const PublisherProfileScreen({super.key, required this.name});
@override
Widget build(BuildContext context, WidgetRef ref) {
final publisher = ref.watch(publisherProvider(name));
final badges = ref.watch(publisherBadgesProvider(name));
final subStatus = ref.watch(publisherSubscriptionStatusProvider(name));
final heatmap = ref.watch(publisherHeatmapProvider(name));
final appbarColor = ref.watch(
publisherAppbarForcegroundColorProvider(name),
);
final categoryTabController = useTabController(initialLength: 3);
final categoryTab = useState(0);
categoryTabController.addListener(() {
categoryTab.value = categoryTabController.index;
});
final subscribing = useState(false);
Future<void> subscribe() async {
final apiClient = ref.watch(apiClientProvider);
subscribing.value = true;
try {
await apiClient.post(
"/sphere/publishers/$name/subscribe",
data: {'tier': 0},
);
ref.invalidate(publisherSubscriptionStatusProvider(name));
HapticFeedback.heavyImpact();
} catch (err) {
showErrorAlert(err);
} finally {
subscribing.value = false;
}
}
Future<void> unsubscribe() async {
final apiClient = ref.watch(apiClientProvider);
subscribing.value = true;
try {
await apiClient.post("/sphere/publishers/$name/unsubscribe");
ref.invalidate(publisherSubscriptionStatusProvider(name));
HapticFeedback.heavyImpact();
} catch (err) {
showErrorAlert(err);
} finally {
subscribing.value = false;
}
}
final appbarShadow = Shadow(
color: appbarColor.value?.invert ?? Colors.transparent,
blurRadius: 5.0,
offset: Offset(1.0, 1.0),
);
return publisher.when(
data:
(data) => AppScaffold(
isNoBackground: false,
appBar:
isWideScreen(context)
? AppBar(
foregroundColor: appbarColor.value,
leading: PageBackButton(
color: appbarColor.value,
shadows: [appbarShadow],
),
flexibleSpace: Stack(
children: [
Positioned.fill(
child:
data.background?.id != null
? CloudImageWidget(file: data.background)
: Container(
color:
Theme.of(
context,
).appBarTheme.backgroundColor,
),
),
FlexibleSpaceBar(
title: Text(
data.nick,
style: TextStyle(
color:
appbarColor.value ??
Theme.of(
context,
).appBarTheme.foregroundColor,
shadows: [appbarShadow],
),
),
background:
Container(), // Empty container since background is handled by Stack
),
],
),
)
: null,
body:
isWideScreen(context)
? Row(
children: [
Flexible(
flex: 4,
child: CustomScrollView(
slivers: [
SliverGap(16),
SliverPostList(pubName: name, pinned: true),
SliverToBoxAdapter(
child: _PublisherCategoryTabWidget(
categoryTabController: categoryTabController,
),
),
SliverPostList(
key: ValueKey(categoryTab.value),
pubName: name,
pinned: false,
type: switch (categoryTab.value) {
1 => 0,
2 => 1,
_ => null,
},
),
SliverGap(
MediaQuery.of(context).padding.bottom + 16,
),
],
).padding(left: 8),
),
Flexible(
flex: 3,
child: Align(
alignment: Alignment.topLeft,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_PublisherBasisWidget(
data: data,
subStatus: subStatus,
subscribing: subscribing,
subscribe: subscribe,
unsubscribe: unsubscribe,
).padding(bottom: 8),
_PublisherBadgesWidget(
data: data,
badges: badges,
),
_PublisherVerificationWidget(data: data),
_PublisherBioWidget(data: data),
_PublisherHeatmapWidget(
heatmap: heatmap,
forceDense: true,
),
],
),
),
),
),
],
)
: CustomScrollView(
slivers: [
SliverAppBar(
foregroundColor: appbarColor.value,
expandedHeight: 180,
pinned: true,
leading: PageBackButton(
color: appbarColor.value,
shadows: [appbarShadow],
),
flexibleSpace: Stack(
children: [
Positioned.fill(
child:
data.background?.id != null
? CloudImageWidget(
file: data.background,
)
: Container(
color:
Theme.of(
context,
).appBarTheme.backgroundColor,
),
),
FlexibleSpaceBar(
title: Text(
data.nick,
style: TextStyle(
color:
appbarColor.value ??
Theme.of(
context,
).appBarTheme.foregroundColor,
shadows: [appbarShadow],
),
),
background:
Container(), // Empty container since background is handled by Stack
),
],
),
),
SliverToBoxAdapter(
child: _PublisherBasisWidget(
data: data,
subStatus: subStatus,
subscribing: subscribing,
subscribe: subscribe,
unsubscribe: unsubscribe,
).padding(bottom: 8),
),
SliverToBoxAdapter(
child: _PublisherBadgesWidget(
data: data,
badges: badges,
),
),
SliverToBoxAdapter(
child: _PublisherVerificationWidget(data: data),
),
SliverToBoxAdapter(
child: _PublisherBioWidget(data: data),
),
SliverToBoxAdapter(
child: _PublisherHeatmapWidget(heatmap: heatmap),
),
SliverPostList(pubName: name, pinned: true),
SliverToBoxAdapter(
child: _PublisherCategoryTabWidget(
categoryTabController: categoryTabController,
),
),
SliverPostList(
key: ValueKey(categoryTab.value),
pubName: name,
pinned: false,
type: switch (categoryTab.value) {
1 => 0,
2 => 1,
_ => null,
},
),
SliverGap(MediaQuery.of(context).padding.bottom + 16),
],
),
),
error:
(error, stackTrace) => AppScaffold(
isNoBackground: false,
appBar: AppBar(leading: const PageBackButton()),
body: Center(child: Text(error.toString())),
),
loading:
() => AppScaffold(
isNoBackground: false,
appBar: AppBar(leading: const PageBackButton()),
body: Center(child: CircularProgressIndicator()),
),
);
}
}

View File

@@ -0,0 +1,991 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart';
import 'package:island/models/publisher.dart';
import 'package:island/models/account.dart';
import 'package:island/models/heatmap.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart';
import 'package:island/services/color.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/account/account_name.dart';
import 'package:island/widgets/account/badge.dart';
import 'package:island/widgets/account/status.dart';
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/markdown.dart';
import 'package:island/widgets/post/post_list.dart';
import 'package:island/widgets/activity_heatmap.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:island/services/color_extraction.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart';
part 'publisher_profile.g.dart';
class _PublisherBasisWidget extends StatelessWidget {
final SnPublisher data;
final AsyncValue<SnSubscriptionStatus> subStatus;
final ValueNotifier<bool> subscribing;
final VoidCallback subscribe;
final VoidCallback unsubscribe;
const _PublisherBasisWidget({
required this.data,
required this.subStatus,
required this.subscribing,
required this.subscribe,
required this.unsubscribe,
});
@override
Widget build(BuildContext context) {
return Card(
child: Builder(
builder: (context) {
final hasBackground = data.background?.id != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isWideScreen(context) && hasBackground)
Stack(
clipBehavior: Clip.none,
children: [
ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(8),
topRight: Radius.circular(8),
),
child: AspectRatio(
aspectRatio: 16 / 7,
child: CloudImageWidget(
file: data.background,
fit: BoxFit.cover,
),
),
),
Positioned(
bottom: -24,
left: 16,
child: ProfilePictureWidget(
file: data.picture,
radius: 32,
borderRadius: data.type == 0 ? null : 12,
),
),
],
),
Builder(
builder: (context) {
final showBackground = isWideScreen(context) && hasBackground;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: showBackground ? 0 : 20,
children: [
if (!showBackground)
GestureDetector(
child: Badge(
isLabelVisible: data.type == 0,
padding: EdgeInsets.all(4),
label: Icon(
Symbols.launch,
size: 16,
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},
);
}
},
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
spacing: 6,
children: [
if (data.account != null && data.type == 0)
AccountName(
account: data.account!,
textOverride: data.nick,
hideVerificationMark: true,
style: TextStyle(fontSize: 20),
)
else
Text(data.nick).fontSize(20),
if (data.verification != null)
VerificationMark(mark: data.verification!),
if (isWideScreen(context))
Expanded(
child: Text(
'@${data.name}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
).fontSize(14).opacity(0.85),
),
],
),
if (!isWideScreen(context))
Text(
'@${data.name}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
).fontSize(14).opacity(0.85).padding(bottom: 2.5),
if (data.type == 0 && data.account != null)
Row(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 6,
children: [
Icon(
data.type == 0
? Symbols.person
: Symbols.workspaces,
fill: 1,
size: 17,
),
Text(
'publisherBelongsTo'.tr(
args: ['@${data.account!.name}'],
),
).fontSize(14),
],
).opacity(0.85),
const Gap(4),
if (data.type == 0 && data.account != null)
AccountStatusWidget(
uname: data.account!.name,
padding: EdgeInsets.zero,
),
subStatus
.when(
data:
(status) => FilledButton.icon(
onPressed:
subscribing.value
? null
: (status.isSubscribed
? unsubscribe
: subscribe),
icon: Icon(
status.isSubscribed
? Symbols.remove_circle
: Symbols.add_circle,
),
label:
Text(
status.isSubscribed
? 'unsubscribe'
: 'subscribe',
).tr(),
style: ButtonStyle(
visualDensity: VisualDensity(
vertical: -2,
),
),
),
error: (_, _) => const SizedBox(),
loading:
() => const SizedBox(
height: 36,
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
),
),
)
.padding(vertical: 12),
],
),
),
],
).padding(
left: 16,
right: 16,
top: 16 + (showBackground ? 16 : 0),
);
},
),
],
);
},
),
);
}
}
class _PublisherBadgesWidget extends StatelessWidget {
final SnPublisher data;
final AsyncValue<List<SnAccountBadge>> badges;
const _PublisherBadgesWidget({required this.data, required this.badges});
@override
Widget build(BuildContext context) {
return (badges.value?.isNotEmpty ?? false)
? Card(
child: BadgeList(
badges: badges.value!,
).padding(horizontal: 26, vertical: 20),
).padding(horizontal: 4)
: const SizedBox.shrink();
}
}
class _PublisherVerificationWidget extends StatelessWidget {
final SnPublisher data;
const _PublisherVerificationWidget({required this.data});
@override
Widget build(BuildContext context) {
return (data.verification != null)
? Card(
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: VerificationStatusCard(mark: data.verification!),
)
: const SizedBox.shrink();
}
}
class _PublisherBioWidget extends StatelessWidget {
final SnPublisher data;
const _PublisherBioWidget({required this.data});
@override
Widget build(BuildContext context) {
return Card(
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('bio').tr().bold().fontSize(15).padding(bottom: 8),
if (data.bio.isEmpty)
Text('descriptionNone').tr().italic()
else
MarkdownTextContent(
content: data.bio,
linesMargin: EdgeInsets.zero,
),
],
).padding(horizontal: 20, vertical: 16),
);
}
}
class _PublisherHeatmapWidget extends StatelessWidget {
final AsyncValue<SnHeatmap?> heatmap;
final bool forceDense;
const _PublisherHeatmapWidget({
required this.heatmap,
this.forceDense = false,
});
@override
Widget build(BuildContext context) {
return heatmap.when(
data:
(data) =>
data != null
? ActivityHeatmapWidget(
heatmap: data,
forceDense: forceDense,
).padding(horizontal: 8)
: const SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
);
}
}
class _PublisherCategoryTabWidget extends StatelessWidget {
final TabController categoryTabController;
final ValueNotifier<bool?> includeReplies;
final ValueNotifier<bool> mediaOnly;
final ValueNotifier<String?> queryTerm;
final ValueNotifier<String?> order;
final ValueNotifier<bool> orderDesc;
final ValueNotifier<int?> periodStart;
final ValueNotifier<int?> periodEnd;
final ValueNotifier<bool> showAdvancedFilters;
const _PublisherCategoryTabWidget({
required this.categoryTabController,
required this.includeReplies,
required this.mediaOnly,
required this.queryTerm,
required this.order,
required this.orderDesc,
required this.periodStart,
required this.periodEnd,
required this.showAdvancedFilters,
});
@override
Widget build(BuildContext context) {
return Card(
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Column(
children: [
TabBar(
controller: categoryTabController,
dividerColor: Colors.transparent,
splashBorderRadius: const BorderRadius.all(Radius.circular(8)),
tabs: [
Tab(text: 'all'.tr()),
Tab(text: 'postTypePost'.tr()),
Tab(text: 'postArticle'.tr()),
],
),
const Divider(height: 1),
Column(
children: [
Row(
children: [
Expanded(
child: CheckboxListTile(
title: Text('reply'.tr()),
value: includeReplies.value,
tristate: true,
onChanged: (value) {
// Cycle through: null -> false -> true -> null
if (includeReplies.value == null) {
includeReplies.value = false;
} else if (includeReplies.value == false) {
includeReplies.value = true;
} else {
includeReplies.value = null;
}
},
dense: true,
controlAffinity: ListTileControlAffinity.leading,
secondary: const Icon(Symbols.reply),
),
),
Expanded(
child: CheckboxListTile(
title: Text('attachments'.tr()),
value: mediaOnly.value,
onChanged: (value) {
if (value != null) {
mediaOnly.value = value;
}
},
dense: true,
controlAffinity: ListTileControlAffinity.leading,
secondary: const Icon(Symbols.attachment),
),
),
],
),
CheckboxListTile(
title: Text('descendingOrder'.tr()),
value: orderDesc.value,
onChanged: (value) {
if (value != null) {
orderDesc.value = value;
}
},
dense: true,
controlAffinity: ListTileControlAffinity.leading,
secondary: const Icon(Symbols.sort),
),
],
),
const Divider(height: 1),
ListTile(
title: Text('advancedFilters'.tr()),
leading: const Icon(Symbols.filter_list),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(const Radius.circular(8)),
),
trailing: Icon(
showAdvancedFilters.value
? Symbols.expand_less
: Symbols.expand_more,
),
onTap: () {
showAdvancedFilters.value = !showAdvancedFilters.value;
},
),
if (showAdvancedFilters.value) ...[
const Divider(height: 1),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
decoration: InputDecoration(
labelText: 'search'.tr(),
hintText: 'searchPosts'.tr(),
prefixIcon: const Icon(Symbols.search),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
onChanged: (value) {
queryTerm.value = value.isEmpty ? null : value;
},
),
const Gap(12),
DropdownButtonFormField<String>(
decoration: InputDecoration(
labelText: 'sortBy'.tr(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
value: order.value,
items: [
DropdownMenuItem(value: 'date', child: Text('date'.tr())),
DropdownMenuItem(
value: 'popularity',
child: Text('popularity'.tr()),
),
],
onChanged: (value) {
order.value = value;
},
),
const Gap(12),
Row(
children: [
Expanded(
child: InkWell(
onTap: () async {
final pickedDate = await showDatePicker(
context: context,
initialDate:
periodStart.value != null
? DateTime.fromMillisecondsSinceEpoch(
periodStart.value! * 1000,
)
: DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime.now().add(
const Duration(days: 365),
),
);
if (pickedDate != null) {
periodStart.value =
pickedDate.millisecondsSinceEpoch ~/ 1000;
}
},
child: InputDecorator(
decoration: InputDecoration(
labelText: 'fromDate'.tr(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
suffixIcon: const Icon(Symbols.calendar_today),
),
child: Text(
periodStart.value != null
? DateTime.fromMillisecondsSinceEpoch(
periodStart.value! * 1000,
).toString().split(' ')[0]
: 'selectDate'.tr(),
),
),
),
),
const Gap(8),
Expanded(
child: InkWell(
onTap: () async {
final pickedDate = await showDatePicker(
context: context,
initialDate:
periodEnd.value != null
? DateTime.fromMillisecondsSinceEpoch(
periodEnd.value! * 1000,
)
: DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime.now().add(
const Duration(days: 365),
),
);
if (pickedDate != null) {
periodEnd.value =
pickedDate.millisecondsSinceEpoch ~/ 1000;
}
},
child: InputDecorator(
decoration: InputDecoration(
labelText: 'toDate'.tr(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
suffixIcon: const Icon(Symbols.calendar_today),
),
child: Text(
periodEnd.value != null
? DateTime.fromMillisecondsSinceEpoch(
periodEnd.value! * 1000,
).toString().split(' ')[0]
: 'selectDate'.tr(),
),
),
),
),
],
),
],
),
),
],
],
),
);
}
}
@riverpod
Future<SnPublisher> publisher(Ref ref, String uname) async {
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get("/sphere/publishers/$uname");
return SnPublisher.fromJson(resp.data);
}
@riverpod
Future<List<SnAccountBadge>> publisherBadges(Ref ref, String pubName) async {
final pub = await ref.watch(publisherProvider(pubName).future);
if (pub.type != 0 || pub.account == null) return [];
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get(
"/pass/accounts/${pub.account!.name}/badges",
);
return List<SnAccountBadge>.from(
resp.data.map((x) => SnAccountBadge.fromJson(x)),
);
}
@riverpod
Future<SnSubscriptionStatus> publisherSubscriptionStatus(
Ref ref,
String pubName,
) async {
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get("/sphere/publishers/$pubName/subscription");
return SnSubscriptionStatus.fromJson(resp.data);
}
@riverpod
Future<Color?> publisherAppbarForcegroundColor(Ref ref, String pubName) async {
try {
final publisher = await ref.watch(publisherProvider(pubName).future);
if (publisher.background == null) return null;
final colors = await ColorExtractionService.getColorsFromImage(
CloudImageWidget.provider(
fileId: publisher.background!.id,
serverUrl: ref.watch(serverUrlProvider),
),
);
if (colors.isEmpty) return null;
final dominantColor = colors.first;
return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white;
} catch (_) {
return null;
}
}
@riverpod
Future<SnHeatmap?> publisherHeatmap(Ref ref, String uname) async {
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get('/sphere/publishers/$uname/heatmap');
return SnHeatmap.fromJson(resp.data);
}
class PublisherProfileScreen extends HookConsumerWidget {
final String name;
const PublisherProfileScreen({super.key, required this.name});
@override
Widget build(BuildContext context, WidgetRef ref) {
final publisher = ref.watch(publisherProvider(name));
final badges = ref.watch(publisherBadgesProvider(name));
final subStatus = ref.watch(publisherSubscriptionStatusProvider(name));
final heatmap = ref.watch(publisherHeatmapProvider(name));
final appbarColor = ref.watch(
publisherAppbarForcegroundColorProvider(name),
);
final categoryTabController = useTabController(initialLength: 3);
final categoryTab = useState(0);
categoryTabController.addListener(() {
categoryTab.value = categoryTabController.index;
});
final includeReplies = useState<bool?>(null);
final mediaOnly = useState(false);
final queryTerm = useState<String?>(null);
final order = useState<String?>('date'); // 'popularity' or 'date'
final orderDesc = useState(
true,
); // true for descending, false for ascending
final periodStart = useState<int?>(null);
final periodEnd = useState<int?>(null);
final showAdvancedFilters = useState(false);
final subscribing = useState(false);
final isPinnedExpanded = useState(true);
Future<void> subscribe() async {
final apiClient = ref.watch(apiClientProvider);
subscribing.value = true;
try {
await apiClient.post(
"/sphere/publishers/$name/subscribe",
data: {'tier': 0},
);
ref.invalidate(publisherSubscriptionStatusProvider(name));
HapticFeedback.heavyImpact();
} catch (err) {
showErrorAlert(err);
} finally {
subscribing.value = false;
}
}
Future<void> unsubscribe() async {
final apiClient = ref.watch(apiClientProvider);
subscribing.value = true;
try {
await apiClient.post("/sphere/publishers/$name/unsubscribe");
ref.invalidate(publisherSubscriptionStatusProvider(name));
HapticFeedback.heavyImpact();
} catch (err) {
showErrorAlert(err);
} finally {
subscribing.value = false;
}
}
final appbarShadow = Shadow(
color: appbarColor.value?.invert ?? Colors.transparent,
blurRadius: 5.0,
offset: Offset(1.0, 1.0),
);
return publisher.when(
data:
(data) => AppScaffold(
isNoBackground: false,
appBar:
isWideScreen(context)
? AppBar(
foregroundColor: appbarColor.value,
leading: PageBackButton(
color: appbarColor.value,
shadows: [appbarShadow],
),
title: Text(
data.nick,
style: TextStyle(
color:
appbarColor.value ??
Theme.of(context).appBarTheme.foregroundColor,
shadows: [appbarShadow],
),
),
)
: null,
body:
isWideScreen(context)
? Row(
children: [
Flexible(
flex: 4,
child: CustomScrollView(
slivers: [
SliverGap(16),
SliverToBoxAdapter(
child: Card(
margin: EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: ListTile(
title: Text('pinnedPosts'.tr()),
leading: const Icon(Symbols.push_pin),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(
Radius.circular(8),
),
),
trailing: Icon(
isPinnedExpanded.value
? Symbols.expand_less
: Symbols.expand_more,
),
onTap:
() =>
isPinnedExpanded.value =
!isPinnedExpanded.value,
),
),
),
...[
if (isPinnedExpanded.value)
SliverPostList(pubName: name, pinned: true),
],
SliverToBoxAdapter(
child: _PublisherCategoryTabWidget(
categoryTabController: categoryTabController,
includeReplies: includeReplies,
mediaOnly: mediaOnly,
queryTerm: queryTerm,
order: order,
orderDesc: orderDesc,
periodStart: periodStart,
periodEnd: periodEnd,
showAdvancedFilters: showAdvancedFilters,
),
),
SliverPostList(
key: ValueKey(
'${categoryTab.value}-${includeReplies.value}-${mediaOnly.value}-${queryTerm.value}-${order.value}-${orderDesc.value}-${periodStart.value}-${periodEnd.value}',
),
pubName: name,
pinned: false,
type:
categoryTab.value == 1
? 0
: (categoryTab.value == 2 ? 1 : null),
includeReplies: includeReplies.value,
mediaOnly: mediaOnly.value,
queryTerm: queryTerm.value,
order: order.value,
orderDesc: orderDesc.value,
periodStart: periodStart.value,
periodEnd: periodEnd.value,
),
SliverGap(
MediaQuery.of(context).padding.bottom + 16,
),
],
).padding(left: 8),
),
Flexible(
flex: 3,
child: Align(
alignment: Alignment.topLeft,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_PublisherBasisWidget(
data: data,
subStatus: subStatus,
subscribing: subscribing,
subscribe: subscribe,
unsubscribe: unsubscribe,
).padding(horizontal: 4, top: 20),
_PublisherBadgesWidget(
data: data,
badges: badges,
),
_PublisherVerificationWidget(data: data),
_PublisherBioWidget(data: data),
_PublisherHeatmapWidget(
heatmap: heatmap,
forceDense: true,
).padding(vertical: 4),
],
),
),
),
),
],
)
: CustomScrollView(
slivers: [
SliverAppBar(
foregroundColor: appbarColor.value,
expandedHeight: 180,
pinned: true,
leading: PageBackButton(
color: appbarColor.value,
shadows: [appbarShadow],
),
flexibleSpace: Stack(
children: [
Positioned.fill(
child:
data.background?.id != null
? CloudImageWidget(
file: data.background,
)
: Container(
color:
Theme.of(
context,
).appBarTheme.backgroundColor,
),
),
FlexibleSpaceBar(
title: Text(
data.nick,
style: TextStyle(
color:
appbarColor.value ??
Theme.of(
context,
).appBarTheme.foregroundColor,
shadows: [appbarShadow],
),
),
background:
Container(), // Empty container since background is handled by Stack
),
],
),
),
SliverToBoxAdapter(
child: _PublisherBasisWidget(
data: data,
subStatus: subStatus,
subscribing: subscribing,
subscribe: subscribe,
unsubscribe: unsubscribe,
).padding(horizontal: 4, top: 8),
),
SliverToBoxAdapter(
child: _PublisherBadgesWidget(
data: data,
badges: badges,
),
),
SliverToBoxAdapter(
child: _PublisherVerificationWidget(data: data),
),
SliverToBoxAdapter(
child: _PublisherBioWidget(data: data),
),
SliverToBoxAdapter(
child: _PublisherHeatmapWidget(
heatmap: heatmap,
).padding(vertical: 4),
),
SliverToBoxAdapter(
child: Card(
margin: EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: ListTile(
title: Text('pinnedPosts'.tr()),
trailing: Icon(
isPinnedExpanded.value
? Symbols.expand_less
: Symbols.expand_more,
),
onTap:
() =>
isPinnedExpanded.value =
!isPinnedExpanded.value,
),
),
),
...[
if (isPinnedExpanded.value)
SliverPostList(pubName: name, pinned: true),
],
SliverToBoxAdapter(
child: _PublisherCategoryTabWidget(
categoryTabController: categoryTabController,
includeReplies: includeReplies,
mediaOnly: mediaOnly,
queryTerm: queryTerm,
order: order,
orderDesc: orderDesc,
periodStart: periodStart,
periodEnd: periodEnd,
showAdvancedFilters: showAdvancedFilters,
),
),
SliverPostList(
key: ValueKey(
'${categoryTab.value}-${includeReplies.value}-${mediaOnly.value}-${queryTerm.value}-${order.value}-${orderDesc.value}-${periodStart.value}-${periodEnd.value}',
),
pubName: name,
pinned: false,
type:
categoryTab.value == 1
? 0
: (categoryTab.value == 2 ? 1 : null),
includeReplies: includeReplies.value,
mediaOnly: mediaOnly.value,
queryTerm: queryTerm.value,
order: order.value,
orderDesc: orderDesc.value,
periodStart: periodStart.value,
periodEnd: periodEnd.value,
),
SliverGap(MediaQuery.of(context).padding.bottom + 16),
],
),
),
error:
(error, stackTrace) => AppScaffold(
isNoBackground: false,
appBar: AppBar(leading: const PageBackButton()),
body: Center(child: Text(error.toString())),
),
loading:
() => AppScaffold(
isNoBackground: false,
appBar: AppBar(leading: const PageBackButton()),
body: Center(child: CircularProgressIndicator()),
),
);
}
}

View File

@@ -1,6 +1,6 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'pub_profile.dart';
part of 'publisher_profile.dart';
// **************************************************************************
// RiverpodGenerator

View File

@@ -51,33 +51,33 @@ class TabsScreen extends HookConsumerWidget {
final destinations = [
NavigationDestination(
label: 'explore'.tr(),
icon: const Icon(Symbols.explore),
icon: const Icon(Symbols.explore_rounded),
),
NavigationDestination(
label: 'chat'.tr(),
icon: const Icon(Symbols.chat_rounded),
icon: const Icon(Symbols.forum_rounded),
),
NavigationDestination(
label: 'realms'.tr(),
icon: const Icon(Symbols.group),
icon: const Icon(Symbols.group_rounded),
),
NavigationDestination(
label: 'account'.tr(),
icon: Badge.count(
count: notificationUnreadCount.value ?? 0,
isLabelVisible: (notificationUnreadCount.value ?? 0) > 0,
child: const Icon(Symbols.account_circle),
child: const Icon(Symbols.person_rounded),
),
),
if (wideScreen)
NavigationDestination(
label: 'creatorHub'.tr(),
icon: const Icon(Symbols.ink_pen),
icon: const Icon(Symbols.design_services_rounded),
),
if (wideScreen)
NavigationDestination(
label: 'developerHub'.tr(),
icon: const Icon(Symbols.data_object),
icon: const Icon(Symbols.data_object_rounded),
),
];

View File

@@ -1,4 +1,5 @@
import "dart:convert";
import "dart:math" as math;
import "package:dio/dio.dart";
import "package:easy_localization/easy_localization.dart";
import "package:flutter/material.dart";
@@ -57,6 +58,9 @@ class ThoughtScreen extends HookConsumerWidget {
final listController = useMemoized(() => ListController(), []);
// Scroll animation notifiers
final bottomGradientNotifier = useState(ValueNotifier<double>(0.0));
// Update local thoughts when provider data changes
useEffect(() {
thoughts.whenData((data) {
@@ -86,6 +90,20 @@ class ThoughtScreen extends HookConsumerWidget {
return null;
}, [localThoughts.value.length, isStreaming.value]);
// Add scroll listener for gradient animations
useEffect(() {
void onScroll() {
// Update gradient animations
final pixels = scrollController.position.pixels;
// Bottom gradient: appears when not at bottom (pixels > 0)
bottomGradientNotifier.value.value = (pixels / 500.0).clamp(0.0, 1.0);
}
scrollController.addListener(onScroll);
return () => scrollController.removeListener(onScroll);
}, [scrollController]);
void sendMessage() async {
if (messageController.text.trim().isEmpty) return;
@@ -258,65 +276,120 @@ class ThoughtScreen extends HookConsumerWidget {
const Gap(8),
],
),
body: Center(
child: Container(
constraints: BoxConstraints(maxWidth: 640),
child: Column(
children: [
Expanded(
child: thoughts.when(
data:
(thoughtList) => SuperListView.builder(
listController: listController,
controller: scrollController,
padding: const EdgeInsets.only(top: 16, bottom: 16),
reverse: true,
itemCount:
localThoughts.value.length +
(isStreaming.value ? 1 : 0),
itemBuilder: (context, index) {
if (isStreaming.value && index == 0) {
return ThoughtItem(
isStreaming: true,
streamingText: streamingText.value,
reasoningChunks: reasoningChunks.value,
streamingFunctionCalls: functionCalls.value,
);
}
final thoughtIndex =
isStreaming.value ? index - 1 : index;
final thought = localThoughts.value[thoughtIndex];
return ThoughtItem(
thought: thought,
thoughtIndex: thoughtIndex,
);
},
body: Stack(
children: [
// Thoughts list
Center(
child: Container(
constraints: BoxConstraints(maxWidth: 640),
child: Column(
children: [
Expanded(
child: thoughts.when(
data:
(thoughtList) => SuperListView.builder(
listController: listController,
controller: scrollController,
padding: EdgeInsets.only(
top: 16,
bottom:
MediaQuery.of(context).padding.bottom +
80, // Leave space for thought input
),
reverse: true,
itemCount:
localThoughts.value.length +
(isStreaming.value ? 1 : 0),
itemBuilder: (context, index) {
if (isStreaming.value && index == 0) {
return ThoughtItem(
isStreaming: true,
streamingText: streamingText.value,
reasoningChunks: reasoningChunks.value,
streamingFunctionCalls: functionCalls.value,
);
}
final thoughtIndex =
isStreaming.value ? index - 1 : index;
final thought = localThoughts.value[thoughtIndex];
return ThoughtItem(
thought: thought,
thoughtIndex: thoughtIndex,
);
},
),
loading:
() =>
const Center(child: CircularProgressIndicator()),
error:
(error, _) => ResponseErrorWidget(
error: error,
onRetry:
() =>
selectedSequenceId.value != null
? ref.invalidate(
thoughtSequenceProvider(
selectedSequenceId.value!,
),
)
: null,
),
),
),
],
),
),
),
// Bottom gradient - appears when scrolling towards newer thoughts (behind thought input)
AnimatedBuilder(
animation: bottomGradientNotifier.value,
builder:
(context, child) => Positioned(
left: 0,
right: 0,
bottom: 0,
child: Opacity(
opacity: bottomGradientNotifier.value.value,
child: Container(
height: math.min(
MediaQuery.of(context).size.height * 0.1,
128,
),
loading:
() => const Center(child: CircularProgressIndicator()),
error:
(error, _) => ResponseErrorWidget(
error: error,
onRetry:
() =>
selectedSequenceId.value != null
? ref.invalidate(
thoughtSequenceProvider(
selectedSequenceId.value!,
),
)
: null,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Theme.of(
context,
).colorScheme.surfaceContainer.withOpacity(0.8),
Theme.of(
context,
).colorScheme.surfaceContainer.withOpacity(0.0),
],
),
),
),
),
),
),
// Thought Input positioned above gradient (higher z-index)
Positioned(
left: 0,
right: 0,
bottom: 0, // At the very bottom, above gradient
child: Center(
child: Container(
constraints: BoxConstraints(maxWidth: 640),
child: ThoughtInput(
messageController: messageController,
isStreaming: isStreaming.value,
onSend: sendMessage,
),
),
ThoughtInput(
messageController: messageController,
isStreaming: isStreaming.value,
onSend: sendMessage,
),
],
),
),
),
],
),
);
}

View File

@@ -1,4 +1,5 @@
import "dart:convert";
import "dart:math" as math;
import "package:dio/dio.dart";
import "package:easy_localization/easy_localization.dart";
import "package:flutter/material.dart";
@@ -54,6 +55,9 @@ class ThoughtSheet extends HookConsumerWidget {
final listController = useMemoized(() => ListController(), []);
// Scroll animation notifiers
final bottomGradientNotifier = useState(ValueNotifier<double>(0.0));
// Scroll to bottom when thoughts change or streaming state changes
useEffect(() {
if (localThoughts.value.isNotEmpty || isStreaming.value) {
@@ -68,6 +72,20 @@ class ThoughtSheet extends HookConsumerWidget {
return null;
}, [localThoughts.value.length, isStreaming.value]);
// Add scroll listener for gradient animations
useEffect(() {
void onScroll() {
// Update gradient animations
final pixels = scrollController.position.pixels;
// Bottom gradient: appears when not at bottom (pixels > 0)
bottomGradientNotifier.value.value = (pixels / 500.0).clamp(0.0, 1.0);
}
scrollController.addListener(onScroll);
return () => scrollController.removeListener(onScroll);
}, [scrollController]);
void sendMessage() async {
if (messageController.text.trim().isEmpty) return;
@@ -196,47 +214,103 @@ class ThoughtSheet extends HookConsumerWidget {
return SheetScaffold(
titleText: currentTopic.value ?? 'aiThought'.tr(),
child: Center(
child: Container(
constraints: BoxConstraints(maxWidth: 640),
child: Column(
children: [
Expanded(
child: SuperListView.builder(
listController: listController,
controller: scrollController,
padding: const EdgeInsets.only(top: 16, bottom: 16),
reverse: true,
itemCount:
localThoughts.value.length + (isStreaming.value ? 1 : 0),
itemBuilder: (context, index) {
if (isStreaming.value && index == 0) {
return ThoughtItem(
isStreaming: true,
streamingText: streamingText.value,
reasoningChunks: reasoningChunks.value,
streamingFunctionCalls: functionCalls.value,
);
}
final thoughtIndex = isStreaming.value ? index - 1 : index;
final thought = localThoughts.value[thoughtIndex];
return ThoughtItem(
thought: thought,
thoughtIndex: thoughtIndex,
);
},
child: Stack(
children: [
// Thoughts list
Center(
child: Container(
constraints: BoxConstraints(maxWidth: 640),
child: Column(
children: [
Expanded(
child: SuperListView.builder(
listController: listController,
controller: scrollController,
padding: EdgeInsets.only(
top: 16,
bottom:
MediaQuery.of(context).padding.bottom +
80, // Leave space for thought input
),
reverse: true,
itemCount:
localThoughts.value.length +
(isStreaming.value ? 1 : 0),
itemBuilder: (context, index) {
if (isStreaming.value && index == 0) {
return ThoughtItem(
isStreaming: true,
streamingText: streamingText.value,
reasoningChunks: reasoningChunks.value,
streamingFunctionCalls: functionCalls.value,
);
}
final thoughtIndex =
isStreaming.value ? index - 1 : index;
final thought = localThoughts.value[thoughtIndex];
return ThoughtItem(
thought: thought,
thoughtIndex: thoughtIndex,
);
},
),
),
],
),
),
),
// Bottom gradient - appears when scrolling towards newer thoughts (behind thought input)
AnimatedBuilder(
animation: bottomGradientNotifier.value,
builder:
(context, child) => Positioned(
left: 0,
right: 0,
bottom: 0,
child: Opacity(
opacity: bottomGradientNotifier.value.value,
child: Container(
height: math.min(
MediaQuery.of(context).size.height * 0.1,
128,
),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Theme.of(
context,
).colorScheme.surfaceContainer.withOpacity(0.8),
Theme.of(
context,
).colorScheme.surfaceContainer.withOpacity(0.0),
],
),
),
),
),
),
),
// Thought Input positioned above gradient (higher z-index)
Positioned(
left: 0,
right: 0,
bottom: 0, // At the very bottom, above gradient
child: Center(
child: Container(
constraints: BoxConstraints(maxWidth: 640),
child: ThoughtInput(
messageController: messageController,
isStreaming: isStreaming.value,
onSend: sendMessage,
attachedMessages: attachedMessages,
attachedPosts: attachedPosts,
),
),
ThoughtInput(
messageController: messageController,
isStreaming: isStreaming.value,
onSend: sendMessage,
attachedMessages: attachedMessages,
attachedPosts: attachedPosts,
),
],
),
),
),
],
),
);
}

View File

@@ -106,20 +106,6 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
child: NotificationCard(notification: notification),
),
),
onTap: () {
if (notification.meta['action_uri'] != null) {
var uri = notification.meta['action_uri'] as String;
if (uri.startsWith('/')) {
// In-app routes
rootNavigatorKey.currentContext?.push(
notification.meta['action_uri'],
);
} else {
// External URLs
launchUrlString(uri);
}
}
},
onDismissed: () {},
dismissType: DismissType.onSwipe,
displayDuration: const Duration(seconds: 5),

View File

@@ -11,8 +11,9 @@ import 'package:island/screens/account/profile.dart';
import 'package:island/services/time.dart';
import 'package:island/services/timezone/native.dart';
import 'package:island/widgets/account/account_name.dart';
import 'package:island/widgets/account/activity_presence.dart';
import 'package:island/widgets/account/badge.dart';
import 'package:island/widgets/account/leveling_progress.dart';
import 'package:island/widgets/account/status.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/response.dart';
@@ -54,7 +55,30 @@ class AccountProfileCard extends HookConsumerWidget {
children: [
Row(
children: [
ProfilePictureWidget(file: data.profile.picture),
GestureDetector(
child: Badge(
isLabelVisible: true,
padding: EdgeInsets.all(2),
label: Icon(
Symbols.launch,
size: 12,
color: Theme.of(context).colorScheme.onPrimary,
),
backgroundColor:
Theme.of(context).colorScheme.primary,
offset: Offset(4, 28),
child: ProfilePictureWidget(
file: data.profile.picture,
),
),
onTap: () {
Navigator.pop(context);
context.pushNamed(
'accountProfile',
pathParameters: {'name': data.name},
);
},
),
const Gap(12),
Expanded(
child: Column(
@@ -81,7 +105,7 @@ class AccountProfileCard extends HookConsumerWidget {
spacing: 6,
children: [
Icon(
Symbols.star,
Symbols.attribution,
size: 17,
fill: 1,
).padding(right: 2),
@@ -144,25 +168,40 @@ class AccountProfileCard extends HookConsumerWidget {
).padding(top: 2);
}
}(),
Row(
spacing: 6,
children: [
Icon(
Symbols.stairs,
size: 17,
fill: 1,
).padding(right: 2),
Text(
'levelingProgressLevel'.tr(
args: [data.profile.level.toString()],
),
).fontSize(12),
Expanded(
child: Tooltip(
message:
'${(data.profile.levelingProgress * 100).toStringAsFixed(2)}%',
child: LinearProgressIndicator(
value: data.profile.levelingProgress,
stopIndicatorRadius: 0,
trackGap: 0,
minHeight: 4,
).padding(top: 1),
),
),
],
).padding(top: 2),
if (data.badges.isNotEmpty)
BadgeList(badges: data.badges).padding(top: 12),
LevelingProgressCard(
ActivityPresenceWidget(
uname: uname,
isCompact: true,
level: data.profile.level,
experience: data.profile.experience,
progress: data.profile.levelingProgress,
).padding(top: 12),
FilledButton.tonalIcon(
onPressed: () {
Navigator.pop(context);
context.pushNamed(
'accountProfile',
pathParameters: {'name': data.name},
);
},
icon: const Icon(Symbols.launch),
label: Text('accountProfileView').tr(),
).padding(top: 12, horizontal: 2),
compactPadding: const EdgeInsets.only(top: 12),
),
],
).padding(horizontal: 24, vertical: 16),
],

View File

@@ -1,59 +1,635 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/activity.dart';
import 'package:island/pods/activity/activity_rpc.dart';
import 'package:island/widgets/content/image.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
class ActivityPresenceWidget extends ConsumerWidget {
final String uname;
part 'activity_presence.g.dart';
const ActivityPresenceWidget({super.key, required this.uname});
@riverpod
Future<Map<String, String>?> discordAssets(
Ref ref,
SnPresenceActivity activity,
) async {
final hasDiscordSmall =
activity.smallImage != null &&
activity.smallImage!.startsWith('discord:');
final hasDiscordLarge =
activity.largeImage != null &&
activity.largeImage!.startsWith('discord:');
@override
Widget build(BuildContext context, WidgetRef ref) {
final activitiesAsync = ref.watch(presenceActivitiesProvider(uname));
return activitiesAsync.when(
data: (activities) => _buildActivitiesList(activities),
loading: () => const Center(child: CircularProgressIndicator()),
error:
(error, stack) =>
Center(child: Text('Error loading activities: $error')),
if (hasDiscordSmall || hasDiscordLarge) {
final dio = Dio();
final response = await dio.get(
'https://discordapp.com/api/oauth2/applications/${activity.manualId}/assets',
);
final data = response.data as List<dynamic>;
return {
for (final item in data) item['name'] as String: item['id'] as String,
};
}
Widget _buildActivitiesList(List<SnPresenceActivity> activities) {
if (activities.isEmpty) {
return const Center(child: Text('No active activities'));
}
return null;
}
return ListView.builder(
itemCount: activities.length,
itemBuilder: (context, index) {
final activity = activities[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: ListTile(
title: Text(activity.title ?? 'Untitled Activity'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Type: ${activity.type}'),
if (activity.subtitle != null) Text(activity.subtitle!),
if (activity.caption != null) Text(activity.caption!),
Text(
'Expires: ${activity.leaseExpiresAt.toLocal().toString()}',
style: const TextStyle(fontSize: 12),
@riverpod
Future<String?> discordAssetsUrl(
Ref ref,
SnPresenceActivity activity,
String key,
) async {
final assets = await ref.watch(discordAssetsProvider(activity).future);
if (assets != null && assets.containsKey(key)) {
final assetId = assets[key]!;
return 'https://cdn.discordapp.com/app-assets/${activity.manualId}/$assetId.png';
}
return null;
}
const kPresenceActivityTypes = [
'unknown',
'presenceTypeGaming',
'presenceTypeMusic',
'presenceTypeWorkout',
];
const kPresenceActivityIcons = <IconData>[
Symbols.question_mark_rounded,
Symbols.play_arrow_rounded,
Symbols.music_note_rounded,
Symbols.running_with_errors,
];
class ActivityPresenceWidget extends StatefulWidget {
final String uname;
final bool isCompact;
final EdgeInsets compactPadding;
const ActivityPresenceWidget({
super.key,
required this.uname,
this.isCompact = false,
this.compactPadding = EdgeInsets.zero,
});
@override
State<ActivityPresenceWidget> createState() => _ActivityPresenceWidgetState();
}
class _ActivityPresenceWidgetState extends State<ActivityPresenceWidget>
with TickerProviderStateMixin {
late AnimationController _progressController;
late Animation<double> _progressAnimation;
double _startProgress = 0.0;
double _endProgress = 0.0;
@override
void initState() {
super.initState();
_progressController = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
);
_progressAnimation = Tween<double>(
begin: 0.0,
end: 0.0,
).animate(_progressController);
}
@override
void dispose() {
_progressController.dispose();
super.dispose();
}
List<Widget> _buildImages(WidgetRef ref, SnPresenceActivity activity) {
final List<Widget> images = [];
if (activity.largeImage != null) {
if (activity.largeImage!.startsWith('discord:')) {
final key = activity.largeImage!.substring('discord:'.length);
final urlAsync = ref.watch(discordAssetsUrlProvider(activity, key));
images.add(
urlAsync.when(
data:
(url) =>
url != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: url,
width: 64,
height: 64,
),
)
: const SizedBox.shrink(),
loading:
() => const SizedBox(
width: 64,
height: 64,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
// TODO: Implement delete functionality
},
error: (error, stack) => const SizedBox.shrink(),
),
);
} else {
images.add(
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: UniversalImage(
uri: activity.largeImage!,
width: 64,
height: 64,
),
),
);
}
}
if (activity.smallImage != null) {
if (activity.smallImage!.startsWith('discord:')) {
final key = activity.smallImage!.substring('discord:'.length);
final urlAsync = ref.watch(discordAssetsUrlProvider(activity, key));
images.add(
urlAsync.when(
data:
(url) =>
url != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: url,
width: 32,
height: 32,
),
)
: const SizedBox.shrink(),
loading:
() => const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
error: (error, stack) => const SizedBox.shrink(),
),
);
} else {
images.add(
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: UniversalImage(
uri: activity.smallImage!,
width: 32,
height: 32,
),
),
);
}
}
return images;
}
@override
Widget build(BuildContext context) {
return Consumer(
builder: (BuildContext context, WidgetRef ref, Widget? child) {
final activitiesAsync = ref.watch(
presenceActivitiesProvider(widget.uname),
);
if (widget.isCompact) {
return activitiesAsync.when(
data: (activities) {
if (activities.isEmpty) return const SizedBox.shrink();
final activity = activities.first;
return Padding(
padding: widget.compactPadding,
child: Row(
spacing: 8,
children: [
if (activity.largeImage != null)
activity.largeImage!.startsWith('discord:')
? ref
.watch(
discordAssetsUrlProvider(
activity,
activity.largeImage!.substring(
'discord:'.length,
),
),
)
.when(
data:
(url) =>
url != null
? ClipRRect(
borderRadius:
BorderRadius.circular(4),
child: CachedNetworkImage(
imageUrl: url,
width: 32,
height: 32,
),
)
: const SizedBox.shrink(),
loading:
() => const SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(
strokeWidth: 1,
),
),
error:
(error, stack) => const SizedBox.shrink(),
)
: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: UniversalImage(
uri: activity.largeImage!,
width: 32,
height: 32,
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
(activity.title?.isEmpty ?? true)
? 'unknown'.tr()
: activity.title!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
).fontSize(13),
Row(
children: [
Text(
kPresenceActivityTypes[activity.type],
).tr().fontSize(11),
Icon(
kPresenceActivityIcons[activity.type],
size: 15,
fill: 1,
),
],
),
],
),
),
StreamBuilder(
stream: Stream.periodic(const Duration(seconds: 1)),
builder: (context, snapshot) {
final now = DateTime.now();
if (activity.manualId == 'spotify' &&
activity.meta != null) {
final meta = activity.meta as Map<String, dynamic>;
final progressMs = meta['progress_ms'] as int? ?? 0;
final durationMs =
meta['track_duration_ms'] as int? ?? 1;
final elapsed =
now.difference(activity.createdAt).inMilliseconds;
final currentProgressMs =
(progressMs + elapsed) % durationMs;
final progressValue = currentProgressMs / durationMs;
if (progressValue != _endProgress) {
_startProgress = _endProgress;
_endProgress = progressValue;
_progressAnimation = Tween<double>(
begin: _startProgress,
end: _endProgress,
).animate(_progressController);
_progressController.forward(from: 0.0);
}
return AnimatedBuilder(
animation: _progressAnimation,
builder: (context, child) {
final animatedValue = _progressAnimation.value;
final animatedProgressMs =
(animatedValue * durationMs).toInt();
final currentMin = animatedProgressMs ~/ 60000;
final currentSec =
(animatedProgressMs % 60000) ~/ 1000;
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
spacing: 2,
children: [
Text(
'${currentMin.toString().padLeft(2, '0')}:${currentSec.toString().padLeft(2, '0')}',
style: TextStyle(
fontSize: 10,
color: Colors.green,
),
),
SizedBox(
width: 120,
child: LinearProgressIndicator(
value: animatedValue,
backgroundColor: Colors.grey.shade300,
stopIndicatorColor: Colors.green,
trackGap: 0,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.green,
),
),
).padding(top: 2),
],
);
},
);
} else {
final duration = now.difference(activity.createdAt);
final hours = duration.inHours.toString().padLeft(
2,
'0',
);
final minutes = (duration.inMinutes % 60)
.toString()
.padLeft(2, '0');
final seconds = (duration.inSeconds % 60)
.toString()
.padLeft(2, '0');
return Text(
'$hours:$minutes:$seconds',
).textColor(Colors.green).fontSize(12);
}
},
),
],
),
);
},
loading: () => const SizedBox.shrink(),
error: (error, stack) => const SizedBox.shrink(),
);
}
return activitiesAsync.when(
data:
(activities) => Card(
margin: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
Text(
'activities',
).tr().bold().padding(horizontal: 16, vertical: 4),
if (activities.isEmpty)
Row(
spacing: 4,
children: [
const Icon(Symbols.inbox, size: 16),
Text('dataEmpty').tr().fontSize(13),
],
).opacity(0.75).padding(horizontal: 16, bottom: 8),
...activities.map((activity) {
final images = _buildImages(ref, activity);
return Stack(
children: [
Card(
elevation: 0,
shape: RoundedRectangleBorder(
side: BorderSide(
color: Colors.grey.shade300,
width: 1,
),
borderRadius: BorderRadius.circular(8),
),
margin: EdgeInsets.zero,
child: ListTile(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (images.isNotEmpty)
Row(
crossAxisAlignment:
CrossAxisAlignment.end,
spacing: 8,
children: images,
).padding(vertical: 4),
Row(
spacing: 2,
children: [
Flexible(
child: Text(
(activity.title?.isEmpty ?? true)
? 'unknown'.tr()
: activity.title!,
),
),
if (activity.titleUrl != null &&
activity.titleUrl!.isNotEmpty)
IconButton(
onPressed: () {
launchUrlString(activity.titleUrl!);
},
icon: const Icon(
Symbols.launch_rounded,
),
iconSize: 16,
padding: EdgeInsets.all(4),
constraints: const BoxConstraints(
maxWidth: 28,
maxHeight: 28,
),
),
],
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
spacing: 4,
children: [
Text(
kPresenceActivityTypes[activity.type],
).tr(),
Icon(
kPresenceActivityIcons[activity.type],
size: 16,
fill: 1,
),
],
),
if (activity.manualId == 'spotify' &&
activity.meta != null)
StreamBuilder(
stream: Stream.periodic(
const Duration(seconds: 1),
),
builder: (context, snapshot) {
final now = DateTime.now();
final meta =
activity.meta
as Map<String, dynamic>;
final progressMs =
meta['progress_ms'] as int? ?? 0;
final durationMs =
meta['track_duration_ms'] as int? ??
1;
final elapsed =
now
.difference(activity.createdAt)
.inMilliseconds;
final currentProgressMs =
(progressMs + elapsed) % durationMs;
final progressValue =
currentProgressMs / durationMs;
if (progressValue != _endProgress) {
_startProgress = _endProgress;
_endProgress = progressValue;
_progressAnimation = Tween<double>(
begin: _startProgress,
end: _endProgress,
).animate(_progressController);
_progressController.forward(
from: 0.0,
);
}
return AnimatedBuilder(
animation: _progressAnimation,
builder: (context, child) {
final animatedValue =
_progressAnimation.value;
final animatedProgressMs =
(animatedValue * durationMs)
.toInt();
final currentMin =
animatedProgressMs ~/ 60000;
final currentSec =
(animatedProgressMs % 60000) ~/
1000;
final totalMin =
durationMs ~/ 60000;
final totalSec =
(durationMs % 60000) ~/ 1000;
return Column(
crossAxisAlignment:
CrossAxisAlignment.start,
spacing: 4,
children: [
LinearProgressIndicator(
value: animatedValue,
backgroundColor:
Colors.grey.shade300,
trackGap: 0,
stopIndicatorColor:
Colors.green,
valueColor:
AlwaysStoppedAnimation<
Color
>(Colors.green),
).padding(top: 3),
Text(
'${currentMin.toString().padLeft(2, '0')}:${currentSec.toString().padLeft(2, '0')} / ${totalMin.toString().padLeft(2, '0')}:${totalSec.toString().padLeft(2, '0')}',
style: TextStyle(
fontSize: 12,
color: Colors.green,
),
),
],
);
},
);
},
)
else
StreamBuilder(
stream: Stream.periodic(
const Duration(seconds: 1),
),
builder: (context, snapshot) {
final now = DateTime.now();
final duration = now.difference(
activity.createdAt,
);
final hours = duration.inHours
.toString()
.padLeft(2, '0');
final minutes = (duration.inMinutes %
60)
.toString()
.padLeft(2, '0');
final seconds = (duration.inSeconds %
60)
.toString()
.padLeft(2, '0');
return Text(
'$hours:$minutes:$seconds',
).textColor(Colors.green);
},
),
if (activity.subtitle?.isNotEmpty ?? false)
Row(
spacing: 2,
children: [
Flexible(
child: Text(activity.subtitle!),
),
if (activity.titleUrl != null &&
activity.titleUrl!.isNotEmpty)
IconButton(
onPressed: () {
launchUrlString(
activity.titleUrl!,
);
},
icon: const Icon(
Symbols.launch_rounded,
),
iconSize: 16,
padding: EdgeInsets.all(4),
constraints: const BoxConstraints(
maxWidth: 28,
maxHeight: 28,
),
),
],
),
if (activity.caption?.isNotEmpty ?? false)
Text(activity.caption!),
],
),
),
).padding(horizontal: 8),
if (activity.manualId == 'spotify')
Positioned(
top: 16,
right: 24,
child: Tooltip(
message: 'Listening on Spotify',
child: Image.asset(
'assets/images/oidc/spotify.png',
width: 24,
height: 24,
),
),
),
],
);
}),
],
).padding(horizontal: 8, top: 8, bottom: 16),
),
loading: () => const Center(child: CircularProgressIndicator()),
error:
(error, stack) =>
Center(child: Text('Error loading activities: $error')),
);
},
);
}

View File

@@ -0,0 +1,287 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'activity_presence.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$discordAssetsHash() => r'3ef8465188059de96cf2ac9660ed3d88910443bf';
/// 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 [discordAssets].
@ProviderFor(discordAssets)
const discordAssetsProvider = DiscordAssetsFamily();
/// See also [discordAssets].
class DiscordAssetsFamily extends Family<AsyncValue<Map<String, String>?>> {
/// See also [discordAssets].
const DiscordAssetsFamily();
/// See also [discordAssets].
DiscordAssetsProvider call(SnPresenceActivity activity) {
return DiscordAssetsProvider(activity);
}
@override
DiscordAssetsProvider getProviderOverride(
covariant DiscordAssetsProvider provider,
) {
return call(provider.activity);
}
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'discordAssetsProvider';
}
/// See also [discordAssets].
class DiscordAssetsProvider
extends AutoDisposeFutureProvider<Map<String, String>?> {
/// See also [discordAssets].
DiscordAssetsProvider(SnPresenceActivity activity)
: this._internal(
(ref) => discordAssets(ref as DiscordAssetsRef, activity),
from: discordAssetsProvider,
name: r'discordAssetsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$discordAssetsHash,
dependencies: DiscordAssetsFamily._dependencies,
allTransitiveDependencies:
DiscordAssetsFamily._allTransitiveDependencies,
activity: activity,
);
DiscordAssetsProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.activity,
}) : super.internal();
final SnPresenceActivity activity;
@override
Override overrideWith(
FutureOr<Map<String, String>?> Function(DiscordAssetsRef provider) create,
) {
return ProviderOverride(
origin: this,
override: DiscordAssetsProvider._internal(
(ref) => create(ref as DiscordAssetsRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
activity: activity,
),
);
}
@override
AutoDisposeFutureProviderElement<Map<String, String>?> createElement() {
return _DiscordAssetsProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is DiscordAssetsProvider && other.activity == activity;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, activity.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin DiscordAssetsRef on AutoDisposeFutureProviderRef<Map<String, String>?> {
/// The parameter `activity` of this provider.
SnPresenceActivity get activity;
}
class _DiscordAssetsProviderElement
extends AutoDisposeFutureProviderElement<Map<String, String>?>
with DiscordAssetsRef {
_DiscordAssetsProviderElement(super.provider);
@override
SnPresenceActivity get activity => (origin as DiscordAssetsProvider).activity;
}
String _$discordAssetsUrlHash() => r'a32f9333c3fb4d50ff88a54a6b8b72fbf5ba3ea1';
/// See also [discordAssetsUrl].
@ProviderFor(discordAssetsUrl)
const discordAssetsUrlProvider = DiscordAssetsUrlFamily();
/// See also [discordAssetsUrl].
class DiscordAssetsUrlFamily extends Family<AsyncValue<String?>> {
/// See also [discordAssetsUrl].
const DiscordAssetsUrlFamily();
/// See also [discordAssetsUrl].
DiscordAssetsUrlProvider call(SnPresenceActivity activity, String key) {
return DiscordAssetsUrlProvider(activity, key);
}
@override
DiscordAssetsUrlProvider getProviderOverride(
covariant DiscordAssetsUrlProvider provider,
) {
return call(provider.activity, provider.key);
}
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'discordAssetsUrlProvider';
}
/// See also [discordAssetsUrl].
class DiscordAssetsUrlProvider extends AutoDisposeFutureProvider<String?> {
/// See also [discordAssetsUrl].
DiscordAssetsUrlProvider(SnPresenceActivity activity, String key)
: this._internal(
(ref) => discordAssetsUrl(ref as DiscordAssetsUrlRef, activity, key),
from: discordAssetsUrlProvider,
name: r'discordAssetsUrlProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$discordAssetsUrlHash,
dependencies: DiscordAssetsUrlFamily._dependencies,
allTransitiveDependencies:
DiscordAssetsUrlFamily._allTransitiveDependencies,
activity: activity,
key: key,
);
DiscordAssetsUrlProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.activity,
required this.key,
}) : super.internal();
final SnPresenceActivity activity;
final String key;
@override
Override overrideWith(
FutureOr<String?> Function(DiscordAssetsUrlRef provider) create,
) {
return ProviderOverride(
origin: this,
override: DiscordAssetsUrlProvider._internal(
(ref) => create(ref as DiscordAssetsUrlRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
activity: activity,
key: key,
),
);
}
@override
AutoDisposeFutureProviderElement<String?> createElement() {
return _DiscordAssetsUrlProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is DiscordAssetsUrlProvider &&
other.activity == activity &&
other.key == key;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, activity.hashCode);
hash = _SystemHash.combine(hash, key.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin DiscordAssetsUrlRef on AutoDisposeFutureProviderRef<String?> {
/// The parameter `activity` of this provider.
SnPresenceActivity get activity;
/// The parameter `key` of this provider.
String get key;
}
class _DiscordAssetsUrlProviderElement
extends AutoDisposeFutureProviderElement<String?>
with DiscordAssetsUrlRef {
_DiscordAssetsUrlProviderElement(super.provider);
@override
SnPresenceActivity get activity =>
(origin as DiscordAssetsUrlProvider).activity;
@override
String get key => (origin as DiscordAssetsUrlProvider).key;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -71,6 +71,9 @@ class AccountStatusCreationWidget extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final userStatus = ref.watch(accountStatusProvider(uname));
final renderPadding =
padding ?? EdgeInsets.symmetric(horizontal: 16, vertical: 8);
return InkWell(
borderRadius: BorderRadius.circular(8),
child: userStatus.when(
@@ -79,26 +82,35 @@ class AccountStatusCreationWidget extends HookConsumerWidget {
(status?.isCustomized ?? false)
? Padding(
padding: const EdgeInsets.only(left: 4),
child: AccountStatusWidget(uname: uname),
child: AccountStatusWidget(
uname: uname,
padding: renderPadding,
),
)
: Padding(
padding:
padding ??
EdgeInsets.symmetric(horizontal: 27, vertical: 4),
child: Row(
spacing: 4,
padding: renderPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Symbols.keyboard_arrow_up),
Expanded(
child: Text('statusCreateHint', maxLines: 1).tr(),
Row(
children: [
Icon(Symbols.keyboard_arrow_up),
SizedBox(width: 4),
Text('Create Status').tr(),
],
),
SizedBox(height: 4),
Text(
'Tap to set your current activity and let others know what you\'re up to',
style: TextStyle(fontSize: 12),
).tr().opacity(0.75),
],
),
).opacity(0.85),
error:
(error, _) => Padding(
padding:
padding ?? EdgeInsets.symmetric(horizontal: 26, vertical: 4),
padding ?? EdgeInsets.symmetric(horizontal: 26, vertical: 12),
child: Row(
spacing: 4,
children: [Icon(Symbols.close), Text('Error: $error')],
@@ -107,7 +119,7 @@ class AccountStatusCreationWidget extends HookConsumerWidget {
loading:
() => Padding(
padding:
padding ?? EdgeInsets.symmetric(horizontal: 26, vertical: 4),
padding ?? EdgeInsets.symmetric(horizontal: 26, vertical: 12),
child: Row(
spacing: 4,
children: [Icon(Symbols.more_vert), Text('loading').tr()],

View File

@@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/account.dart';
import 'package:island/route.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
class NotificationCard extends HookConsumerWidget {
final SnNotification notification;
@@ -14,58 +17,78 @@ class NotificationCard extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final icon = Symbols.info;
return Card(
elevation: 4,
margin: const EdgeInsets.only(bottom: 8),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (notification.meta['pfp'] != null)
ProfilePictureWidget(
fileId: notification.meta['pfp'],
radius: 12,
).padding(right: 12, top: 2)
else
Icon(
icon,
color: Theme.of(context).colorScheme.primary,
size: 24,
).padding(right: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
notification.title,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
if (notification.content.isNotEmpty)
return GestureDetector(
onTap: () {
if (notification.meta['action_uri'] != null) {
var uri = notification.meta['action_uri'] as String;
if (uri.startsWith('solian://')) {
uri = uri.replaceFirst('solian://', '');
}
if (uri.startsWith('/')) {
// In-app routes
rootNavigatorKey.currentContext?.push(
notification.meta['action_uri'],
);
} else {
// External URLs
launchUrlString(uri);
}
}
},
child: Card(
elevation: 4,
margin: const EdgeInsets.only(bottom: 8),
color: Theme.of(context).colorScheme.surfaceContainer,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (notification.meta['pfp'] != null)
ProfilePictureWidget(
fileId: notification.meta['pfp'],
radius: 12,
).padding(right: 12, top: 2)
else
Icon(
icon,
color: Theme.of(context).colorScheme.primary,
size: 24,
).padding(right: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
notification.content,
style: Theme.of(context).textTheme.bodyMedium,
notification.title,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
if (notification.subtitle.isNotEmpty)
Text(
notification.subtitle,
style: Theme.of(context).textTheme.bodySmall,
),
],
if (notification.content.isNotEmpty)
Text(
notification.content,
style: Theme.of(context).textTheme.bodyMedium,
),
if (notification.subtitle.isNotEmpty)
Text(
notification.subtitle,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
],
],
),
),
),
],
],
),
),
);
}

View File

@@ -296,7 +296,7 @@ class CheckInWidget extends HookConsumerWidget {
}
class CheckInActivityWidget extends StatelessWidget {
final SnActivity item;
final SnTimelineEvent item;
const CheckInActivityWidget({super.key, required this.item});
@override

View File

@@ -1,27 +1,18 @@
import 'dart:io';
import 'dart:math' as math;
import 'dart:ui';
import 'package:dismissible_page/dismissible_page.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_blurhash/flutter_blurhash.dart';
import 'package:file_saver/file_saver.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:gal/gal.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/file_info_sheet.dart';
import 'package:island/widgets/content/cloud_file_lightbox.dart';
import 'package:island/widgets/content/sensitive.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:path/path.dart' show extension;
import 'package:path_provider/path_provider.dart';
import 'package:photo_view/photo_view.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:uuid/uuid.dart';
@@ -47,13 +38,100 @@ class CloudFileList extends HookConsumerWidget {
});
double calculateAspectRatio() {
double total = 0;
for (var ratio in files.map((e) => e.fileMeta?['ratio'] ?? 1)) {
if (ratio is double) total += ratio;
if (ratio is String) total += double.parse(ratio);
final ratios = <double>[];
// Collect all valid ratios
for (final file in files) {
final meta = file.fileMeta;
if (meta is Map<String, dynamic> && meta.containsKey('ratio')) {
final ratioValue = meta['ratio'];
if (ratioValue is num && ratioValue > 0) {
ratios.add(ratioValue.toDouble());
} else if (ratioValue is String) {
try {
final parsed = double.parse(ratioValue);
if (parsed > 0) ratios.add(parsed);
} catch (_) {
// Skip invalid string ratios
}
}
}
}
if (total == 0) return 1;
return total / files.length;
if (ratios.isEmpty) {
// Default to 4:3 aspect ratio when no valid ratios found
return 4 / 3;
}
if (ratios.length == 1) {
return ratios.first;
}
// Group similar ratios and find the most common one
final commonRatios = <double, int>{};
// Common aspect ratios to round to (with tolerance)
const tolerance = 0.05;
final standardRatios = [
1.0,
4 / 3,
3 / 2,
16 / 9,
5 / 3,
5 / 4,
7 / 5,
9 / 16,
2 / 3,
3 / 4,
4 / 5,
];
for (final ratio in ratios) {
// Find the closest standard ratio within tolerance
double closestRatio = ratio;
double minDiff = double.infinity;
for (final standard in standardRatios) {
final diff = (ratio - standard).abs();
if (diff < minDiff && diff <= tolerance) {
minDiff = diff;
closestRatio = standard;
}
}
// If no standard ratio is close enough, keep original
if (minDiff == double.infinity || minDiff > tolerance) {
closestRatio = ratio;
}
commonRatios[closestRatio] = (commonRatios[closestRatio] ?? 0) + 1;
}
// Find the most frequent ratio(s)
int maxCount = 0;
final mostFrequent = <double>[];
for (final entry in commonRatios.entries) {
if (entry.value > maxCount) {
maxCount = entry.value;
mostFrequent.clear();
mostFrequent.add(entry.key);
} else if (entry.value == maxCount) {
mostFrequent.add(entry.key);
}
}
// If only one most frequent ratio, return it
if (mostFrequent.length == 1) {
return mostFrequent.first;
}
// If multiple ratios have the same highest frequency, use median of them
mostFrequent.sort();
final mid = mostFrequent.length ~/ 2;
return mostFrequent.length.isEven
? (mostFrequent[mid - 1] + mostFrequent[mid]) / 2
: mostFrequent[mid];
}
@override
@@ -90,7 +168,7 @@ class CloudFileList extends HookConsumerWidget {
}
if (!disableZoomIn) {
context.pushTransparentRoute(
CloudFileZoomIn(item: file, heroTag: heroTags[i]),
CloudFileLightbox(item: file, heroTag: heroTags[i]),
rootNavigator: true,
);
}
@@ -151,7 +229,7 @@ class CloudFileList extends HookConsumerWidget {
}
if (!disableZoomIn) {
context.pushTransparentRoute(
CloudFileZoomIn(item: files.first, heroTag: heroTags.first),
CloudFileLightbox(item: files.first, heroTag: heroTags.first),
rootNavigator: true,
);
}
@@ -169,10 +247,7 @@ class CloudFileList extends HookConsumerWidget {
child:
isAudio
? widgetItem
: AspectRatio(
aspectRatio: calculateAspectRatio(),
child: widgetItem,
),
: IntrinsicWidth(child: IntrinsicHeight(child: widgetItem)),
);
}
@@ -188,53 +263,60 @@ class CloudFileList extends HookConsumerWidget {
aspectRatio: calculateAspectRatio(),
child: Padding(
padding: padding ?? EdgeInsets.zero,
child: CarouselView(
itemSnapping: true,
itemExtent: math.min(
math.min(
MediaQuery.of(context).size.width * 0.75,
maxWidth * 0.75,
),
640,
),
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(16)),
),
children: [
for (var i = 0; i < files.length; i++)
Stack(
children: [
_CloudFileListEntry(
file: files[i],
heroTag: heroTags[i],
isImage:
files[i].mimeType?.startsWith('image') ?? false,
disableZoomIn: disableZoomIn,
),
Positioned(
bottom: 12,
left: 16,
child: Text('${i + 1}/${files.length}')
.textColor(Colors.white)
.textShadow(
color: Colors.black54,
offset: Offset(1, 1),
blurRadius: 3,
),
),
],
child: LayoutBuilder(
builder: (context, constraints) {
final availableWidth =
constraints.maxWidth.isFinite
? constraints.maxWidth
: MediaQuery.of(context).size.width;
final itemExtent = math.min(
math.min(availableWidth * 0.75, maxWidth * 0.75).toDouble(),
640.0,
);
return CarouselView(
itemSnapping: true,
itemExtent: itemExtent,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(16)),
),
],
onTap: (i) {
if (!(files[i].mimeType?.startsWith('image') ?? false)) {
return;
}
if (!disableZoomIn) {
context.pushTransparentRoute(
CloudFileZoomIn(item: files[i], heroTag: heroTags[i]),
rootNavigator: true,
);
}
children: [
for (var i = 0; i < files.length; i++)
Stack(
children: [
_CloudFileListEntry(
file: files[i],
heroTag: heroTags[i],
isImage:
files[i].mimeType?.startsWith('image') ?? false,
disableZoomIn: disableZoomIn,
),
Positioned(
bottom: 12,
left: 16,
child: Text('${i + 1}/${files.length}')
.textColor(Colors.white)
.textShadow(
color: Colors.black54,
offset: Offset(1, 1),
blurRadius: 3,
),
),
],
),
],
onTap: (i) {
if (!(files[i].mimeType?.startsWith('image') ?? false)) {
return;
}
if (!disableZoomIn) {
context.pushTransparentRoute(
CloudFileLightbox(item: files[i], heroTag: heroTags[i]),
rootNavigator: true,
);
}
},
);
},
),
),
@@ -273,7 +355,7 @@ class CloudFileList extends HookConsumerWidget {
}
if (!disableZoomIn) {
context.pushTransparentRoute(
CloudFileZoomIn(
CloudFileLightbox(
item: files[index],
heroTag: heroTags[index],
),
@@ -305,211 +387,6 @@ class CloudFileList extends HookConsumerWidget {
}
}
class CloudFileZoomIn extends HookConsumerWidget {
final SnCloudFile item;
final String heroTag;
const CloudFileZoomIn({super.key, required this.item, required this.heroTag});
@override
Widget build(BuildContext context, WidgetRef ref) {
final serverUrl = ref.watch(serverUrlProvider);
final photoViewController = useMemoized(() => PhotoViewController(), []);
final rotation = useState(0);
final showOriginal = useState(false);
Future<void> saveToGallery() async {
try {
// Show loading indicator
showSnackBar('Saving image...');
// Get the image URL
final client = ref.watch(apiClientProvider);
// Create a temporary file to save the image
final tempDir = await getTemporaryDirectory();
var extName = extension(item.name).trim();
if (extName.isEmpty) {
extName = item.mimeType?.split('/').lastOrNull ?? 'jpeg';
}
final filePath = '${tempDir.path}/${item.id}.$extName';
await client.download(
'/drive/files/${item.id}',
filePath,
queryParameters: {'original': true},
);
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
// Save to gallery
await Gal.putImage(filePath, album: 'Solar Network');
// Show success message
showSnackBar('Image saved to gallery');
} else {
await FileSaver.instance.saveFile(
name: item.name.isEmpty ? '${item.id}.$extName' : item.name,
file: File(filePath),
);
showSnackBar('Image saved to $filePath');
}
} catch (e) {
showErrorAlert(e);
}
}
void showInfoSheet() {
showModalBottomSheet(
useRootNavigator: true,
context: context,
isScrollControlled: true,
builder: (context) => FileInfoSheet(item: item),
);
}
final shadow = [
Shadow(color: Colors.black54, blurRadius: 5.0, offset: Offset(1.0, 1.0)),
];
return DismissiblePage(
isFullScreen: true,
backgroundColor: Colors.transparent,
direction: DismissiblePageDismissDirection.down,
onDismissed: () {
Navigator.of(context).pop();
},
child: Stack(
children: [
Positioned.fill(
child: PhotoView(
backgroundDecoration: BoxDecoration(
color: Colors.black.withOpacity(0.9),
),
controller: photoViewController,
heroAttributes: PhotoViewHeroAttributes(tag: heroTag),
imageProvider: CloudImageWidget.provider(
fileId: item.id,
serverUrl: serverUrl,
original: showOriginal.value,
),
// Apply rotation transformation
customSize: MediaQuery.of(context).size,
basePosition: Alignment.center,
filterQuality: FilterQuality.high,
),
),
// Close button and save button
Positioned(
top: MediaQuery.of(context).padding.top + 16,
right: 16,
left: 16,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
if (!kIsWeb)
IconButton(
icon: Icon(
Icons.save_alt,
color: Colors.white,
shadows: shadow,
),
onPressed: () async {
saveToGallery();
},
),
IconButton(
onPressed: () {
showOriginal.value = !showOriginal.value;
},
icon: Icon(
showOriginal.value ? Symbols.hd : Symbols.sd,
color: Colors.white,
shadows: shadow,
),
),
],
),
IconButton(
icon: Icon(Icons.close, color: Colors.white, shadows: shadow),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
// Rotation controls
Positioned(
bottom: MediaQuery.of(context).padding.bottom + 16,
left: 16,
right: 16,
child: Row(
children: [
IconButton(
icon: Icon(
Icons.info_outline,
color: Colors.white,
shadows: shadow,
),
onPressed: showInfoSheet,
),
Spacer(),
IconButton(
icon: Icon(
Icons.remove,
color: Colors.white,
shadows: shadow,
),
onPressed: () {
photoViewController.scale =
(photoViewController.scale ?? 1) - 0.05;
},
),
IconButton(
icon: Icon(Icons.add, color: Colors.white, shadows: shadow),
onPressed: () {
photoViewController.scale =
(photoViewController.scale ?? 1) + 0.05;
},
),
const Gap(8),
IconButton(
icon: Icon(
Icons.rotate_left,
color: Colors.white,
shadows: shadow,
),
onPressed: () {
rotation.value = (rotation.value - 1) % 4;
photoViewController.rotation =
rotation.value * -math.pi / 2;
},
),
IconButton(
icon: Icon(
Icons.rotate_right,
color: Colors.white,
shadows: [
Shadow(
color: Colors.black54,
blurRadius: 5.0,
offset: Offset(1.0, 1.0),
),
],
),
onPressed: () {
rotation.value = (rotation.value + 1) % 4;
photoViewController.rotation =
rotation.value * -math.pi / 2;
},
),
],
),
),
],
),
);
}
}
class _CloudFileListEntry extends HookConsumerWidget {
final SnCloudFile file;
final String heroTag;
@@ -535,15 +412,8 @@ class _CloudFileListEntry extends HookConsumerWidget {
final lockedByDS = dataSaving && !showDataSaving.value;
final lockedByMature = file.sensitiveMarks.isNotEmpty && !showMature.value;
final meta = file.fileMeta is Map ? file.fileMeta as Map : const {};
final hasRatio =
meta.containsKey('ratio') &&
(meta['ratio'] is num && (meta['ratio'] as num) != 0);
final ratio =
(meta['ratio'] is num && (meta['ratio'] as num) != 0)
? (meta['ratio'] as num).toDouble()
: 1.0;
final fit = hasRatio ? BoxFit.cover : BoxFit.contain;
final fit = BoxFit.cover;
Widget bg = const SizedBox.shrink();
if (isImage) {
@@ -551,7 +421,7 @@ class _CloudFileListEntry extends HookConsumerWidget {
bg = BlurHash(hash: meta['blur'] as String);
} else if (!lockedByDS && !lockedByMature) {
bg = ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
imageFilter: ImageFilter.blur(sigmaX: 50, sigmaY: 50),
child: CloudFileWidget(
fit: BoxFit.cover,
item: file,
@@ -581,7 +451,9 @@ class _CloudFileListEntry extends HookConsumerWidget {
fit: fit,
useInternalGate: false,
))
: AspectRatio(aspectRatio: ratio, child: const SizedBox.shrink());
: IntrinsicWidth(
child: IntrinsicHeight(child: const SizedBox.shrink()),
);
Widget overlays;
if (lockedByDS) {

View File

@@ -0,0 +1,231 @@
import 'dart:io';
import 'dart:math' as math;
import 'package:dismissible_page/dismissible_page.dart';
import 'package:file_saver/file_saver.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gal/gal.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/file_info_sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:path/path.dart' show extension;
import 'package:path_provider/path_provider.dart';
import 'package:photo_view/photo_view.dart';
import 'cloud_files.dart';
class CloudFileLightbox extends HookConsumerWidget {
final SnCloudFile item;
final String heroTag;
const CloudFileLightbox({
super.key,
required this.item,
required this.heroTag,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final serverUrl = ref.watch(serverUrlProvider);
final photoViewController = useMemoized(() => PhotoViewController(), []);
final rotation = useState(0);
final showOriginal = useState(false);
Future<void> saveToGallery() async {
try {
// Show loading indicator
showSnackBar('Saving image...');
// Get the image URL
final client = ref.watch(apiClientProvider);
// Create a temporary file to save the image
final tempDir = await getTemporaryDirectory();
var extName = extension(item.name).trim();
if (extName.isEmpty) {
extName = item.mimeType?.split('/').lastOrNull ?? 'jpeg';
}
final filePath = '${tempDir.path}/${item.id}.$extName';
await client.download(
'/drive/files/${item.id}',
filePath,
queryParameters: {'original': true},
);
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
// Save to gallery
await Gal.putImage(filePath, album: 'Solar Network');
// Show success message
showSnackBar('Image saved to gallery');
} else {
await FileSaver.instance.saveFile(
name: item.name.isEmpty ? '${item.id}.$extName' : item.name,
file: File(filePath),
);
showSnackBar('Image saved to $filePath');
}
} catch (e) {
showErrorAlert(e);
}
}
void showInfoSheet() {
showModalBottomSheet(
useRootNavigator: true,
context: context,
isScrollControlled: true,
builder: (context) => FileInfoSheet(item: item),
);
}
final shadow = [
Shadow(color: Colors.black54, blurRadius: 5.0, offset: Offset(1.0, 1.0)),
];
return DismissiblePage(
isFullScreen: true,
backgroundColor: Colors.transparent,
direction: DismissiblePageDismissDirection.down,
onDismissed: () {
Navigator.of(context).pop();
},
child: Stack(
children: [
Positioned.fill(
child: PhotoView(
backgroundDecoration: BoxDecoration(
color: Colors.black.withOpacity(0.9),
),
controller: photoViewController,
heroAttributes: PhotoViewHeroAttributes(tag: heroTag),
imageProvider: CloudImageWidget.provider(
fileId: item.id,
serverUrl: serverUrl,
original: showOriginal.value,
),
// Apply rotation transformation
customSize: MediaQuery.of(context).size,
basePosition: Alignment.center,
filterQuality: FilterQuality.high,
),
),
// Close button and save button
Positioned(
top: MediaQuery.of(context).padding.top + 16,
right: 16,
left: 16,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
if (!kIsWeb)
IconButton(
icon: Icon(
Icons.save_alt,
color: Colors.white,
shadows: shadow,
),
onPressed: () async {
saveToGallery();
},
),
IconButton(
onPressed: () {
showOriginal.value = !showOriginal.value;
},
icon: Icon(
showOriginal.value ? Symbols.hd : Symbols.sd,
color: Colors.white,
shadows: shadow,
),
),
],
),
IconButton(
icon: Icon(Icons.close, color: Colors.white, shadows: shadow),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
// Rotation controls
Positioned(
bottom: MediaQuery.of(context).padding.bottom + 16,
left: 16,
right: 16,
child: Row(
children: [
IconButton(
icon: Icon(
Icons.info_outline,
color: Colors.white,
shadows: shadow,
),
onPressed: showInfoSheet,
),
Spacer(),
IconButton(
icon: Icon(
Icons.remove,
color: Colors.white,
shadows: shadow,
),
onPressed: () {
photoViewController.scale =
(photoViewController.scale ?? 1) - 0.05;
},
),
IconButton(
icon: Icon(Icons.add, color: Colors.white, shadows: shadow),
onPressed: () {
photoViewController.scale =
(photoViewController.scale ?? 1) + 0.05;
},
),
const Gap(8),
IconButton(
icon: Icon(
Icons.rotate_left,
color: Colors.white,
shadows: shadow,
),
onPressed: () {
rotation.value = (rotation.value - 1) % 4;
photoViewController.rotation =
rotation.value * -math.pi / 2;
},
),
IconButton(
icon: Icon(
Icons.rotate_right,
color: Colors.white,
shadows: [
Shadow(
color: Colors.black54,
blurRadius: 5.0,
offset: Offset(1.0, 1.0),
),
],
),
onPressed: () {
rotation.value = (rotation.value + 1) % 4;
photoViewController.rotation =
rotation.value * -math.pi / 2;
},
),
],
),
),
],
),
);
}
}

View File

@@ -371,13 +371,21 @@ class CloudFileWidget extends HookConsumerWidget {
}
var content = switch (item.mimeType?.split('/').firstOrNull) {
'image' => AspectRatio(
aspectRatio: ratio,
child:
(useInternalGate && dataSaving && !unlocked.value)
? dataPlaceHolder(Symbols.image)
: cloudImage(),
),
'image' =>
ratio == 1.0
? IntrinsicHeight(
child:
(useInternalGate && dataSaving && !unlocked.value)
? dataPlaceHolder(Symbols.image)
: cloudImage(),
)
: AspectRatio(
aspectRatio: ratio,
child:
(useInternalGate && dataSaving && !unlocked.value)
? dataPlaceHolder(Symbols.image)
: cloudImage(),
),
'video' => AspectRatio(
aspectRatio: ratio,
child:

View File

@@ -2,7 +2,6 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:island/models/embed.dart';
import 'package:island/models/poll.dart';
import 'package:island/services/responsive.dart';
import 'package:island/utils/mapping.dart';
import 'package:island/widgets/content/embed/link.dart';
@@ -54,13 +53,10 @@ class EmbedListWidget extends StatelessWidget {
vertical: 8,
),
child:
embedData['poll'] == null
? const Text('Poll was not loaded...')
embedData['id'] == null
? const Text('Poll was unavailable...')
: PollSubmit(
initialAnswers:
embedData['poll']?['user_answer']?['answer'],
stats: embedData['poll']?['stats'],
poll: SnPollWithStats.fromJson(embedData['poll']),
pollId: embedData['id'],
onSubmit: (_) {},
isReadonly: !isInteractive,
isInitiallyExpanded: isFullPost,

View File

@@ -1,3 +1,6 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:island/models/embed.dart';
@@ -5,7 +8,7 @@ import 'package:island/widgets/content/image.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:url_launcher/url_launcher.dart';
class EmbedLinkWidget extends StatelessWidget {
class EmbedLinkWidget extends StatefulWidget {
final SnScrappedLink link;
final double? maxWidth;
final EdgeInsetsGeometry? margin;
@@ -17,167 +20,264 @@ class EmbedLinkWidget extends StatelessWidget {
this.margin,
});
@override
State<EmbedLinkWidget> createState() => _EmbedLinkWidgetState();
}
class _EmbedLinkWidgetState extends State<EmbedLinkWidget> {
bool? _isSquare;
@override
void initState() {
super.initState();
_checkIfSquare();
}
Future<void> _checkIfSquare() async {
if (widget.link.imageUrl == null ||
widget.link.imageUrl!.isEmpty ||
widget.link.imageUrl == widget.link.faviconUrl) {
return;
}
try {
final image = CachedNetworkImageProvider(widget.link.imageUrl!);
final ImageStream stream = image.resolve(ImageConfiguration.empty);
final completer = Completer<ImageInfo>();
final listener = ImageStreamListener((
ImageInfo info,
bool synchronousCall,
) {
completer.complete(info);
});
stream.addListener(listener);
final info = await completer.future;
stream.removeListener(listener);
final aspectRatio = info.image.width / info.image.height;
setState(() {
_isSquare = aspectRatio >= 0.9 && aspectRatio <= 1.1;
});
} catch (e) {
// If error, assume not square
setState(() {
_isSquare = false;
});
}
}
Future<void> _launchUrl() async {
final uri = Uri.parse(link.url);
final uri = Uri.parse(widget.link.url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
String _getBaseUrl(String url) {
final uri = Uri.parse(url);
final port = uri.port;
final defaultPort = uri.scheme == 'https' ? 443 : 80;
final portString = port != defaultPort ? ':$port' : '';
return '${uri.scheme}://${uri.host}$portString';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
width: maxWidth,
margin: margin ?? const EdgeInsets.symmetric(vertical: 8),
width: widget.maxWidth,
margin: widget.margin ?? const EdgeInsets.symmetric(vertical: 8),
child: Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: _launchUrl,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
child: Row(
children: [
// Preview Image
if (link.imageUrl != null && link.imageUrl!.isNotEmpty)
AspectRatio(
aspectRatio: 16 / 9,
child: UniversalImage(uri: link.imageUrl!, fit: BoxFit.cover),
// Sqaure open graph image
if (_isSquare == true) ...[
Flexible(
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 120),
child: AspectRatio(
aspectRatio: 1,
child: UniversalImage(
uri: widget.link.imageUrl!,
fit: BoxFit.cover,
),
),
),
),
// Content
Padding(
padding: const EdgeInsets.all(16),
const Gap(8),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Site info row
Row(
children: [
// Favicon
if (link.faviconUrl?.isNotEmpty ?? false) ...[
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: UniversalImage(
uri: link.faviconUrl!,
width: 16,
height: 16,
fit: BoxFit.cover,
),
// Preview Image
if (widget.link.imageUrl != null &&
widget.link.imageUrl!.isNotEmpty &&
widget.link.imageUrl != widget.link.faviconUrl &&
_isSquare != true)
Container(
width: double.infinity,
color:
Theme.of(context).colorScheme.surfaceContainerHigh,
child: IntrinsicHeight(
child: UniversalImage(
uri: widget.link.imageUrl!,
fit: BoxFit.cover,
useFallbackImage: false,
),
const Gap(8),
] else ...[
Icon(
Symbols.link,
size: 16,
color: colorScheme.onSurfaceVariant,
),
const Gap(8),
],
),
),
// Site name
Expanded(
child: Text(
(link.siteName?.isNotEmpty ?? false)
? link.siteName!
: Uri.parse(link.url).host,
// Content
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Site info row
Row(
children: [
if (widget.link.faviconUrl?.isNotEmpty ??
false) ...[
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: UniversalImage(
uri:
widget.link.faviconUrl!.startsWith('//')
? 'https:${widget.link.faviconUrl!}'
: widget.link.faviconUrl!
.startsWith('/')
? _getBaseUrl(widget.link.url) +
widget.link.faviconUrl!
: widget.link.faviconUrl!,
width: 16,
height: 16,
fit: BoxFit.cover,
useFallbackImage: false,
),
),
const Gap(8),
] else ...[
Icon(
Symbols.link,
size: 16,
color: colorScheme.onSurfaceVariant,
),
const Gap(8),
],
// Site name
Expanded(
child: Text(
(widget.link.siteName?.isNotEmpty ?? false)
? widget.link.siteName!
: Uri.parse(widget.link.url).host,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// External link icon
Icon(
Symbols.open_in_new,
size: 16,
color: colorScheme.onSurfaceVariant,
),
],
),
const Gap(8),
// Title
if (widget.link.title.isNotEmpty) ...[
Text(
widget.link.title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: _isSquare == true ? 1 : 2,
overflow: TextOverflow.ellipsis,
),
Gap(_isSquare == true ? 2 : 4),
],
// Description
if (widget.link.description != null &&
widget.link.description!.isNotEmpty) ...[
Text(
widget.link.description!,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: _isSquare == true ? 1 : 3,
overflow: TextOverflow.ellipsis,
),
Gap(_isSquare == true ? 4 : 8),
],
// URL
Text(
widget.link.url,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
color: colorScheme.primary,
decoration: TextDecoration.underline,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// External link icon
Icon(
Symbols.open_in_new,
size: 16,
color: colorScheme.onSurfaceVariant,
),
],
),
const Gap(8),
// Title
if (link.title.isNotEmpty) ...[
Text(
link.title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const Gap(4),
],
// Description
if (link.description != null &&
link.description!.isNotEmpty) ...[
Text(
link.description!,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const Gap(8),
],
// URL
Text(
link.url,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
decoration: TextDecoration.underline,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Author and publish date
if (link.author != null || link.publishedDate != null) ...[
const Gap(8),
Row(
children: [
if (link.author != null) ...[
Icon(
Symbols.person,
size: 14,
color: colorScheme.onSurfaceVariant,
),
const Gap(4),
Text(
link.author!,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
if (link.author != null && link.publishedDate != null)
const Gap(16),
if (link.publishedDate != null) ...[
Icon(
Symbols.schedule,
size: 14,
color: colorScheme.onSurfaceVariant,
),
const Gap(4),
Text(
_formatDate(link.publishedDate!),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
// Author and publish date
if (widget.link.author != null ||
widget.link.publishedDate != null) ...[
const Gap(8),
Row(
children: [
if (widget.link.author != null) ...[
Icon(
Symbols.person,
size: 14,
color: colorScheme.onSurfaceVariant,
),
const Gap(4),
Text(
widget.link.author!,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
if (widget.link.author != null &&
widget.link.publishedDate != null)
const Gap(16),
if (widget.link.publishedDate != null) ...[
Icon(
Symbols.schedule,
size: 14,
color: colorScheme.onSurfaceVariant,
),
const Gap(4),
Text(
_formatDate(widget.link.publishedDate!),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
],
),
],
],
),
],
),
],
),
),

View File

@@ -1,6 +1,7 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_blurhash/flutter_blurhash.dart';
import 'package:flutter_svg/flutter_svg.dart';
class UniversalImage extends StatelessWidget {
final String uri;
@@ -9,6 +10,8 @@ class UniversalImage extends StatelessWidget {
final double? width;
final double? height;
final bool noCacheOptimization;
final bool isSvg;
final bool useFallbackImage;
const UniversalImage({
super.key,
@@ -18,10 +21,26 @@ class UniversalImage extends StatelessWidget {
this.width,
this.height,
this.noCacheOptimization = false,
this.isSvg = false,
this.useFallbackImage = true,
});
@override
Widget build(BuildContext context) {
final isSvgImage = isSvg || uri.toLowerCase().endsWith('.svg');
if (isSvgImage) {
return SvgPicture.network(
uri,
fit: fit,
width: width,
height: height,
placeholderBuilder:
(BuildContext context) =>
Center(child: CircularProgressIndicator()),
);
}
int? cacheWidth;
int? cacheHeight;
if (width != null && height != null && !noCacheOptimization) {
@@ -50,13 +69,15 @@ class UniversalImage extends StatelessWidget {
child: CircularProgressIndicator(value: progress.progress),
);
},
errorWidget: (context, url, error) {
return Image.asset(
'assets/images/media-offline.jpg',
fit: BoxFit.cover,
key: Key('image-broke-$uri'),
);
},
errorWidget:
(context, url, error) =>
useFallbackImage
? Image.asset(
'assets/images/media-offline.jpg',
fit: BoxFit.cover,
key: Key('image-broke-$uri'),
)
: SizedBox.shrink(),
),
],
),

View File

@@ -10,6 +10,8 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/pods/config.dart';
import 'package:island/screens/account/profile.dart';
import 'package:island/screens/creators/publishers_form.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/markdown_latex.dart';
@@ -397,7 +399,13 @@ class MentionChipSpanNode extends SpanNode {
onTap: () => onTap(type, id),
borderRadius: BorderRadius.circular(32),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
padding: const EdgeInsets.only(
left: 5,
right: 7,
top: 2.5,
bottom: 2.5,
),
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
color: backgroundColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(32),
@@ -411,18 +419,58 @@ class MentionChipSpanNode extends SpanNode {
color: backgroundColor.withOpacity(0.5),
borderRadius: const BorderRadius.all(Radius.circular(32)),
),
child: Icon(
switch (parts.first.isEmpty ? 'u' : parts.first) {
'c' => Symbols.forum_rounded,
'r' => Symbols.group_rounded,
'u' => Symbols.person_rounded,
'p' => Symbols.edit_rounded,
_ => Symbols.person_rounded,
},
size: 14,
color: foregroundColor,
fill: 1,
).padding(all: 2),
child: switch (parts.length == 1 ? 'u' : parts.first) {
'u' => Consumer(
builder: (context, ref, _) {
final userData = ref.watch(accountProvider(parts.last));
return userData.when(
data:
(data) => ProfilePictureWidget(
file: data.profile.picture,
fallbackIcon: Symbols.person_rounded,
radius: 9,
),
error: (_, _) => const Icon(Symbols.close),
loading:
() => const SizedBox(
width: 9,
height: 9,
child: CircularProgressIndicator(),
),
);
},
),
'p' => Consumer(
builder: (context, ref, _) {
final pubData = ref.watch(publisherProvider(parts.last));
return pubData.when(
data:
(data) => ProfilePictureWidget(
file: data?.picture,
fallbackIcon: Symbols.design_services_rounded,
radius: 9,
),
error: (_, _) => const Icon(Symbols.close),
loading:
() => const SizedBox(
width: 9,
height: 9,
child: CircularProgressIndicator(),
),
);
},
),
_ => Icon(
(switch (parts.length == 1 ? 'u' : parts.first) {
'c' => Symbols.forum_rounded,
'r' => Symbols.group_rounded,
_ => Symbols.person_rounded,
}),
size: 14,
color: foregroundColor,
fill: 1,
).padding(all: 2),
},
),
Text(
parts.last,

View File

@@ -11,7 +11,7 @@ import 'package:island/widgets/alert.dart';
import 'package:island/widgets/post/compose_sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
enum FabMenuType { main, chat, realm }
enum FabMenuType { main, compose, chat, realm }
/// Global state provider for FAB menu type
final fabMenuTypeProvider = StateProvider<FabMenuType>(
@@ -69,7 +69,7 @@ class FabMenu extends HookConsumerWidget {
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.notifications),
trailing: Badge(
label: Text(notificationCount.toString()),
label: Text(notificationCount.value.toString()),
isLabelVisible: notificationCount.value! > 0,
),
title: Text('notifications').tr(),
@@ -88,6 +88,38 @@ class FabMenu extends HookConsumerWidget {
];
switch (fabType) {
case FabMenuType.compose:
icon = Symbols.create;
useRootNavigator = false;
menuContent = Column(
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(),
...commonEntires,
Gap(MediaQuery.of(context).padding.bottom + 16),
],
);
break;
case FabMenuType.chat:
icon = Symbols.chat_add_on;
useRootNavigator = true;
@@ -160,16 +192,6 @@ 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);
},
),
const Divider(),
...commonEntires,
Gap(MediaQuery.of(context).padding.bottom + 16),
],

View File

@@ -4,15 +4,15 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/models/poll.dart';
import 'package:island/pods/network.dart';
import 'package:island/screens/creators/poll/poll_list.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/poll/poll_stats_widget.dart';
class PollSubmit extends ConsumerStatefulWidget {
const PollSubmit({
super.key,
required this.poll,
required this.pollId,
required this.onSubmit,
required this.stats,
this.initialAnswers,
this.onCancel,
this.showProgress = true,
@@ -20,14 +20,13 @@ class PollSubmit extends ConsumerStatefulWidget {
this.isInitiallyExpanded = false,
});
final SnPollWithStats poll;
final String pollId;
/// Callback when user submits all answers. Map questionId -> answer.
final void Function(Map<String, dynamic> answers) onSubmit;
/// Optional initial answers, keyed by questionId.
final Map<String, dynamic>? initialAnswers;
final Map<String, dynamic>? stats;
/// Optional cancel callback.
final VoidCallback? onCancel;
@@ -45,7 +44,7 @@ class PollSubmit extends ConsumerStatefulWidget {
}
class _PollSubmitState extends ConsumerState<PollSubmit> {
late final List<SnPollQuestion> _questions;
List<SnPollQuestion>? _questions;
int _index = 0;
bool _submitting = false;
bool _isModifying = false; // New state to track if user is modifying answers
@@ -66,14 +65,10 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
@override
void initState() {
super.initState();
// Ensure questions are ordered by `order`
_questions = [...widget.poll.questions]
..sort((a, b) => a.order.compareTo(b.order));
_answers = Map<String, dynamic>.from(widget.initialAnswers ?? {});
// Set initial collapse state based on the parameter
_isCollapsed = !widget.isInitiallyExpanded;
if (!widget.isReadonly) {
_loadCurrentIntoLocalState();
// If initial answers are provided, set _isModifying to false initially
// so the "Modify" button is shown.
if (widget.initialAnswers != null && widget.initialAnswers!.isNotEmpty) {
@@ -82,23 +77,25 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
}
}
void _initializeFromPollData(SnPollWithStats poll) {
// Initialize answers from poll data if available
if (poll.userAnswer != null && poll.userAnswer!.answer.isNotEmpty) {
_answers = Map<String, dynamic>.from(poll.userAnswer!.answer);
if (!widget.isReadonly && !_isModifying) {
_isModifying = false; // Show modify button if user has answered
}
}
_loadCurrentIntoLocalState();
}
@override
void didUpdateWidget(covariant PollSubmit oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.poll.id != widget.poll.id) {
if (oldWidget.pollId != widget.pollId) {
_index = 0;
_answers = Map<String, dynamic>.from(widget.initialAnswers ?? {});
_questions
..clear()
..addAll(
[...widget.poll.questions]
..sort((a, b) => a.order.compareTo(b.order)),
);
if (!widget.isReadonly) {
_loadCurrentIntoLocalState();
// If poll ID changes, reset modification state
_isModifying = false;
}
// Reset modification state when poll changes
_isModifying = false;
}
}
@@ -108,7 +105,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
super.dispose();
}
SnPollQuestion get _current => _questions[_index];
SnPollQuestion get _current => _questions![_index];
void _loadCurrentIntoLocalState() {
final q = _current;
@@ -201,7 +198,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
}
}
Future<void> _submitToServer() async {
Future<void> _submitToServer(SnPollWithStats poll) async {
// Persist current question before final submit
_persistCurrentAnswer();
@@ -213,7 +210,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
final dio = ref.read(apiClientProvider);
await dio.post(
'/sphere/polls/${widget.poll.id}/answer',
'/sphere/polls/${poll.id}/answer',
data: {'answer': _answers},
);
@@ -233,17 +230,17 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
}
}
void _next() {
void _next(SnPollWithStats poll) {
if (_submitting) return;
_persistCurrentAnswer();
if (_index < _questions.length - 1) {
if (_index < _questions!.length - 1) {
setState(() {
_index++;
_loadCurrentIntoLocalState();
});
} else {
// Final submit to API
_submitToServer();
_submitToServer(poll);
}
}
@@ -261,41 +258,15 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
}
}
Widget _buildHeader(BuildContext context) {
Widget _buildHeader(BuildContext context, SnPollWithStats poll) {
final q = _current;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.poll.title != null || widget.poll.description != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.poll.title != null)
Text(
widget.poll.title!,
style: Theme.of(context).textTheme.titleLarge,
),
if (widget.poll.description != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
widget.poll.description!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(
context,
).textTheme.bodyMedium?.color?.withOpacity(0.7),
),
),
),
],
),
),
if (widget.showProgress &&
_isModifying) // Only show progress when modifying
Text(
'${_index + 1} / ${_questions.length}',
'${_index + 1} / ${_questions!.length}',
style: Theme.of(context).textTheme.labelMedium,
),
Row(
@@ -334,12 +305,18 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
);
}
Widget _buildStats(BuildContext context, SnPollQuestion q) {
return PollStatsWidget(question: q, stats: widget.stats);
Widget _buildStats(
BuildContext context,
SnPollQuestion q,
Map<String, dynamic>? stats,
) {
return PollStatsWidget(question: q, stats: stats);
}
Widget _buildBody(BuildContext context) {
if (widget.initialAnswers != null && !widget.isReadonly && !_isModifying) {
Widget _buildBody(BuildContext context, SnPollWithStats poll) {
final hasUserAnswer =
poll.userAnswer != null && poll.userAnswer!.answer.isNotEmpty;
if (hasUserAnswer && !widget.isReadonly && !_isModifying) {
return const SizedBox.shrink(); // Collapse input fields if already submitted and not modifying
}
final q = _current;
@@ -449,11 +426,13 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
);
}
Widget _buildNavBar(BuildContext context) {
final isLast = _index == _questions.length - 1;
Widget _buildNavBar(BuildContext context, SnPollWithStats poll) {
final isLast = _index == _questions!.length - 1;
final canProceed = _isCurrentAnswered() && !_submitting;
final hasUserAnswer =
poll.userAnswer != null && poll.userAnswer!.answer.isNotEmpty;
if (widget.initialAnswers != null && !_isModifying && !widget.isReadonly) {
if (hasUserAnswer && !_isModifying && !widget.isReadonly) {
// If poll is submitted and not in modification mode, show "Modify" button
return FilledButton.icon(
icon: const Icon(Icons.edit),
@@ -498,32 +477,32 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
)
: Icon(isLast ? Icons.check : Icons.arrow_forward),
label: Text(isLast ? 'submit'.tr() : 'next'.tr()),
onPressed: canProceed ? _next : null,
onPressed: canProceed ? () => _next(poll) : null,
),
],
);
}
Widget _buildSubmittedView(BuildContext context) {
Widget _buildSubmittedView(BuildContext context, SnPollWithStats poll) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.poll.title != null || widget.poll.description != null)
if (poll.title != null || poll.description != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.poll.title?.isNotEmpty ?? false)
if (poll.title?.isNotEmpty ?? false)
Text(
widget.poll.title!,
poll.title!,
style: Theme.of(context).textTheme.titleLarge,
),
if (widget.poll.description?.isNotEmpty ?? false)
if (poll.description?.isNotEmpty ?? false)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
widget.poll.description!,
poll.description!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(
context,
@@ -534,7 +513,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
],
),
),
for (final q in _questions)
for (final q in _questions!)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Column(
@@ -574,7 +553,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
),
),
),
_buildStats(context, q),
_buildStats(context, q, poll.stats),
],
),
),
@@ -582,26 +561,26 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
);
}
Widget _buildReadonlyView(BuildContext context) {
Widget _buildReadonlyView(BuildContext context, SnPollWithStats poll) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.poll.title != null || widget.poll.description != null)
if (poll.title != null || poll.description != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.poll.title != null)
if (poll.title != null)
Text(
widget.poll.title!,
poll.title!,
style: Theme.of(context).textTheme.titleLarge,
),
if (widget.poll.description != null)
if (poll.description != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
widget.poll.description!,
poll.description!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(
context,
@@ -612,7 +591,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
],
),
),
for (final q in _questions)
for (final q in _questions!)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Column(
@@ -652,7 +631,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
),
),
),
_buildStats(context, q),
_buildStats(context, q, poll.stats),
],
),
),
@@ -660,7 +639,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
);
}
Widget _buildCollapsedView(BuildContext context) {
Widget _buildCollapsedView(BuildContext context, SnPollWithStats poll) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -670,20 +649,20 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.poll.title != null)
if (poll.title != null)
Text(
widget.poll.title!,
poll.title!,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (widget.poll.description != null)
if (poll.description != null)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
widget.poll.description!,
poll.description!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(
context,
@@ -697,7 +676,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
'${_questions.length} question${_questions.length == 1 ? '' : 's'}',
'${_questions!.length} question${_questions!.length == 1 ? '' : 's'}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(
context,
@@ -729,111 +708,156 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
@override
Widget build(BuildContext context) {
if (_questions.isEmpty) {
return const SizedBox.shrink();
}
final pollAsync = ref.watch(pollWithStatsProvider(widget.pollId));
// If collapsed, show collapsed view for all states
if (_isCollapsed) {
return _buildCollapsedView(context);
}
// If poll is already submitted and not in readonly mode, and not in modification mode, show submitted view
if (widget.initialAnswers != null && !widget.isReadonly && !_isModifying) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildCollapsedView(context),
const SizedBox(height: 8),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, anim) {
final offset = Tween<Offset>(
begin: const Offset(0, -0.1),
end: Offset.zero,
).animate(CurvedAnimation(parent: anim, curve: Curves.easeOut));
final fade = CurvedAnimation(parent: anim, curve: Curves.easeOut);
return FadeTransition(
opacity: fade,
child: SlideTransition(position: offset, child: child),
);
},
child: Column(
key: const ValueKey('submitted_expanded'),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [_buildSubmittedView(context), _buildNavBar(context)],
return pollAsync.when(
loading:
() => const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator(),
),
),
],
);
}
// If poll is in readonly mode, show readonly view
if (widget.isReadonly) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildCollapsedView(context),
const SizedBox(height: 8),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, anim) {
final offset = Tween<Offset>(
begin: const Offset(0, -0.1),
end: Offset.zero,
).animate(CurvedAnimation(parent: anim, curve: Curves.easeOut));
final fade = CurvedAnimation(parent: anim, curve: Curves.easeOut);
return FadeTransition(
opacity: fade,
child: SlideTransition(position: offset, child: child),
);
},
child: _buildReadonlyView(context),
error:
(error, stack) => Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text('Failed to load poll: $error'),
),
),
],
);
}
data: (poll) {
// Initialize questions when data is available
_questions = [...poll.questions]
..sort((a, b) => a.order.compareTo(b.order));
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildCollapsedView(context),
const SizedBox(height: 8),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, anim) {
final offset = Tween<Offset>(
begin: const Offset(0, -0.1),
end: Offset.zero,
).animate(CurvedAnimation(parent: anim, curve: Curves.easeOut));
final fade = CurvedAnimation(parent: anim, curve: Curves.easeOut);
return FadeTransition(
opacity: fade,
child: SlideTransition(position: offset, child: child),
);
},
child: Column(
key: const ValueKey('normal_expanded'),
// Initialize answers from poll data
_initializeFromPollData(poll);
if (_questions!.isEmpty) {
return const SizedBox.shrink();
}
// If collapsed, show collapsed view for all states
if (_isCollapsed) {
return _buildCollapsedView(context, poll);
}
// If poll is already submitted and not in readonly mode, and not in modification mode, show submitted view
final hasUserAnswer =
poll.userAnswer != null && poll.userAnswer!.answer.isNotEmpty;
if (hasUserAnswer && !widget.isReadonly && !_isModifying) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(context),
const SizedBox(height: 12),
_AnimatedStep(
key: ValueKey(_current.id),
_buildCollapsedView(context, poll),
const SizedBox(height: 8),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, anim) {
final offset = Tween<Offset>(
begin: const Offset(0, -0.1),
end: Offset.zero,
).animate(
CurvedAnimation(parent: anim, curve: Curves.easeOut),
);
final fade = CurvedAnimation(
parent: anim,
curve: Curves.easeOut,
);
return FadeTransition(
opacity: fade,
child: SlideTransition(position: offset, child: child),
);
},
child: Column(
key: const ValueKey('submitted_expanded'),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildBody(context),
_buildStats(context, _current),
_buildSubmittedView(context, poll),
_buildNavBar(context, poll),
],
),
),
const SizedBox(height: 16),
_buildNavBar(context),
],
),
),
],
);
}
// If poll is in readonly mode, show readonly view
if (widget.isReadonly) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildCollapsedView(context, poll),
const SizedBox(height: 8),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, anim) {
final offset = Tween<Offset>(
begin: const Offset(0, -0.1),
end: Offset.zero,
).animate(
CurvedAnimation(parent: anim, curve: Curves.easeOut),
);
final fade = CurvedAnimation(
parent: anim,
curve: Curves.easeOut,
);
return FadeTransition(
opacity: fade,
child: SlideTransition(position: offset, child: child),
);
},
child: _buildReadonlyView(context, poll),
),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildCollapsedView(context, poll),
const SizedBox(height: 8),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, anim) {
final offset = Tween<Offset>(
begin: const Offset(0, -0.1),
end: Offset.zero,
).animate(CurvedAnimation(parent: anim, curve: Curves.easeOut));
final fade = CurvedAnimation(
parent: anim,
curve: Curves.easeOut,
);
return FadeTransition(
opacity: fade,
child: SlideTransition(position: offset, child: child),
);
},
child: Column(
key: const ValueKey('normal_expanded'),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(context, poll),
const SizedBox(height: 12),
_AnimatedStep(
key: ValueKey(_current.id),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildBody(context, poll),
_buildStats(context, _current, poll.stats),
],
),
),
const SizedBox(height: 16),
_buildNavBar(context, poll),
],
),
),
],
);
},
);
}
}

View File

@@ -423,6 +423,7 @@ class PostComposeCard extends HookConsumerWidget {
state: composeState,
originalPost: originalPost,
isCompact: true,
useSafeArea: isContained,
),
),
),

View File

@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:island/services/event_bus.dart';
import 'package:mime/mime.dart';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -709,6 +710,8 @@ class ComposeLogic {
if (context.mounted) {
Navigator.of(context).maybePop(true);
}
eventBus.fire(PostCreatedEvent());
} catch (err) {
showErrorAlert(err);
} finally {

View File

@@ -6,7 +6,6 @@ import 'package:island/models/file.dart';
import 'package:island/models/post.dart';
import 'package:island/screens/posts/compose.dart';
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';
@@ -124,8 +123,6 @@ class PostComposeSheet extends HookConsumerWidget {
repliedPost: repliedPost,
forwardedPost: forwardedPost,
onSuccess: () {
// Fire event to notify listeners that a post was created
eventBus.fire(PostCreatedEvent());
Navigator.of(context).pop(true);
},
);
@@ -162,8 +159,6 @@ class PostComposeSheet extends HookConsumerWidget {
initialState: restoredInitialState.value ?? initialState,
onCancel: () => Navigator.of(context).pop(),
onSubmit: () {
// Fire event to notify listeners that a post was created
eventBus.fire(PostCreatedEvent());
Navigator.of(context).pop(true);
},
isContained: true,

View File

@@ -3,6 +3,7 @@ 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';
@@ -96,6 +97,7 @@ class ComposeSubmitUtils {
// Call the success callback
onSuccess();
eventBus.fire(PostCreatedEvent());
return post;
} catch (err) {

View File

@@ -14,12 +14,14 @@ class ComposeToolbar extends HookConsumerWidget {
final ComposeState state;
final SnPost? originalPost;
final bool isCompact;
final bool useSafeArea;
const ComposeToolbar({
super.key,
required this.state,
this.originalPost,
this.isCompact = false,
this.useSafeArea = false,
});
@override
@@ -200,7 +202,12 @@ class ComposeToolbar extends HookConsumerWidget {
),
),
],
).padding(horizontal: 8, vertical: 4),
).padding(
horizontal: 8,
top: 4,
bottom:
useSafeArea ? MediaQuery.of(context).padding.bottom + 4 : 4,
),
),
),
);

View File

@@ -10,6 +10,7 @@ import 'package:island/services/time.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/post/post_shared.dart';
import 'package:island/widgets/post/compose_sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:super_context_menu/super_context_menu.dart';
@@ -45,13 +46,23 @@ class PostItemCreator extends HookConsumerWidget {
title: 'edit'.tr(),
image: MenuImage.icon(Symbols.edit),
callback: () {
context
.pushNamed('postEdit', pathParameters: {'id': item.id})
.then((value) {
if (value != null) {
onRefresh?.call();
}
});
if (item.type == 1) {
context
.pushNamed('articleEdit', pathParameters: {'id': item.id})
.then((value) {
if (value != null) {
onRefresh?.call();
}
});
} else {
PostComposeSheet.show(context, originalPost: item).then((
value,
) {
if (value == true) {
onRefresh?.call();
}
});
}
},
),
MenuAction(

View File

@@ -24,6 +24,12 @@ class PostListNotifier extends _$PostListNotifier
bool? pinned,
bool shuffle = false,
bool? includeReplies,
bool? mediaOnly,
String? queryTerm,
String? order,
int? periodStart,
int? periodEnd,
bool orderDesc = true,
}) {
return fetch(cursor: null);
}
@@ -36,14 +42,20 @@ class PostListNotifier extends _$PostListNotifier
final queryParams = {
'offset': offset,
'take': _pageSize,
'replies': includeReplies,
'orderDesc': orderDesc,
if (shuffle) 'shuffle': shuffle,
if (pubName != null) 'pub': pubName,
if (realm != null) 'realm': realm,
if (type != null) 'type': type,
if (tags != null) 'tags': tags,
if (categories != null) 'categories': categories,
if (shuffle) 'shuffle': true,
if (pinned != null) 'pinned': pinned,
if (includeReplies != null) 'includeReplies': includeReplies,
if (order != null) 'order': order,
if (periodStart != null) 'periodStart': periodStart,
if (periodEnd != null) 'periodEnd': periodEnd,
if (queryTerm != null) 'query': queryTerm,
if (mediaOnly != null) 'media': mediaOnly,
};
final response = await client.get(
@@ -82,6 +94,14 @@ class SliverPostList extends HookConsumerWidget {
final List<String>? tags;
final bool shuffle;
final bool? pinned;
final bool? includeReplies;
final bool? mediaOnly;
final String? queryTerm;
// Can be "populaurity", other value will be treated as "date"
final String? order;
final int? periodStart;
final int? periodEnd;
final bool? orderDesc;
final PostItemType itemType;
final Color? backgroundColor;
final EdgeInsets? padding;
@@ -99,6 +119,13 @@ class SliverPostList extends HookConsumerWidget {
this.tags,
this.shuffle = false,
this.pinned,
this.includeReplies,
this.mediaOnly,
this.queryTerm,
this.order,
this.orderDesc = true,
this.periodStart,
this.periodEnd,
this.itemType = PostItemType.regular,
this.backgroundColor,
this.padding,
@@ -118,6 +145,13 @@ class SliverPostList extends HookConsumerWidget {
tags: tags,
shuffle: shuffle,
pinned: pinned,
includeReplies: includeReplies,
mediaOnly: mediaOnly,
queryTerm: queryTerm,
order: order,
periodStart: periodStart,
periodEnd: periodEnd,
orderDesc: orderDesc ?? true,
);
return PagingHelperSliverView(
provider: provider,

View File

@@ -6,7 +6,7 @@ part of 'post_list.dart';
// RiverpodGenerator
// **************************************************************************
String _$postListNotifierHash() => r'fc139ad4df0deb67bcbb949560319f2f7fbfb503';
String _$postListNotifierHash() => r'8241120dc3c2004387c6cf881e5cb9224cbd3a97';
/// Copied from Dart SDK
class _SystemHash {
@@ -39,6 +39,12 @@ abstract class _$PostListNotifier
late final bool? pinned;
late final bool shuffle;
late final bool? includeReplies;
late final bool? mediaOnly;
late final String? queryTerm;
late final String? order;
late final int? periodStart;
late final int? periodEnd;
late final bool orderDesc;
FutureOr<CursorPagingData<SnPost>> build({
String? pubName,
@@ -49,6 +55,12 @@ abstract class _$PostListNotifier
bool? pinned,
bool shuffle = false,
bool? includeReplies,
bool? mediaOnly,
String? queryTerm,
String? order,
int? periodStart,
int? periodEnd,
bool orderDesc = true,
});
}
@@ -72,6 +84,12 @@ class PostListNotifierFamily
bool? pinned,
bool shuffle = false,
bool? includeReplies,
bool? mediaOnly,
String? queryTerm,
String? order,
int? periodStart,
int? periodEnd,
bool orderDesc = true,
}) {
return PostListNotifierProvider(
pubName: pubName,
@@ -82,6 +100,12 @@ class PostListNotifierFamily
pinned: pinned,
shuffle: shuffle,
includeReplies: includeReplies,
mediaOnly: mediaOnly,
queryTerm: queryTerm,
order: order,
periodStart: periodStart,
periodEnd: periodEnd,
orderDesc: orderDesc,
);
}
@@ -98,6 +122,12 @@ class PostListNotifierFamily
pinned: provider.pinned,
shuffle: provider.shuffle,
includeReplies: provider.includeReplies,
mediaOnly: provider.mediaOnly,
queryTerm: provider.queryTerm,
order: provider.order,
periodStart: provider.periodStart,
periodEnd: provider.periodEnd,
orderDesc: provider.orderDesc,
);
}
@@ -133,6 +163,12 @@ class PostListNotifierProvider
bool? pinned,
bool shuffle = false,
bool? includeReplies,
bool? mediaOnly,
String? queryTerm,
String? order,
int? periodStart,
int? periodEnd,
bool orderDesc = true,
}) : this._internal(
() =>
PostListNotifier()
@@ -143,7 +179,13 @@ class PostListNotifierProvider
..tags = tags
..pinned = pinned
..shuffle = shuffle
..includeReplies = includeReplies,
..includeReplies = includeReplies
..mediaOnly = mediaOnly
..queryTerm = queryTerm
..order = order
..periodStart = periodStart
..periodEnd = periodEnd
..orderDesc = orderDesc,
from: postListNotifierProvider,
name: r'postListNotifierProvider',
debugGetCreateSourceHash:
@@ -161,6 +203,12 @@ class PostListNotifierProvider
pinned: pinned,
shuffle: shuffle,
includeReplies: includeReplies,
mediaOnly: mediaOnly,
queryTerm: queryTerm,
order: order,
periodStart: periodStart,
periodEnd: periodEnd,
orderDesc: orderDesc,
);
PostListNotifierProvider._internal(
@@ -178,6 +226,12 @@ class PostListNotifierProvider
required this.pinned,
required this.shuffle,
required this.includeReplies,
required this.mediaOnly,
required this.queryTerm,
required this.order,
required this.periodStart,
required this.periodEnd,
required this.orderDesc,
}) : super.internal();
final String? pubName;
@@ -188,6 +242,12 @@ class PostListNotifierProvider
final bool? pinned;
final bool shuffle;
final bool? includeReplies;
final bool? mediaOnly;
final String? queryTerm;
final String? order;
final int? periodStart;
final int? periodEnd;
final bool orderDesc;
@override
FutureOr<CursorPagingData<SnPost>> runNotifierBuild(
@@ -202,6 +262,12 @@ class PostListNotifierProvider
pinned: pinned,
shuffle: shuffle,
includeReplies: includeReplies,
mediaOnly: mediaOnly,
queryTerm: queryTerm,
order: order,
periodStart: periodStart,
periodEnd: periodEnd,
orderDesc: orderDesc,
);
}
@@ -219,7 +285,13 @@ class PostListNotifierProvider
..tags = tags
..pinned = pinned
..shuffle = shuffle
..includeReplies = includeReplies,
..includeReplies = includeReplies
..mediaOnly = mediaOnly
..queryTerm = queryTerm
..order = order
..periodStart = periodStart
..periodEnd = periodEnd
..orderDesc = orderDesc,
from: from,
name: null,
dependencies: null,
@@ -233,6 +305,12 @@ class PostListNotifierProvider
pinned: pinned,
shuffle: shuffle,
includeReplies: includeReplies,
mediaOnly: mediaOnly,
queryTerm: queryTerm,
order: order,
periodStart: periodStart,
periodEnd: periodEnd,
orderDesc: orderDesc,
),
);
}
@@ -256,7 +334,13 @@ class PostListNotifierProvider
other.tags == tags &&
other.pinned == pinned &&
other.shuffle == shuffle &&
other.includeReplies == includeReplies;
other.includeReplies == includeReplies &&
other.mediaOnly == mediaOnly &&
other.queryTerm == queryTerm &&
other.order == order &&
other.periodStart == periodStart &&
other.periodEnd == periodEnd &&
other.orderDesc == orderDesc;
}
@override
@@ -270,6 +354,12 @@ class PostListNotifierProvider
hash = _SystemHash.combine(hash, pinned.hashCode);
hash = _SystemHash.combine(hash, shuffle.hashCode);
hash = _SystemHash.combine(hash, includeReplies.hashCode);
hash = _SystemHash.combine(hash, mediaOnly.hashCode);
hash = _SystemHash.combine(hash, queryTerm.hashCode);
hash = _SystemHash.combine(hash, order.hashCode);
hash = _SystemHash.combine(hash, periodStart.hashCode);
hash = _SystemHash.combine(hash, periodEnd.hashCode);
hash = _SystemHash.combine(hash, orderDesc.hashCode);
return _SystemHash.finish(hash);
}
@@ -302,6 +392,24 @@ mixin PostListNotifierRef
/// The parameter `includeReplies` of this provider.
bool? get includeReplies;
/// The parameter `mediaOnly` of this provider.
bool? get mediaOnly;
/// The parameter `queryTerm` of this provider.
String? get queryTerm;
/// The parameter `order` of this provider.
String? get order;
/// The parameter `periodStart` of this provider.
int? get periodStart;
/// The parameter `periodEnd` of this provider.
int? get periodEnd;
/// The parameter `orderDesc` of this provider.
bool get orderDesc;
}
class _PostListNotifierProviderElement
@@ -331,6 +439,18 @@ class _PostListNotifierProviderElement
@override
bool? get includeReplies =>
(origin as PostListNotifierProvider).includeReplies;
@override
bool? get mediaOnly => (origin as PostListNotifierProvider).mediaOnly;
@override
String? get queryTerm => (origin as PostListNotifierProvider).queryTerm;
@override
String? get order => (origin as PostListNotifierProvider).order;
@override
int? get periodStart => (origin as PostListNotifierProvider).periodStart;
@override
int? get periodEnd => (origin as PostListNotifierProvider).periodEnd;
@override
bool get orderDesc => (origin as PostListNotifierProvider).orderDesc;
}
// ignore_for_file: type=lint

View File

@@ -8,6 +8,7 @@ import 'package:island/models/publisher.dart';
import 'package:island/pods/network.dart';
import 'package:island/screens/creators/publishers_form.dart';
import 'package:island/screens/posts/compose.dart';
import 'package:island/services/event_bus.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/post/compose_sheet.dart';
@@ -61,6 +62,7 @@ class PostQuickReply extends HookConsumerWidget {
);
contentController.clear();
onPosted?.call();
eventBus.fire(PostCreatedEvent());
} catch (err) {
showErrorAlert(err);
} finally {

View File

@@ -542,6 +542,7 @@ class ReactionDetailsPopup extends HookConsumerWidget {
notifierRefreshable: provider.notifier,
contentBuilder:
(data, widgetCount, endItemView) => ListView.builder(
padding: EdgeInsets.zero,
itemCount: widgetCount,
itemBuilder: (context, index) {
if (index == widgetCount - 1) {

View File

@@ -10,6 +10,7 @@ import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:island/screens/posts/compose.dart';
import 'package:island/models/file.dart';
import 'package:island/widgets/post/compose_sheet.dart';
import 'package:island/pods/link_preview.dart';
import 'package:island/pods/network.dart';
import 'package:mime/mime.dart';
@@ -174,9 +175,9 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
attachments: attachments,
);
// Navigate to compose screen
// Show compose sheet
if (mounted) {
context.pushNamed('postCompose', extra: initialState);
PostComposeSheet.show(context, initialState: initialState);
Navigator.of(context).pop(); // Close the share sheet
}
} catch (e) {

View File

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