Compare commits

..

20 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
37 changed files with 2689 additions and 1716 deletions

View File

@@ -1309,5 +1309,15 @@
"presenceTypeGaming": "Playing", "presenceTypeGaming": "Playing",
"presenceTypeMusic": "Listening to Music", "presenceTypeMusic": "Listening to Music",
"presenceTypeWorkout": "Working out", "presenceTypeWorkout": "Working out",
"articleCompose": "Compose Article" "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": "签到", "checkIn": "签到",
"checkInNone": "尚未签到", "checkInNone": "尚未签到",
"checkInNoneHint": "通过签到获取您的财富提示和每日奖励。", "checkInNoneHint": "通过签到获取您的财富提示和每日奖励。",
"checkInResultLevel0": "最差运气", "checkInResultLevel0": "大凶",
"checkInResultLevel1": "坏运气", "checkInResultLevel1": "",
"checkInResultLevel2": "一个普通的日常", "checkInResultLevel2": "中平",
"checkInResultLevel3": "好运", "checkInResultLevel3": "",
"checkInResultLevel4": "最佳运气", "checkInResultLevel4": "大吉",
"checkInActivityTitle": "{} 在 {} 签到并获得了 {}", "checkInActivityTitle": "{} 在 {} 签到并获得了 {}",
"eventCalander": "活动日历", "eventCalander": "活动日历",
"eventCalanderEmpty": "该日无活动。", "eventCalanderEmpty": "该日无活动。",

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
/// @nodoc /// @nodoc
mixin _$SnPollWithStats { 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 /// Create a copy of SnPollWithStats
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -28,12 +28,12 @@ $SnPollWithStatsCopyWith<SnPollWithStats> get copyWith => _$SnPollWithStatsCopyW
@override @override
bool operator ==(Object other) { 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) @JsonKey(includeFromJson: false, includeToJson: false)
@override @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 @override
String toString() { String toString() {
@@ -48,11 +48,11 @@ abstract mixin class $SnPollWithStatsCopyWith<$Res> {
factory $SnPollWithStatsCopyWith(SnPollWithStats value, $Res Function(SnPollWithStats) _then) = _$SnPollWithStatsCopyWithImpl; factory $SnPollWithStatsCopyWith(SnPollWithStats value, $Res Function(SnPollWithStats) _then) = _$SnPollWithStatsCopyWithImpl;
@useResult @useResult
$Res call({ $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 /// @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,}) { @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( return _then(_self.copyWith(
userAnswer: freezed == userAnswer ? _self.userAnswer : userAnswer // ignore: cast_nullable_to_non_nullable 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 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 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 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?, 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) { switch (_that) {
case _SnPollWithStats() when $default != null: 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 _: 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) { switch (_that) {
case _SnPollWithStats(): 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);} 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) { switch (_that) {
case _SnPollWithStats() when $default != null: 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 _: 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() @JsonSerializable()
class _SnPollWithStats implements SnPollWithStats { 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); factory _SnPollWithStats.fromJson(Map<String, dynamic> json) => _$SnPollWithStatsFromJson(json);
final Map<String, dynamic>? _userAnswer; @override final SnPollAnswer? 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);
}
final Map<String, dynamic> _stats; final Map<String, dynamic> _stats;
@override@JsonKey() Map<String, dynamic> get stats { @override@JsonKey() Map<String, dynamic> get stats {
if (_stats is EqualUnmodifiableMapView) return _stats; if (_stats is EqualUnmodifiableMapView) return _stats;
@@ -261,12 +265,12 @@ Map<String, dynamic> toJson() {
@override @override
bool operator ==(Object other) { 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) @JsonKey(includeFromJson: false, includeToJson: false)
@override @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 @override
String toString() { String toString() {
@@ -281,11 +285,11 @@ abstract mixin class _$SnPollWithStatsCopyWith<$Res> implements $SnPollWithStats
factory _$SnPollWithStatsCopyWith(_SnPollWithStats value, $Res Function(_SnPollWithStats) _then) = __$SnPollWithStatsCopyWithImpl; factory _$SnPollWithStatsCopyWith(_SnPollWithStats value, $Res Function(_SnPollWithStats) _then) = __$SnPollWithStatsCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $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 /// @nodoc
@@ -300,8 +304,8 @@ class __$SnPollWithStatsCopyWithImpl<$Res>
/// with the given fields replaced by the non-null parameter values. /// 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,}) { @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( return _then(_SnPollWithStats(
userAnswer: freezed == userAnswer ? _self._userAnswer : userAnswer // ignore: cast_nullable_to_non_nullable 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 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 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 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 _$SnPollWithStatsFromJson(Map<String, dynamic> json) =>
_SnPollWithStats( _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 {}, stats: json['stats'] as Map<String, dynamic>? ?? const {},
id: json['id'] as String, id: json['id'] as String,
questions: questions:
@@ -32,7 +37,7 @@ _SnPollWithStats _$SnPollWithStatsFromJson(Map<String, dynamic> json) =>
Map<String, dynamic> _$SnPollWithStatsToJson(_SnPollWithStats instance) => Map<String, dynamic> _$SnPollWithStatsToJson(_SnPollWithStats instance) =>
<String, dynamic>{ <String, dynamic>{
'user_answer': instance.userAnswer, 'user_answer': instance.userAnswer?.toJson(),
'stats': instance.stats, 'stats': instance.stats,
'id': instance.id, 'id': instance.id,
'questions': instance.questions.map((e) => e.toJson()).toList(), 'questions': instance.questions.map((e) => e.toJson()).toList(),

View File

@@ -7,7 +7,7 @@ part of 'activity_rpc.dart';
// ************************************************************************** // **************************************************************************
String _$presenceActivitiesHash() => String _$presenceActivitiesHash() =>
r'dcea3cad01b4010c0087f5281413d83a754c2a17'; r'3bfaa638eeb961ecd62a32d6a7760a6a7e7bf6f2';
/// Copied from Dart SDK /// Copied from Dart SDK
class _SystemHash { class _SystemHash {

View File

@@ -8,11 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/screens/about.dart'; import 'package:island/screens/about.dart';
import 'package:island/screens/developers/app_detail.dart'; import 'package:island/screens/developers/app_detail.dart';
import 'package:island/screens/developers/bot_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/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/edit_project.dart';
import 'package:island/screens/developers/new_project.dart'; import 'package:island/screens/developers/new_project.dart';
import 'package:island/screens/discovery/articles.dart'; import 'package:island/screens/discovery/articles.dart';
@@ -570,25 +566,6 @@ final routerProvider = Provider<GoRouter>((ref) {
return const SizedBox.shrink(); // Temporary placeholder return const SizedBox.shrink(); // Temporary placeholder
}, },
routes: [ 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( GoRoute(
name: 'developerAppDetail', name: 'developerAppDetail',
path: 'apps/:appId', path: 'apps/:appId',
@@ -599,15 +576,6 @@ final routerProvider = Provider<GoRouter>((ref) {
appId: state.pathParameters['appId']!, appId: state.pathParameters['appId']!,
), ),
), ),
GoRoute(
name: 'developerBotNew',
path: 'bots/new',
builder:
(context, state) => NewBotScreen(
publisherName: state.pathParameters['name']!,
projectId: state.pathParameters['projectId']!,
),
),
GoRoute( GoRoute(
name: 'developerBotDetail', name: 'developerBotDetail',
path: 'bots/:botId', path: 'bots/:botId',
@@ -618,16 +586,6 @@ final routerProvider = Provider<GoRouter>((ref) {
botId: state.pathParameters['botId']!, 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

@@ -1,4 +1,5 @@
import "dart:async"; import "dart:async";
import "dart:math" as math;
import "package:easy_localization/easy_localization.dart"; import "package:easy_localization/easy_localization.dart";
import "package:file_picker/file_picker.dart"; import "package:file_picker/file_picker.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
@@ -140,6 +141,9 @@ class ChatRoomScreen extends HookConsumerWidget {
final messageController = useTextEditingController(); final messageController = useTextEditingController();
final scrollController = useScrollController(); final scrollController = useScrollController();
// Scroll animation notifiers
final bottomGradientNotifier = useState(ValueNotifier<double>(0.0));
final messageReplyingTo = useState<SnChatMessage?>(null); final messageReplyingTo = useState<SnChatMessage?>(null);
final messageForwardingTo = useState<SnChatMessage?>(null); final messageForwardingTo = useState<SnChatMessage?>(null);
final messageEditingTo = useState<SnChatMessage?>(null); final messageEditingTo = useState<SnChatMessage?>(null);
@@ -164,6 +168,12 @@ class ChatRoomScreen extends HookConsumerWidget {
isLoading = true; isLoading = true;
messagesNotifier.loadMore().then((_) => isLoading = false); 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); scrollController.addListener(onScroll);
@@ -589,7 +599,9 @@ class ChatRoomScreen extends HookConsumerWidget {
listController: listController, listController: listController,
padding: EdgeInsets.only( padding: EdgeInsets.only(
top: 16, top: 16,
bottom: MediaQuery.of(context).padding.bottom + 16, bottom:
MediaQuery.of(context).padding.bottom +
80, // Leave space for chat input
), ),
controller: scrollController, controller: scrollController,
reverse: true, // Show newest messages at the bottom reverse: true, // Show newest messages at the bottom
@@ -828,7 +840,7 @@ class ChatRoomScreen extends HookConsumerWidget {
), ),
body: Stack( body: Stack(
children: [ children: [
// Messages and Input in Column // Messages only in Column
Positioned.fill( Positioned.fill(
child: Column( child: Column(
children: [ 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 // Selection mode toolbar
if (isSelectionMode.value) if (isSelectionMode.value)
Positioned( Positioned(

View File

@@ -1,12 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/custom_app.dart'; import 'package:island/models/custom_app.dart';
import 'package:island/pods/network.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/alert.dart';
import 'package:island/widgets/content/cloud_files.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:island/widgets/response.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -68,12 +73,18 @@ class CustomAppsScreen extends HookConsumerWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: () { onPressed: () {
context.pushNamed( showModalBottomSheet(
'developerAppNew', context: context,
pathParameters: { isScrollControlled: true,
'name': publisherName, builder:
'projectId': projectId, (context) => SheetScaffold(
}, titleText: 'createCustomApp'.tr(),
child: NewCustomAppScreen(
publisherName: publisherName,
projectId: projectId,
isModal: true,
),
),
); );
}, },
icon: const Icon(Symbols.add), icon: const Icon(Symbols.add),
@@ -83,18 +94,44 @@ class CustomAppsScreen extends HookConsumerWidget {
), ),
); );
} }
return RefreshIndicator( return ExtendedRefreshIndicator(
onRefresh: onRefresh:
() => ref.refresh( () => ref.refresh(
customAppsProvider(publisherName, projectId).future, customAppsProvider(publisherName, projectId).future,
), ),
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( child: ListView.builder(
padding: EdgeInsets.only(top: 4), padding: EdgeInsets.zero,
itemCount: data.length, itemCount: data.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final app = data[index]; final app = data[index];
return Card( return Card(
margin: const EdgeInsets.all(8.0),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: InkWell( child: InkWell(
onTap: () { onTap: () {
@@ -138,7 +175,10 @@ class CustomAppsScreen extends HookConsumerWidget {
app.slug, app.slug,
style: GoogleFonts.robotoMono(fontSize: 12), style: GoogleFonts.robotoMono(fontSize: 12),
), ),
contentPadding: EdgeInsets.only(left: 20, right: 12), contentPadding: EdgeInsets.only(
left: 20,
right: 12,
),
trailing: PopupMenuButton( trailing: PopupMenuButton(
itemBuilder: itemBuilder:
(context) => [ (context) => [
@@ -163,7 +203,9 @@ class CustomAppsScreen extends HookConsumerWidget {
const SizedBox(width: 12), const SizedBox(width: 12),
Text( Text(
'delete', 'delete',
style: TextStyle(color: Colors.red), style: TextStyle(
color: Colors.red,
),
).tr(), ).tr(),
], ],
), ),
@@ -171,13 +213,19 @@ class CustomAppsScreen extends HookConsumerWidget {
], ],
onSelected: (value) { onSelected: (value) {
if (value == 'edit') { if (value == 'edit') {
context.pushNamed( showModalBottomSheet(
'developerAppEdit', context: context,
pathParameters: { isScrollControlled: true,
'name': publisherName, builder:
'projectId': projectId, (context) => SheetScaffold(
'id': app.id, titleText: 'editCustomApp'.tr(),
}, child: EditAppScreen(
publisherName: publisherName,
projectId: projectId,
id: app.id,
isModal: true,
),
),
); );
} else if (value == 'delete') { } else if (value == 'delete') {
showConfirmAlert( showConfirmAlert(
@@ -185,7 +233,9 @@ class CustomAppsScreen extends HookConsumerWidget {
'deleteCustomApp'.tr(), 'deleteCustomApp'.tr(),
).then((confirm) { ).then((confirm) {
if (confirm) { if (confirm) {
final client = ref.read(apiClientProvider); final client = ref.read(
apiClientProvider,
);
client.delete( client.delete(
'/develop/developers/$publisherName/projects/$projectId/apps/${app.id}', '/develop/developers/$publisherName/projects/$projectId/apps/${app.id}',
); );
@@ -207,6 +257,9 @@ class CustomAppsScreen extends HookConsumerWidget {
); );
}, },
), ),
),
],
),
); );
}, },
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),

View File

@@ -1,15 +1,20 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/bot.dart'; import 'package:island/models/bot.dart';
import 'package:island/pods/network.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/alert.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/response.dart'; import 'package:island/widgets/response.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/widgets/extended_refresh_indicator.dart'; import 'package:island/widgets/extended_refresh_indicator.dart';
import 'package:styled_widget/styled_widget.dart';
part 'bots.g.dart'; part 'bots.g.dart';
@@ -46,12 +51,18 @@ class BotsScreen extends HookConsumerWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: () { onPressed: () {
context.pushNamed( showModalBottomSheet(
'developerBotNew', context: context,
pathParameters: { isScrollControlled: true,
'name': publisherName, builder:
'projectId': projectId, (context) => SheetScaffold(
}, titleText: 'createBot'.tr(),
child: NewBotScreen(
publisherName: publisherName,
projectId: projectId,
isModal: true,
),
),
); );
}, },
icon: const Icon(Symbols.add), icon: const Icon(Symbols.add),
@@ -64,13 +75,39 @@ class BotsScreen extends HookConsumerWidget {
return ExtendedRefreshIndicator( return ExtendedRefreshIndicator(
onRefresh: onRefresh:
() => ref.refresh(botsProvider(publisherName, projectId).future), () => ref.refresh(botsProvider(publisherName, projectId).future),
child: Column(
children: [
const Gap(8),
Card(
child: ListTile(
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,
),
),
);
},
icon: const Icon(Symbols.add),
),
),
),
Expanded(
child: ListView.builder( child: ListView.builder(
padding: const EdgeInsets.only(top: 4), padding: EdgeInsets.zero,
itemCount: data.length, itemCount: data.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final bot = data[index]; final bot = data[index];
return Card( return Card(
margin: const EdgeInsets.all(8.0),
child: ListTile( child: ListTile(
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8.0)), borderRadius: BorderRadius.all(Radius.circular(8.0)),
@@ -102,7 +139,10 @@ class BotsScreen extends HookConsumerWidget {
value: 'delete', value: 'delete',
child: Row( child: Row(
children: [ children: [
const Icon(Symbols.delete, color: Colors.red), const Icon(
Symbols.delete,
color: Colors.red,
),
const SizedBox(width: 12), const SizedBox(width: 12),
Text( Text(
'delete', 'delete',
@@ -114,13 +154,19 @@ class BotsScreen extends HookConsumerWidget {
], ],
onSelected: (value) { onSelected: (value) {
if (value == 'edit') { if (value == 'edit') {
context.pushNamed( showModalBottomSheet(
'developerBotEdit', context: context,
pathParameters: { isScrollControlled: true,
'name': publisherName, builder:
'projectId': projectId, (context) => SheetScaffold(
'id': bot.id, titleText: 'editBot'.tr(),
}, child: EditBotScreen(
publisherName: publisherName,
projectId: projectId,
id: bot.id,
isModal: true,
),
),
); );
} else if (value == 'delete') { } else if (value == 'delete') {
showConfirmAlert( showConfirmAlert(
@@ -154,6 +200,9 @@ class BotsScreen extends HookConsumerWidget {
); );
}, },
), ),
),
],
),
); );
}, },
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),

View File

@@ -39,11 +39,13 @@ class EditAppScreen extends HookConsumerWidget {
final String publisherName; final String publisherName;
final String projectId; final String projectId;
final String? id; final String? id;
final bool isModal;
const EditAppScreen({ const EditAppScreen({
super.key, super.key,
required this.publisherName, required this.publisherName,
required this.projectId, required this.projectId,
this.id, this.id,
this.isModal = false,
}); });
@override @override
@@ -177,7 +179,12 @@ class EditAppScreen extends HookConsumerWidget {
children: [ children: [
TextFormField( TextFormField(
controller: scopeController, controller: scopeController,
decoration: InputDecoration(labelText: 'scopeName'.tr()), decoration: InputDecoration(
labelText: 'scopeName'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
FilledButton.tonalIcon( FilledButton.tonalIcon(
@@ -220,6 +227,9 @@ class EditAppScreen extends HookConsumerWidget {
hintText: 'https://example.com/auth/callback', hintText: 'https://example.com/auth/callback',
helperText: 'redirectUriHint'.tr(), helperText: 'redirectUriHint'.tr(),
helperMaxLines: 3, helperMaxLines: 3,
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
), ),
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
validator: (value) { validator: (value) {
@@ -316,12 +326,7 @@ class EditAppScreen extends HookConsumerWidget {
} }
} }
return AppScaffold( final bodyContent =
isNoBackground: false,
appBar: AppBar(
title: Text(isNew ? 'createCustomApp'.tr() : 'editCustomApp'.tr()),
),
body:
app == null && !isNew app == null && !isNew
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: app?.hasError == true && !isNew : app?.hasError == true && !isNew
@@ -382,11 +387,17 @@ class EditAppScreen extends HookConsumerWidget {
children: [ children: [
TextFormField( TextFormField(
controller: nameController, controller: nameController,
decoration: InputDecoration(labelText: 'name'.tr()), decoration: InputDecoration(
labelText: 'name'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
),
onTapOutside: onTapOutside:
(_) => (_) =>
FocusManager.instance.primaryFocus FocusManager.instance.primaryFocus?.unfocus(),
?.unfocus(),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
@@ -394,11 +405,15 @@ class EditAppScreen extends HookConsumerWidget {
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'slug'.tr(), labelText: 'slug'.tr(),
helperText: 'slugHint'.tr(), helperText: 'slugHint'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
), ),
onTapOutside: onTapOutside:
(_) => (_) =>
FocusManager.instance.primaryFocus FocusManager.instance.primaryFocus?.unfocus(),
?.unfocus(),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
@@ -406,12 +421,16 @@ class EditAppScreen extends HookConsumerWidget {
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'description'.tr(), labelText: 'description'.tr(),
alignLabelWithHint: true, alignLabelWithHint: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
), ),
maxLines: 3, maxLines: 3,
onTapOutside: onTapOutside:
(_) => (_) =>
FocusManager.instance.primaryFocus FocusManager.instance.primaryFocus?.unfocus(),
?.unfocus(),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
ExpansionPanelList( ExpansionPanelList(
@@ -438,6 +457,11 @@ class EditAppScreen extends HookConsumerWidget {
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'homePageUrl'.tr(), labelText: 'homePageUrl'.tr(),
hintText: 'https://example.com', hintText: 'https://example.com',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
), ),
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
), ),
@@ -446,6 +470,11 @@ class EditAppScreen extends HookConsumerWidget {
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'privacyPolicyUrl'.tr(), labelText: 'privacyPolicyUrl'.tr(),
hintText: 'https://example.com/privacy', hintText: 'https://example.com/privacy',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
), ),
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
), ),
@@ -454,6 +483,11 @@ class EditAppScreen extends HookConsumerWidget {
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'termsOfServiceUrl'.tr(), labelText: 'termsOfServiceUrl'.tr(),
hintText: 'https://example.com/terms', hintText: 'https://example.com/terms',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
), ),
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
), ),
@@ -463,9 +497,8 @@ class EditAppScreen extends HookConsumerWidget {
), ),
ExpansionPanel( ExpansionPanel(
headerBuilder: headerBuilder:
(context, isExpanded) => ListTile( (context, isExpanded) =>
title: Text('oauthConfig').tr(), ListTile(title: Text('oauthConfig').tr()),
),
body: Column( body: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -480,15 +513,11 @@ class EditAppScreen extends HookConsumerWidget {
(uri) => ListTile( (uri) => ListTile(
title: Text(uri), title: Text(uri),
trailing: IconButton( trailing: IconButton(
icon: const Icon( icon: const Icon(Symbols.delete),
Symbols.delete,
),
onPressed: () { onPressed: () {
redirectUris.value = redirectUris.value =
redirectUris.value redirectUris.value
.where( .where((u) => u != uri)
(u) => u != uri,
)
.toList(); .toList();
}, },
), ),
@@ -501,8 +530,9 @@ class EditAppScreen extends HookConsumerWidget {
title: Text('addRedirectUri'.tr()), title: Text('addRedirectUri'.tr()),
onTap: showAddRedirectUriDialog, onTap: showAddRedirectUriDialog,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: borderRadius: BorderRadius.circular(
BorderRadius.circular(8), 8,
),
), ),
), ),
], ],
@@ -520,9 +550,7 @@ class EditAppScreen extends HookConsumerWidget {
(scope) => ListTile( (scope) => ListTile(
title: Text(scope), title: Text(scope),
trailing: IconButton( trailing: IconButton(
icon: const Icon( icon: const Icon(Symbols.delete),
Symbols.delete,
),
onPressed: () { onPressed: () {
allowedScopes.value = allowedScopes.value =
allowedScopes.value allowedScopes.value
@@ -568,8 +596,7 @@ class EditAppScreen extends HookConsumerWidget {
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: TextButton.icon( child: TextButton.icon(
onPressed: onPressed: submitting.value ? null : performAction,
submitting.value ? null : performAction,
label: Text('saveChanges'.tr()), label: Text('saveChanges'.tr()),
icon: const Icon(Symbols.save), icon: const Icon(Symbols.save),
), ),
@@ -579,7 +606,18 @@ class EditAppScreen extends HookConsumerWidget {
), ),
], ],
), ),
);
if (isModal) {
return bodyContent;
}
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
title: Text(isNew ? 'createCustomApp'.tr() : 'editCustomApp'.tr()),
), ),
body: bodyContent,
); );
} }
} }

View File

@@ -38,11 +38,13 @@ class EditBotScreen extends HookConsumerWidget {
final String publisherName; final String publisherName;
final String projectId; final String projectId;
final String? id; final String? id;
final bool isModal;
const EditBotScreen({ const EditBotScreen({
super.key, super.key,
required this.publisherName, required this.publisherName,
required this.projectId, required this.projectId,
this.id, this.id,
this.isModal = false,
}); });
@override @override
@@ -191,10 +193,7 @@ class EditBotScreen extends HookConsumerWidget {
} }
} }
return AppScaffold( final bodyContent =
isNoBackground: false,
appBar: AppBar(title: Text(isNew ? 'createBot'.tr() : 'editBot'.tr())),
body:
botData == null && !isNew botData == null && !isNew
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: botData?.hasError == true && !isNew : botData?.hasError == true && !isNew
@@ -255,7 +254,14 @@ class EditBotScreen extends HookConsumerWidget {
children: [ children: [
TextFormField( TextFormField(
controller: nameController, controller: nameController,
decoration: InputDecoration(labelText: 'name'.tr()), decoration: InputDecoration(
labelText: 'name'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
@@ -263,6 +269,11 @@ class EditBotScreen extends HookConsumerWidget {
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'nickname'.tr(), labelText: 'nickname'.tr(),
alignLabelWithHint: true, alignLabelWithHint: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -271,6 +282,11 @@ class EditBotScreen extends HookConsumerWidget {
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'slug'.tr(), labelText: 'slug'.tr(),
helperText: 'slugHint'.tr(), helperText: 'slugHint'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -279,6 +295,11 @@ class EditBotScreen extends HookConsumerWidget {
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'bio'.tr(), labelText: 'bio'.tr(),
alignLabelWithHint: true, alignLabelWithHint: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
), ),
maxLines: 3, maxLines: 3,
), ),
@@ -291,6 +312,11 @@ class EditBotScreen extends HookConsumerWidget {
controller: firstNameController, controller: firstNameController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'firstName'.tr(), labelText: 'firstName'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
), ),
), ),
), ),
@@ -299,6 +325,11 @@ class EditBotScreen extends HookConsumerWidget {
controller: middleNameController, controller: middleNameController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'middleName'.tr(), labelText: 'middleName'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
), ),
), ),
), ),
@@ -307,6 +338,11 @@ class EditBotScreen extends HookConsumerWidget {
controller: lastNameController, controller: lastNameController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'lastName'.tr(), labelText: 'lastName'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
), ),
), ),
), ),
@@ -321,6 +357,11 @@ class EditBotScreen extends HookConsumerWidget {
controller: genderController, controller: genderController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'gender'.tr(), labelText: 'gender'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
), ),
), ),
), ),
@@ -329,6 +370,11 @@ class EditBotScreen extends HookConsumerWidget {
controller: pronounsController, controller: pronounsController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'pronouns'.tr(), labelText: 'pronouns'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
), ),
), ),
), ),
@@ -343,6 +389,11 @@ class EditBotScreen extends HookConsumerWidget {
controller: locationController, controller: locationController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'location'.tr(), labelText: 'location'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
), ),
), ),
), ),
@@ -351,6 +402,11 @@ class EditBotScreen extends HookConsumerWidget {
controller: timeZoneController, controller: timeZoneController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'timeZone'.tr(), labelText: 'timeZone'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
), ),
), ),
), ),
@@ -370,13 +426,14 @@ class EditBotScreen extends HookConsumerWidget {
} }
}, },
child: Container( child: Container(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( border: Border.all(
bottom: BorderSide(
color: Theme.of(context).dividerColor, color: Theme.of(context).dividerColor,
width: 1, width: 1,
), ),
borderRadius: BorderRadius.all(
Radius.circular(12),
), ),
), ),
child: Column( child: Column(
@@ -403,8 +460,7 @@ class EditBotScreen extends HookConsumerWidget {
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: TextButton.icon( child: TextButton.icon(
onPressed: onPressed: submitting.value ? null : performAction,
submitting.value ? null : performAction,
label: Text('saveChanges').tr(), label: Text('saveChanges').tr(),
icon: const Icon(Symbols.save), icon: const Icon(Symbols.save),
), ),
@@ -414,7 +470,16 @@ class EditBotScreen extends HookConsumerWidget {
), ),
], ],
), ),
), );
if (isModal) {
return bodyContent;
}
return AppScaffold(
isNoBackground: false,
appBar: AppBar(title: Text(isNew ? 'createBot'.tr() : 'editBot'.tr())),
body: bodyContent,
); );
} }
} }

View File

@@ -1,5 +1,3 @@
import 'dart:math';
import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -99,15 +97,6 @@ class DeveloperHubScreen extends HookConsumerWidget {
), ),
body: Column( body: Column(
children: [ children: [
if (currentProject.value == null)
...([
// Welcome Section
_WelcomeSection(currentDeveloper: currentDeveloper.value),
// Navigation Tabs
_NavigationTabs(),
]),
// Main Content // Main Content
if (currentProject.value != null) if (currentProject.value != null)
Expanded( Expanded(
@@ -195,162 +184,6 @@ class _ConsoleAppBar extends StatelessWidget implements PreferredSizeWidget {
} }
} }
// 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 // Main Content Section
class _MainContentSection extends HookConsumerWidget { class _MainContentSection extends HookConsumerWidget {
final SnDeveloper? currentDeveloper; final SnDeveloper? currentDeveloper;

View File

@@ -4,10 +4,20 @@ import 'package:island/screens/developers/edit_app.dart';
class NewCustomAppScreen extends StatelessWidget { class NewCustomAppScreen extends StatelessWidget {
final String publisherName; final String publisherName;
final String projectId; 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 @override
Widget build(BuildContext context) { 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:flutter/material.dart';
import 'package:island/screens/developers/edit_bot.dart'; import 'package:island/screens/developers/edit_bot.dart';
class NewBotScreen extends StatelessWidget { class NewBotScreen extends StatelessWidget {
final String publisherName; final String publisherName;
final String projectId; 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 @override
Widget build(BuildContext context) { 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:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/dev_project.dart'; import 'package:island/models/dev_project.dart';
import 'package:island/screens/developers/apps.dart'; import 'package:island/screens/developers/apps.dart';
import 'package:island/screens/developers/bots.dart'; import 'package:island/screens/developers/bots.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:styled_widget/styled_widget.dart';
class ProjectDetailView extends HookConsumerWidget { class ProjectDetailView extends HookConsumerWidget {
final String publisherName; final String publisherName;
@@ -54,6 +56,37 @@ class ProjectDetailView extends HookConsumerWidget {
label: Text('bots'.tr()), 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 { } else {
@@ -97,7 +131,7 @@ class ProjectDetailView extends HookConsumerWidget {
), ),
BotsScreen(publisherName: publisherName, projectId: project.id), BotsScreen(publisherName: publisherName, projectId: project.id),
], ],
), ).padding(horizontal: 8),
), ),
], ],
); );

View File

@@ -737,7 +737,7 @@ class ActivityListNotifier extends _$ActivityListNotifier
}; };
final response = await client.get( final response = await client.get(
'/sphere/activities', '/sphere/timeline',
queryParameters: queryParameters, queryParameters: queryParameters,
); );

View File

@@ -7,7 +7,7 @@ part of 'explore.dart';
// ************************************************************************** // **************************************************************************
String _$activityListNotifierHash() => String _$activityListNotifierHash() =>
r'a3ad3242f08139bef14a2f0fab6591ce8b3cb9f0'; r'77ffc7852feffa5438b56fa26123d453b7c310cf';
/// Copied from Dart SDK /// Copied from Dart SDK
class _SystemHash { class _SystemHash {

View File

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

View File

@@ -326,14 +326,34 @@ class _PublisherHeatmapWidget extends StatelessWidget {
class _PublisherCategoryTabWidget extends StatelessWidget { class _PublisherCategoryTabWidget extends StatelessWidget {
final TabController categoryTabController; 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}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Card( return Card(
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4), margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: TabBar( child: Column(
children: [
TabBar(
controller: categoryTabController, controller: categoryTabController,
dividerColor: Colors.transparent, dividerColor: Colors.transparent,
splashBorderRadius: const BorderRadius.all(Radius.circular(8)), splashBorderRadius: const BorderRadius.all(Radius.circular(8)),
@@ -343,6 +363,228 @@ class _PublisherCategoryTabWidget extends StatelessWidget {
Tab(text: 'postArticle'.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(),
),
),
),
),
],
),
],
),
),
],
],
),
); );
} }
} }
@@ -423,7 +665,18 @@ class PublisherProfileScreen extends HookConsumerWidget {
categoryTab.value = categoryTabController.index; 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 subscribing = useState(false);
final isPinnedExpanded = useState(true);
Future<void> subscribe() async { Future<void> subscribe() async {
final apiClient = ref.watch(apiClientProvider); final apiClient = ref.watch(apiClientProvider);
@@ -494,21 +747,66 @@ class PublisherProfileScreen extends HookConsumerWidget {
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
SliverGap(16), 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), SliverPostList(pubName: name, pinned: true),
],
SliverToBoxAdapter( SliverToBoxAdapter(
child: _PublisherCategoryTabWidget( child: _PublisherCategoryTabWidget(
categoryTabController: categoryTabController, categoryTabController: categoryTabController,
includeReplies: includeReplies,
mediaOnly: mediaOnly,
queryTerm: queryTerm,
order: order,
orderDesc: orderDesc,
periodStart: periodStart,
periodEnd: periodEnd,
showAdvancedFilters: showAdvancedFilters,
), ),
), ),
SliverPostList( SliverPostList(
key: ValueKey(categoryTab.value), key: ValueKey(
'${categoryTab.value}-${includeReplies.value}-${mediaOnly.value}-${queryTerm.value}-${order.value}-${orderDesc.value}-${periodStart.value}-${periodEnd.value}',
),
pubName: name, pubName: name,
pinned: false, pinned: false,
type: switch (categoryTab.value) { type:
1 => 0, categoryTab.value == 1
2 => 1, ? 0
_ => null, : (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( SliverGap(
MediaQuery.of(context).padding.bottom + 16, MediaQuery.of(context).padding.bottom + 16,
@@ -617,21 +915,60 @@ class PublisherProfileScreen extends HookConsumerWidget {
heatmap: heatmap, heatmap: heatmap,
).padding(vertical: 4), ).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), SliverPostList(pubName: name, pinned: true),
],
SliverToBoxAdapter( SliverToBoxAdapter(
child: _PublisherCategoryTabWidget( child: _PublisherCategoryTabWidget(
categoryTabController: categoryTabController, categoryTabController: categoryTabController,
includeReplies: includeReplies,
mediaOnly: mediaOnly,
queryTerm: queryTerm,
order: order,
orderDesc: orderDesc,
periodStart: periodStart,
periodEnd: periodEnd,
showAdvancedFilters: showAdvancedFilters,
), ),
), ),
SliverPostList( SliverPostList(
key: ValueKey(categoryTab.value), key: ValueKey(
'${categoryTab.value}-${includeReplies.value}-${mediaOnly.value}-${queryTerm.value}-${order.value}-${orderDesc.value}-${periodStart.value}-${periodEnd.value}',
),
pubName: name, pubName: name,
pinned: false, pinned: false,
type: switch (categoryTab.value) { type:
1 => 0, categoryTab.value == 1
2 => 1, ? 0
_ => null, : (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), SliverGap(MediaQuery.of(context).padding.bottom + 16),
], ],

View File

@@ -1,4 +1,5 @@
import "dart:convert"; import "dart:convert";
import "dart:math" as math;
import "package:dio/dio.dart"; import "package:dio/dio.dart";
import "package:easy_localization/easy_localization.dart"; import "package:easy_localization/easy_localization.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
@@ -57,6 +58,9 @@ class ThoughtScreen extends HookConsumerWidget {
final listController = useMemoized(() => ListController(), []); final listController = useMemoized(() => ListController(), []);
// Scroll animation notifiers
final bottomGradientNotifier = useState(ValueNotifier<double>(0.0));
// Update local thoughts when provider data changes // Update local thoughts when provider data changes
useEffect(() { useEffect(() {
thoughts.whenData((data) { thoughts.whenData((data) {
@@ -86,6 +90,20 @@ class ThoughtScreen extends HookConsumerWidget {
return null; return null;
}, [localThoughts.value.length, isStreaming.value]); }, [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 { void sendMessage() async {
if (messageController.text.trim().isEmpty) return; if (messageController.text.trim().isEmpty) return;
@@ -258,7 +276,10 @@ class ThoughtScreen extends HookConsumerWidget {
const Gap(8), const Gap(8),
], ],
), ),
body: Center( body: Stack(
children: [
// Thoughts list
Center(
child: Container( child: Container(
constraints: BoxConstraints(maxWidth: 640), constraints: BoxConstraints(maxWidth: 640),
child: Column( child: Column(
@@ -269,7 +290,12 @@ class ThoughtScreen extends HookConsumerWidget {
(thoughtList) => SuperListView.builder( (thoughtList) => SuperListView.builder(
listController: listController, listController: listController,
controller: scrollController, controller: scrollController,
padding: const EdgeInsets.only(top: 16, bottom: 16), padding: EdgeInsets.only(
top: 16,
bottom:
MediaQuery.of(context).padding.bottom +
80, // Leave space for thought input
),
reverse: true, reverse: true,
itemCount: itemCount:
localThoughts.value.length + localThoughts.value.length +
@@ -293,7 +319,8 @@ class ThoughtScreen extends HookConsumerWidget {
}, },
), ),
loading: loading:
() => const Center(child: CircularProgressIndicator()), () =>
const Center(child: CircularProgressIndicator()),
error: error:
(error, _) => ResponseErrorWidget( (error, _) => ResponseErrorWidget(
error: error, error: error,
@@ -309,15 +336,61 @@ class ThoughtScreen extends HookConsumerWidget {
), ),
), ),
), ),
ThoughtInput(
messageController: messageController,
isStreaming: isStreaming.value,
onSend: sendMessage,
),
], ],
), ),
), ),
), ),
// 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,
),
),
),
),
],
),
); );
} }
} }

View File

@@ -1,4 +1,5 @@
import "dart:convert"; import "dart:convert";
import "dart:math" as math;
import "package:dio/dio.dart"; import "package:dio/dio.dart";
import "package:easy_localization/easy_localization.dart"; import "package:easy_localization/easy_localization.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
@@ -54,6 +55,9 @@ class ThoughtSheet extends HookConsumerWidget {
final listController = useMemoized(() => ListController(), []); final listController = useMemoized(() => ListController(), []);
// Scroll animation notifiers
final bottomGradientNotifier = useState(ValueNotifier<double>(0.0));
// Scroll to bottom when thoughts change or streaming state changes // Scroll to bottom when thoughts change or streaming state changes
useEffect(() { useEffect(() {
if (localThoughts.value.isNotEmpty || isStreaming.value) { if (localThoughts.value.isNotEmpty || isStreaming.value) {
@@ -68,6 +72,20 @@ class ThoughtSheet extends HookConsumerWidget {
return null; return null;
}, [localThoughts.value.length, isStreaming.value]); }, [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 { void sendMessage() async {
if (messageController.text.trim().isEmpty) return; if (messageController.text.trim().isEmpty) return;
@@ -196,7 +214,10 @@ class ThoughtSheet extends HookConsumerWidget {
return SheetScaffold( return SheetScaffold(
titleText: currentTopic.value ?? 'aiThought'.tr(), titleText: currentTopic.value ?? 'aiThought'.tr(),
child: Center( child: Stack(
children: [
// Thoughts list
Center(
child: Container( child: Container(
constraints: BoxConstraints(maxWidth: 640), constraints: BoxConstraints(maxWidth: 640),
child: Column( child: Column(
@@ -205,10 +226,16 @@ class ThoughtSheet extends HookConsumerWidget {
child: SuperListView.builder( child: SuperListView.builder(
listController: listController, listController: listController,
controller: scrollController, controller: scrollController,
padding: const EdgeInsets.only(top: 16, bottom: 16), padding: EdgeInsets.only(
top: 16,
bottom:
MediaQuery.of(context).padding.bottom +
80, // Leave space for thought input
),
reverse: true, reverse: true,
itemCount: itemCount:
localThoughts.value.length + (isStreaming.value ? 1 : 0), localThoughts.value.length +
(isStreaming.value ? 1 : 0),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (isStreaming.value && index == 0) { if (isStreaming.value && index == 0) {
return ThoughtItem( return ThoughtItem(
@@ -218,7 +245,8 @@ class ThoughtSheet extends HookConsumerWidget {
streamingFunctionCalls: functionCalls.value, streamingFunctionCalls: functionCalls.value,
); );
} }
final thoughtIndex = isStreaming.value ? index - 1 : index; final thoughtIndex =
isStreaming.value ? index - 1 : index;
final thought = localThoughts.value[thoughtIndex]; final thought = localThoughts.value[thoughtIndex];
return ThoughtItem( return ThoughtItem(
thought: thought, thought: thought,
@@ -227,17 +255,63 @@ class ThoughtSheet extends HookConsumerWidget {
}, },
), ),
), ),
ThoughtInput( ],
),
),
),
// 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, messageController: messageController,
isStreaming: isStreaming.value, isStreaming: isStreaming.value,
onSend: sendMessage, onSend: sendMessage,
attachedMessages: attachedMessages, attachedMessages: attachedMessages,
attachedPosts: attachedPosts, attachedPosts: attachedPosts,
), ),
),
),
),
], ],
), ),
),
),
); );
} }
} }

View File

@@ -106,20 +106,6 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
child: NotificationCard(notification: notification), 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: () {}, onDismissed: () {},
dismissType: DismissType.onSwipe, dismissType: DismissType.onSwipe,
displayDuration: const Duration(seconds: 5), displayDuration: const Duration(seconds: 5),

View File

@@ -1,9 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/account.dart'; import 'package:island/models/account.dart';
import 'package:island/route.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
class NotificationCard extends HookConsumerWidget { class NotificationCard extends HookConsumerWidget {
final SnNotification notification; final SnNotification notification;
@@ -14,9 +17,28 @@ class NotificationCard extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final icon = Symbols.info; final icon = Symbols.info;
return Card( 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, elevation: 4,
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
color: Theme.of(context).colorScheme.surfaceContainer,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)), borderRadius: BorderRadius.all(Radius.circular(8)),
), ),
@@ -67,6 +89,7 @@ class NotificationCard extends HookConsumerWidget {
), ),
], ],
), ),
),
); );
} }
} }

View File

@@ -1,27 +1,18 @@
import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:ui'; import 'dart:ui';
import 'package:dismissible_page/dismissible_page.dart'; import 'package:dismissible_page/dismissible_page.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_blurhash/flutter_blurhash.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/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:gal/gal.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/pods/config.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/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:island/widgets/content/sensitive.dart';
import 'package:material_symbols_icons/symbols.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:styled_widget/styled_widget.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
@@ -47,13 +38,100 @@ class CloudFileList extends HookConsumerWidget {
}); });
double calculateAspectRatio() { double calculateAspectRatio() {
double total = 0; final ratios = <double>[];
for (var ratio in files.map((e) => e.fileMeta?['ratio'] ?? 1)) {
if (ratio is double) total += ratio; // Collect all valid ratios
if (ratio is String) total += double.parse(ratio); 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 @override
@@ -90,7 +168,7 @@ class CloudFileList extends HookConsumerWidget {
} }
if (!disableZoomIn) { if (!disableZoomIn) {
context.pushTransparentRoute( context.pushTransparentRoute(
CloudFileZoomIn(item: file, heroTag: heroTags[i]), CloudFileLightbox(item: file, heroTag: heroTags[i]),
rootNavigator: true, rootNavigator: true,
); );
} }
@@ -151,7 +229,7 @@ class CloudFileList extends HookConsumerWidget {
} }
if (!disableZoomIn) { if (!disableZoomIn) {
context.pushTransparentRoute( context.pushTransparentRoute(
CloudFileZoomIn(item: files.first, heroTag: heroTags.first), CloudFileLightbox(item: files.first, heroTag: heroTags.first),
rootNavigator: true, rootNavigator: true,
); );
} }
@@ -169,10 +247,7 @@ class CloudFileList extends HookConsumerWidget {
child: child:
isAudio isAudio
? widgetItem ? widgetItem
: AspectRatio( : IntrinsicWidth(child: IntrinsicHeight(child: widgetItem)),
aspectRatio: calculateAspectRatio(),
child: widgetItem,
),
); );
} }
@@ -188,15 +263,20 @@ class CloudFileList extends HookConsumerWidget {
aspectRatio: calculateAspectRatio(), aspectRatio: calculateAspectRatio(),
child: Padding( child: Padding(
padding: padding ?? EdgeInsets.zero, padding: padding ?? EdgeInsets.zero,
child: CarouselView( 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, itemSnapping: true,
itemExtent: math.min( itemExtent: itemExtent,
math.min(
MediaQuery.of(context).size.width * 0.75,
maxWidth * 0.75,
),
640,
),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(16)), borderRadius: const BorderRadius.all(Radius.circular(16)),
), ),
@@ -231,11 +311,13 @@ class CloudFileList extends HookConsumerWidget {
} }
if (!disableZoomIn) { if (!disableZoomIn) {
context.pushTransparentRoute( context.pushTransparentRoute(
CloudFileZoomIn(item: files[i], heroTag: heroTags[i]), CloudFileLightbox(item: files[i], heroTag: heroTags[i]),
rootNavigator: true, rootNavigator: true,
); );
} }
}, },
);
},
), ),
), ),
), ),
@@ -273,7 +355,7 @@ class CloudFileList extends HookConsumerWidget {
} }
if (!disableZoomIn) { if (!disableZoomIn) {
context.pushTransparentRoute( context.pushTransparentRoute(
CloudFileZoomIn( CloudFileLightbox(
item: files[index], item: files[index],
heroTag: heroTags[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 { class _CloudFileListEntry extends HookConsumerWidget {
final SnCloudFile file; final SnCloudFile file;
final String heroTag; final String heroTag;
@@ -535,15 +412,8 @@ class _CloudFileListEntry extends HookConsumerWidget {
final lockedByDS = dataSaving && !showDataSaving.value; final lockedByDS = dataSaving && !showDataSaving.value;
final lockedByMature = file.sensitiveMarks.isNotEmpty && !showMature.value; final lockedByMature = file.sensitiveMarks.isNotEmpty && !showMature.value;
final meta = file.fileMeta is Map ? file.fileMeta as Map : const {}; 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(); Widget bg = const SizedBox.shrink();
if (isImage) { if (isImage) {
@@ -551,7 +421,7 @@ class _CloudFileListEntry extends HookConsumerWidget {
bg = BlurHash(hash: meta['blur'] as String); bg = BlurHash(hash: meta['blur'] as String);
} else if (!lockedByDS && !lockedByMature) { } else if (!lockedByDS && !lockedByMature) {
bg = ImageFiltered( bg = ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), imageFilter: ImageFilter.blur(sigmaX: 50, sigmaY: 50),
child: CloudFileWidget( child: CloudFileWidget(
fit: BoxFit.cover, fit: BoxFit.cover,
item: file, item: file,
@@ -581,7 +451,9 @@ class _CloudFileListEntry extends HookConsumerWidget {
fit: fit, fit: fit,
useInternalGate: false, useInternalGate: false,
)) ))
: AspectRatio(aspectRatio: ratio, child: const SizedBox.shrink()); : IntrinsicWidth(
child: IntrinsicHeight(child: const SizedBox.shrink()),
);
Widget overlays; Widget overlays;
if (lockedByDS) { 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,7 +371,15 @@ class CloudFileWidget extends HookConsumerWidget {
} }
var content = switch (item.mimeType?.split('/').firstOrNull) { var content = switch (item.mimeType?.split('/').firstOrNull) {
'image' => AspectRatio( 'image' =>
ratio == 1.0
? IntrinsicHeight(
child:
(useInternalGate && dataSaving && !unlocked.value)
? dataPlaceHolder(Symbols.image)
: cloudImage(),
)
: AspectRatio(
aspectRatio: ratio, aspectRatio: ratio,
child: child:
(useInternalGate && dataSaving && !unlocked.value) (useInternalGate && dataSaving && !unlocked.value)

View File

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

View File

@@ -10,6 +10,8 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/pods/config.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/alert.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/markdown_latex.dart'; import 'package:island/widgets/content/markdown_latex.dart';
@@ -397,7 +399,13 @@ class MentionChipSpanNode extends SpanNode {
onTap: () => onTap(type, id), onTap: () => onTap(type, id),
borderRadius: BorderRadius.circular(32), borderRadius: BorderRadius.circular(32),
child: Container( 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( decoration: BoxDecoration(
color: backgroundColor.withOpacity(0.1), color: backgroundColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(32), borderRadius: BorderRadius.circular(32),
@@ -411,18 +419,58 @@ class MentionChipSpanNode extends SpanNode {
color: backgroundColor.withOpacity(0.5), color: backgroundColor.withOpacity(0.5),
borderRadius: const BorderRadius.all(Radius.circular(32)), borderRadius: const BorderRadius.all(Radius.circular(32)),
), ),
child: Icon( child: switch (parts.length == 1 ? 'u' : parts.first) {
switch (parts.first.isEmpty ? '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, 'c' => Symbols.forum_rounded,
'r' => Symbols.group_rounded, 'r' => Symbols.group_rounded,
'u' => Symbols.person_rounded,
'p' => Symbols.edit_rounded,
_ => Symbols.person_rounded, _ => Symbols.person_rounded,
}, }),
size: 14, size: 14,
color: foregroundColor, color: foregroundColor,
fill: 1, fill: 1,
).padding(all: 2), ).padding(all: 2),
},
), ),
Text( Text(
parts.last, parts.last,

View File

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

View File

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

View File

@@ -14,12 +14,14 @@ class ComposeToolbar extends HookConsumerWidget {
final ComposeState state; final ComposeState state;
final SnPost? originalPost; final SnPost? originalPost;
final bool isCompact; final bool isCompact;
final bool useSafeArea;
const ComposeToolbar({ const ComposeToolbar({
super.key, super.key,
required this.state, required this.state,
this.originalPost, this.originalPost,
this.isCompact = false, this.isCompact = false,
this.useSafeArea = false,
}); });
@override @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

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

View File

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

View File

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

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 # 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 # 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. # 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: environment:
sdk: ^3.7.2 sdk: ^3.7.2