Compare commits

...

16 Commits

27 changed files with 1729 additions and 848 deletions

View File

@@ -1310,5 +1310,14 @@
"presenceTypeMusic": "Listening to Music", "presenceTypeMusic": "Listening to Music",
"presenceTypeWorkout": "Working out", "presenceTypeWorkout": "Working out",
"articleCompose": "Compose Article", "articleCompose": "Compose Article",
"backToHub": "Back to Hub" "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

@@ -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,40 +70,12 @@ class NotificationService: UNNotificationServiceExtension {
let handle = INPersonHandle(value: "\(metaCopy["user_id"] ?? "")", type: .unknown) let handle = INPersonHandle(value: "\(metaCopy["user_id"] ?? "")", type: .unknown)
if let pfpUrl = pfpUrl, let url = URL(string: pfpUrl) { let completeNotificationProcessing: (Data?) -> Void = { imageData in
let targetSize = 512
let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit)
KingfisherManager.shared.retrieveImage(with: url, options: [.processor(scaleProcessor)], completionHandler: { result in
var image: Data?
switch result {
case .success(let value):
image = value.image.pngData()
case .failure(let error):
print("Unable to get pfp url: \(error)")
}
let sender = INPerson(
personHandle: handle,
nameComponents: PersonNameComponents(nickname: "\(metaCopy["sender_name"] ?? "")"),
displayName: content.title,
image: image == nil ? nil : INImage(imageData: image!),
contactIdentifier: nil,
customIdentifier: nil
)
let intent = self.createMessageIntent(with: sender, meta: metaCopy, body: content.body)
self.donateInteraction(for: intent)
content.categoryIdentifier = "CHAT_MESSAGE"
self.contentHandler?(content)
})
} else {
let sender = INPerson( let sender = INPerson(
personHandle: handle, personHandle: handle,
nameComponents: PersonNameComponents(nickname: "\(metaCopy["sender_name"] ?? "")"), nameComponents: PersonNameComponents(nickname: "\(metaCopy["sender_name"] ?? "")"),
displayName: content.title, displayName: content.title,
image: nil, image: imageData == nil ? nil : INImage(imageData: imageData!),
contactIdentifier: nil, contactIdentifier: nil,
customIdentifier: nil customIdentifier: nil
) )
@@ -105,8 +83,37 @@ class NotificationService: UNNotificationServiceExtension {
let intent = self.createMessageIntent(with: sender, meta: metaCopy, body: content.body) let intent = self.createMessageIntent(with: sender, meta: metaCopy, body: content.body)
self.donateInteraction(for: intent) self.donateInteraction(for: intent)
content.categoryIdentifier = "CHAT_MESSAGE" if let updatedContent = try? request.content.updating(from: intent) {
self.contentHandler?(content) if let mutableContent = updatedContent.mutableCopy() as? UNMutableNotificationContent {
mutableContent.categoryIdentifier = "CHAT_MESSAGE"
self.contentHandler?(mutableContent)
} else {
self.contentHandler?(updatedContent)
}
} else {
content.categoryIdentifier = "CHAT_MESSAGE"
self.contentHandler?(content)
}
}
if let pfpUrl = pfpUrl, let url = URL(string: pfpUrl) {
let targetSize = 512
let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit)
KingfisherManager.shared.retrieveImage(with: url, options: [
.processor(scaleProcessor)
], completionHandler: { result in
var image: Data?
switch result {
case .success(let value):
image = value.image.pngData()
case .failure(let error):
print("Unable to get pfp url: \(error)")
}
completeNotificationProcessing(image)
})
} else {
completeNotificationProcessing(nil)
} }
} }

View File

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

@@ -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,21 +326,263 @@ 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(
controller: categoryTabController, children: [
dividerColor: Colors.transparent, TabBar(
splashBorderRadius: const BorderRadius.all(Radius.circular(8)), controller: categoryTabController,
tabs: [ dividerColor: Colors.transparent,
Tab(text: 'all'.tr()), splashBorderRadius: const BorderRadius.all(Radius.circular(8)),
Tab(text: 'postTypePost'.tr()), tabs: [
Tab(text: 'postArticle'.tr()), Tab(text: 'all'.tr()),
Tab(text: 'postTypePost'.tr()),
Tab(text: 'postArticle'.tr()),
],
),
const Divider(height: 1),
Column(
children: [
Row(
children: [
Expanded(
child: CheckboxListTile(
title: Text('reply'.tr()),
value: includeReplies.value,
tristate: true,
onChanged: (value) {
// Cycle through: null -> false -> true -> null
if (includeReplies.value == null) {
includeReplies.value = false;
} else if (includeReplies.value == false) {
includeReplies.value = true;
} else {
includeReplies.value = null;
}
},
dense: true,
controlAffinity: ListTileControlAffinity.leading,
secondary: const Icon(Symbols.reply),
),
),
Expanded(
child: CheckboxListTile(
title: Text('attachments'.tr()),
value: mediaOnly.value,
onChanged: (value) {
if (value != null) {
mediaOnly.value = value;
}
},
dense: true,
controlAffinity: ListTileControlAffinity.leading,
secondary: const Icon(Symbols.attachment),
),
),
],
),
CheckboxListTile(
title: Text('descendingOrder'.tr()),
value: orderDesc.value,
onChanged: (value) {
if (value != null) {
orderDesc.value = value;
}
},
dense: true,
controlAffinity: ListTileControlAffinity.leading,
secondary: const Icon(Symbols.sort),
),
],
),
const Divider(height: 1),
ListTile(
title: Text('advancedFilters'.tr()),
leading: const Icon(Symbols.filter_list),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(const Radius.circular(8)),
),
trailing: Icon(
showAdvancedFilters.value
? Symbols.expand_less
: Symbols.expand_more,
),
onTap: () {
showAdvancedFilters.value = !showAdvancedFilters.value;
},
),
if (showAdvancedFilters.value) ...[
const Divider(height: 1),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
decoration: InputDecoration(
labelText: 'search'.tr(),
hintText: 'searchPosts'.tr(),
prefixIcon: const Icon(Symbols.search),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
onChanged: (value) {
queryTerm.value = value.isEmpty ? null : value;
},
),
const Gap(12),
DropdownButtonFormField<String>(
decoration: InputDecoration(
labelText: 'sortBy'.tr(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
value: order.value,
items: [
DropdownMenuItem(value: 'date', child: Text('date'.tr())),
DropdownMenuItem(
value: 'popularity',
child: Text('popularity'.tr()),
),
],
onChanged: (value) {
order.value = value;
},
),
const Gap(12),
Row(
children: [
Expanded(
child: InkWell(
onTap: () async {
final pickedDate = await showDatePicker(
context: context,
initialDate:
periodStart.value != null
? DateTime.fromMillisecondsSinceEpoch(
periodStart.value! * 1000,
)
: DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime.now().add(
const Duration(days: 365),
),
);
if (pickedDate != null) {
periodStart.value =
pickedDate.millisecondsSinceEpoch ~/ 1000;
}
},
child: InputDecorator(
decoration: InputDecoration(
labelText: 'fromDate'.tr(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
suffixIcon: const Icon(Symbols.calendar_today),
),
child: Text(
periodStart.value != null
? DateTime.fromMillisecondsSinceEpoch(
periodStart.value! * 1000,
).toString().split(' ')[0]
: 'selectDate'.tr(),
),
),
),
),
const Gap(8),
Expanded(
child: InkWell(
onTap: () async {
final pickedDate = await showDatePicker(
context: context,
initialDate:
periodEnd.value != null
? DateTime.fromMillisecondsSinceEpoch(
periodEnd.value! * 1000,
)
: DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime.now().add(
const Duration(days: 365),
),
);
if (pickedDate != null) {
periodEnd.value =
pickedDate.millisecondsSinceEpoch ~/ 1000;
}
},
child: InputDecorator(
decoration: InputDecoration(
labelText: 'toDate'.tr(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
suffixIcon: const Icon(Symbols.calendar_today),
),
child: Text(
periodEnd.value != null
? DateTime.fromMillisecondsSinceEpoch(
periodEnd.value! * 1000,
).toString().split(' ')[0]
: 'selectDate'.tr(),
),
),
),
),
],
),
],
),
),
],
], ],
), ),
); );
@@ -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),
SliverPostList(pubName: name, pinned: true), SliverToBoxAdapter(
child: Card(
margin: EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: ListTile(
title: Text('pinnedPosts'.tr()),
leading: const Icon(Symbols.push_pin),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(
Radius.circular(8),
),
),
trailing: Icon(
isPinnedExpanded.value
? Symbols.expand_less
: Symbols.expand_more,
),
onTap:
() =>
isPinnedExpanded.value =
!isPinnedExpanded.value,
),
),
),
...[
if (isPinnedExpanded.value)
SliverPostList(pubName: name, pinned: true),
],
SliverToBoxAdapter( 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),
), ),
SliverPostList(pubName: name, pinned: true), SliverToBoxAdapter(
child: Card(
margin: EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: ListTile(
title: Text('pinnedPosts'.tr()),
trailing: Icon(
isPinnedExpanded.value
? Symbols.expand_less
: Symbols.expand_more,
),
onTap:
() =>
isPinnedExpanded.value =
!isPinnedExpanded.value,
),
),
),
...[
if (isPinnedExpanded.value)
SliverPostList(pubName: name, pinned: true),
],
SliverToBoxAdapter( 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,65 +276,120 @@ class ThoughtScreen extends HookConsumerWidget {
const Gap(8), const Gap(8),
], ],
), ),
body: Center( body: Stack(
child: Container( children: [
constraints: BoxConstraints(maxWidth: 640), // Thoughts list
child: Column( Center(
children: [ child: Container(
Expanded( constraints: BoxConstraints(maxWidth: 640),
child: thoughts.when( child: Column(
data: children: [
(thoughtList) => SuperListView.builder( Expanded(
listController: listController, child: thoughts.when(
controller: scrollController, data:
padding: const EdgeInsets.only(top: 16, bottom: 16), (thoughtList) => SuperListView.builder(
reverse: true, listController: listController,
itemCount: controller: scrollController,
localThoughts.value.length + padding: EdgeInsets.only(
(isStreaming.value ? 1 : 0), top: 16,
itemBuilder: (context, index) { bottom:
if (isStreaming.value && index == 0) { MediaQuery.of(context).padding.bottom +
return ThoughtItem( 80, // Leave space for thought input
isStreaming: true, ),
streamingText: streamingText.value, reverse: true,
reasoningChunks: reasoningChunks.value, itemCount:
streamingFunctionCalls: functionCalls.value, localThoughts.value.length +
); (isStreaming.value ? 1 : 0),
} itemBuilder: (context, index) {
final thoughtIndex = if (isStreaming.value && index == 0) {
isStreaming.value ? index - 1 : index; return ThoughtItem(
final thought = localThoughts.value[thoughtIndex]; isStreaming: true,
return ThoughtItem( streamingText: streamingText.value,
thought: thought, reasoningChunks: reasoningChunks.value,
thoughtIndex: thoughtIndex, streamingFunctionCalls: functionCalls.value,
); );
}, }
final thoughtIndex =
isStreaming.value ? index - 1 : index;
final thought = localThoughts.value[thoughtIndex];
return ThoughtItem(
thought: thought,
thoughtIndex: thoughtIndex,
);
},
),
loading:
() =>
const Center(child: CircularProgressIndicator()),
error:
(error, _) => ResponseErrorWidget(
error: error,
onRetry:
() =>
selectedSequenceId.value != null
? ref.invalidate(
thoughtSequenceProvider(
selectedSequenceId.value!,
),
)
: null,
),
),
),
],
),
),
),
// Bottom gradient - appears when scrolling towards newer thoughts (behind thought input)
AnimatedBuilder(
animation: bottomGradientNotifier.value,
builder:
(context, child) => Positioned(
left: 0,
right: 0,
bottom: 0,
child: Opacity(
opacity: bottomGradientNotifier.value.value,
child: Container(
height: math.min(
MediaQuery.of(context).size.height * 0.1,
128,
), ),
loading: decoration: BoxDecoration(
() => const Center(child: CircularProgressIndicator()), gradient: LinearGradient(
error: begin: Alignment.bottomCenter,
(error, _) => ResponseErrorWidget( end: Alignment.topCenter,
error: error, colors: [
onRetry: Theme.of(
() => context,
selectedSequenceId.value != null ).colorScheme.surfaceContainer.withOpacity(0.8),
? ref.invalidate( Theme.of(
thoughtSequenceProvider( context,
selectedSequenceId.value!, ).colorScheme.surfaceContainer.withOpacity(0.0),
), ],
) ),
: null,
), ),
),
),
),
),
// Thought Input positioned above gradient (higher z-index)
Positioned(
left: 0,
right: 0,
bottom: 0, // At the very bottom, above gradient
child: Center(
child: Container(
constraints: BoxConstraints(maxWidth: 640),
child: ThoughtInput(
messageController: messageController,
isStreaming: isStreaming.value,
onSend: sendMessage,
), ),
), ),
ThoughtInput( ),
messageController: messageController,
isStreaming: isStreaming.value,
onSend: sendMessage,
),
],
), ),
), ],
), ),
); );
} }

View File

@@ -1,4 +1,5 @@
import "dart:convert"; import "dart: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,47 +214,103 @@ class ThoughtSheet extends HookConsumerWidget {
return SheetScaffold( return SheetScaffold(
titleText: currentTopic.value ?? 'aiThought'.tr(), titleText: currentTopic.value ?? 'aiThought'.tr(),
child: Center( child: Stack(
child: Container( children: [
constraints: BoxConstraints(maxWidth: 640), // Thoughts list
child: Column( Center(
children: [ child: Container(
Expanded( constraints: BoxConstraints(maxWidth: 640),
child: SuperListView.builder( child: Column(
listController: listController, children: [
controller: scrollController, Expanded(
padding: const EdgeInsets.only(top: 16, bottom: 16), child: SuperListView.builder(
reverse: true, listController: listController,
itemCount: controller: scrollController,
localThoughts.value.length + (isStreaming.value ? 1 : 0), padding: EdgeInsets.only(
itemBuilder: (context, index) { top: 16,
if (isStreaming.value && index == 0) { bottom:
return ThoughtItem( MediaQuery.of(context).padding.bottom +
isStreaming: true, 80, // Leave space for thought input
streamingText: streamingText.value, ),
reasoningChunks: reasoningChunks.value, reverse: true,
streamingFunctionCalls: functionCalls.value, itemCount:
); localThoughts.value.length +
} (isStreaming.value ? 1 : 0),
final thoughtIndex = isStreaming.value ? index - 1 : index; itemBuilder: (context, index) {
final thought = localThoughts.value[thoughtIndex]; if (isStreaming.value && index == 0) {
return ThoughtItem( return ThoughtItem(
thought: thought, isStreaming: true,
thoughtIndex: thoughtIndex, streamingText: streamingText.value,
); reasoningChunks: reasoningChunks.value,
}, streamingFunctionCalls: functionCalls.value,
);
}
final thoughtIndex =
isStreaming.value ? index - 1 : index;
final thought = localThoughts.value[thoughtIndex];
return ThoughtItem(
thought: thought,
thoughtIndex: thoughtIndex,
);
},
),
),
],
),
),
),
// Bottom gradient - appears when scrolling towards newer thoughts (behind thought input)
AnimatedBuilder(
animation: bottomGradientNotifier.value,
builder:
(context, child) => Positioned(
left: 0,
right: 0,
bottom: 0,
child: Opacity(
opacity: bottomGradientNotifier.value.value,
child: Container(
height: math.min(
MediaQuery.of(context).size.height * 0.1,
128,
),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Theme.of(
context,
).colorScheme.surfaceContainer.withOpacity(0.8),
Theme.of(
context,
).colorScheme.surfaceContainer.withOpacity(0.0),
],
),
),
),
),
),
),
// Thought Input positioned above gradient (higher z-index)
Positioned(
left: 0,
right: 0,
bottom: 0, // At the very bottom, above gradient
child: Center(
child: Container(
constraints: BoxConstraints(maxWidth: 640),
child: ThoughtInput(
messageController: messageController,
isStreaming: isStreaming.value,
onSend: sendMessage,
attachedMessages: attachedMessages,
attachedPosts: attachedPosts,
), ),
), ),
ThoughtInput( ),
messageController: messageController,
isStreaming: isStreaming.value,
onSend: sendMessage,
attachedMessages: attachedMessages,
attachedPosts: attachedPosts,
),
],
), ),
), ],
), ),
); );
} }

View File

@@ -106,20 +106,6 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
child: NotificationCard(notification: notification), 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,58 +17,78 @@ 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(
elevation: 4, onTap: () {
margin: const EdgeInsets.only(bottom: 8), if (notification.meta['action_uri'] != null) {
shape: const RoundedRectangleBorder( var uri = notification.meta['action_uri'] as String;
borderRadius: BorderRadius.all(Radius.circular(8)), if (uri.startsWith('solian://')) {
), uri = uri.replaceFirst('solian://', '');
child: Column( }
crossAxisAlignment: CrossAxisAlignment.start, if (uri.startsWith('/')) {
mainAxisSize: MainAxisSize.min, // In-app routes
children: [ rootNavigatorKey.currentContext?.push(
Padding( notification.meta['action_uri'],
padding: const EdgeInsets.all(12), );
child: Row( } else {
crossAxisAlignment: CrossAxisAlignment.start, // External URLs
children: [ launchUrlString(uri);
if (notification.meta['pfp'] != null) }
ProfilePictureWidget( }
fileId: notification.meta['pfp'], },
radius: 12, child: Card(
).padding(right: 12, top: 2) elevation: 4,
else margin: const EdgeInsets.only(bottom: 8),
Icon( color: Theme.of(context).colorScheme.surfaceContainer,
icon, shape: const RoundedRectangleBorder(
color: Theme.of(context).colorScheme.primary, borderRadius: BorderRadius.all(Radius.circular(8)),
size: 24, ),
).padding(right: 12), child: Column(
Expanded( crossAxisAlignment: CrossAxisAlignment.start,
child: Column( mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ Padding(
Text( padding: const EdgeInsets.all(12),
notification.title, child: Row(
style: Theme.of(context).textTheme.titleMedium crossAxisAlignment: CrossAxisAlignment.start,
?.copyWith(fontWeight: FontWeight.bold), children: [
), if (notification.meta['pfp'] != null)
if (notification.content.isNotEmpty) ProfilePictureWidget(
fileId: notification.meta['pfp'],
radius: 12,
).padding(right: 12, top: 2)
else
Icon(
icon,
color: Theme.of(context).colorScheme.primary,
size: 24,
).padding(right: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text( Text(
notification.content, notification.title,
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
), ),
if (notification.subtitle.isNotEmpty) if (notification.content.isNotEmpty)
Text( Text(
notification.subtitle, notification.content,
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodyMedium,
), ),
], if (notification.subtitle.isNotEmpty)
Text(
notification.subtitle,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
), ),
), ],
], ),
), ),
), ],
], ),
), ),
); );
} }

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,53 +263,60 @@ class CloudFileList extends HookConsumerWidget {
aspectRatio: calculateAspectRatio(), aspectRatio: calculateAspectRatio(),
child: Padding( child: Padding(
padding: padding ?? EdgeInsets.zero, padding: padding ?? EdgeInsets.zero,
child: CarouselView( child: LayoutBuilder(
itemSnapping: true, builder: (context, constraints) {
itemExtent: math.min( final availableWidth =
math.min( constraints.maxWidth.isFinite
MediaQuery.of(context).size.width * 0.75, ? constraints.maxWidth
maxWidth * 0.75, : MediaQuery.of(context).size.width;
), final itemExtent = math.min(
640, math.min(availableWidth * 0.75, maxWidth * 0.75).toDouble(),
), 640.0,
shape: RoundedRectangleBorder( );
borderRadius: const BorderRadius.all(Radius.circular(16)),
), return CarouselView(
children: [ itemSnapping: true,
for (var i = 0; i < files.length; i++) itemExtent: itemExtent,
Stack( shape: RoundedRectangleBorder(
children: [ borderRadius: const BorderRadius.all(Radius.circular(16)),
_CloudFileListEntry(
file: files[i],
heroTag: heroTags[i],
isImage:
files[i].mimeType?.startsWith('image') ?? false,
disableZoomIn: disableZoomIn,
),
Positioned(
bottom: 12,
left: 16,
child: Text('${i + 1}/${files.length}')
.textColor(Colors.white)
.textShadow(
color: Colors.black54,
offset: Offset(1, 1),
blurRadius: 3,
),
),
],
), ),
], children: [
onTap: (i) { for (var i = 0; i < files.length; i++)
if (!(files[i].mimeType?.startsWith('image') ?? false)) { Stack(
return; children: [
} _CloudFileListEntry(
if (!disableZoomIn) { file: files[i],
context.pushTransparentRoute( heroTag: heroTags[i],
CloudFileZoomIn(item: files[i], heroTag: heroTags[i]), isImage:
rootNavigator: true, files[i].mimeType?.startsWith('image') ?? false,
); disableZoomIn: disableZoomIn,
} ),
Positioned(
bottom: 12,
left: 16,
child: Text('${i + 1}/${files.length}')
.textColor(Colors.white)
.textShadow(
color: Colors.black54,
offset: Offset(1, 1),
blurRadius: 3,
),
),
],
),
],
onTap: (i) {
if (!(files[i].mimeType?.startsWith('image') ?? false)) {
return;
}
if (!disableZoomIn) {
context.pushTransparentRoute(
CloudFileLightbox(item: files[i], heroTag: heroTags[i]),
rootNavigator: true,
);
}
},
);
}, },
), ),
), ),
@@ -273,7 +355,7 @@ class CloudFileList extends HookConsumerWidget {
} }
if (!disableZoomIn) { 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,13 +371,21 @@ class CloudFileWidget extends HookConsumerWidget {
} }
var content = switch (item.mimeType?.split('/').firstOrNull) { var content = switch (item.mimeType?.split('/').firstOrNull) {
'image' => AspectRatio( 'image' =>
aspectRatio: ratio, ratio == 1.0
child: ? IntrinsicHeight(
(useInternalGate && dataSaving && !unlocked.value) child:
? dataPlaceHolder(Symbols.image) (useInternalGate && dataSaving && !unlocked.value)
: cloudImage(), ? dataPlaceHolder(Symbols.image)
), : cloudImage(),
)
: AspectRatio(
aspectRatio: ratio,
child:
(useInternalGate && dataSaving && !unlocked.value)
? dataPlaceHolder(Symbols.image)
: cloudImage(),
),
'video' => AspectRatio( 'video' => AspectRatio(
aspectRatio: ratio, aspectRatio: ratio,
child: child:

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(
'c' => Symbols.forum_rounded, builder: (context, ref, _) {
'r' => Symbols.group_rounded, final userData = ref.watch(accountProvider(parts.last));
'u' => Symbols.person_rounded, return userData.when(
'p' => Symbols.edit_rounded, data:
_ => Symbols.person_rounded, (data) => ProfilePictureWidget(
}, file: data.profile.picture,
size: 14, fallbackIcon: Symbols.person_rounded,
color: foregroundColor, radius: 9,
fill: 1, ),
).padding(all: 2), error: (_, _) => const Icon(Symbols.close),
loading:
() => const SizedBox(
width: 9,
height: 9,
child: CircularProgressIndicator(),
),
);
},
),
'p' => Consumer(
builder: (context, ref, _) {
final pubData = ref.watch(publisherProvider(parts.last));
return pubData.when(
data:
(data) => ProfilePictureWidget(
file: data?.picture,
fallbackIcon: Symbols.design_services_rounded,
radius: 9,
),
error: (_, _) => const Icon(Symbols.close),
loading:
() => const SizedBox(
width: 9,
height: 9,
child: CircularProgressIndicator(),
),
);
},
),
_ => Icon(
(switch (parts.length == 1 ? 'u' : parts.first) {
'c' => Symbols.forum_rounded,
'r' => Symbols.group_rounded,
_ => Symbols.person_rounded,
}),
size: 14,
color: foregroundColor,
fill: 1,
).padding(all: 2),
},
), ),
Text( 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,23 +77,25 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
} }
} }
void _initializeFromPollData(SnPollWithStats poll) {
// Initialize answers from poll data if available
if (poll.userAnswer != null && poll.userAnswer!.answer.isNotEmpty) {
_answers = Map<String, dynamic>.from(poll.userAnswer!.answer);
if (!widget.isReadonly && !_isModifying) {
_isModifying = false; // Show modify button if user has answered
}
}
_loadCurrentIntoLocalState();
}
@override @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() _isModifying = false;
..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;
}
} }
} }
@@ -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,111 +708,156 @@ 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 const SizedBox.shrink();
}
// If collapsed, show collapsed view for all states return pollAsync.when(
if (_isCollapsed) { loading:
return _buildCollapsedView(context); () => const Center(
} child: Padding(
padding: EdgeInsets.all(16.0),
// If poll is already submitted and not in readonly mode, and not in modification mode, show submitted view child: CircularProgressIndicator(),
if (widget.initialAnswers != null && !widget.isReadonly && !_isModifying) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildCollapsedView(context),
const SizedBox(height: 8),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, anim) {
final offset = Tween<Offset>(
begin: const Offset(0, -0.1),
end: Offset.zero,
).animate(CurvedAnimation(parent: anim, curve: Curves.easeOut));
final fade = CurvedAnimation(parent: anim, curve: Curves.easeOut);
return FadeTransition(
opacity: fade,
child: SlideTransition(position: offset, child: child),
);
},
child: Column(
key: const ValueKey('submitted_expanded'),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [_buildSubmittedView(context), _buildNavBar(context)],
), ),
), ),
], error:
); (error, stack) => Center(
} child: Padding(
padding: const EdgeInsets.all(16.0),
// If poll is in readonly mode, show readonly view child: Text('Failed to load poll: $error'),
if (widget.isReadonly) { ),
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildCollapsedView(context),
const SizedBox(height: 8),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, anim) {
final offset = Tween<Offset>(
begin: const Offset(0, -0.1),
end: Offset.zero,
).animate(CurvedAnimation(parent: anim, curve: Curves.easeOut));
final fade = CurvedAnimation(parent: anim, curve: Curves.easeOut);
return FadeTransition(
opacity: fade,
child: SlideTransition(position: offset, child: child),
);
},
child: _buildReadonlyView(context),
), ),
], data: (poll) {
); // Initialize questions when data is available
} _questions = [...poll.questions]
..sort((a, b) => a.order.compareTo(b.order));
return Column( // Initialize answers from poll data
crossAxisAlignment: CrossAxisAlignment.stretch, _initializeFromPollData(poll);
children: [
_buildCollapsedView(context), if (_questions!.isEmpty) {
const SizedBox(height: 8), return const SizedBox.shrink();
AnimatedSwitcher( }
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, anim) { // If collapsed, show collapsed view for all states
final offset = Tween<Offset>( if (_isCollapsed) {
begin: const Offset(0, -0.1), return _buildCollapsedView(context, poll);
end: Offset.zero, }
).animate(CurvedAnimation(parent: anim, curve: Curves.easeOut));
final fade = CurvedAnimation(parent: anim, curve: Curves.easeOut); // If poll is already submitted and not in readonly mode, and not in modification mode, show submitted view
return FadeTransition( final hasUserAnswer =
opacity: fade, poll.userAnswer != null && poll.userAnswer!.answer.isNotEmpty;
child: SlideTransition(position: offset, child: child), if (hasUserAnswer && !widget.isReadonly && !_isModifying) {
); return Column(
},
child: Column(
key: const ValueKey('normal_expanded'),
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_buildHeader(context), _buildCollapsedView(context, poll),
const SizedBox(height: 12), const SizedBox(height: 8),
_AnimatedStep( AnimatedSwitcher(
key: ValueKey(_current.id), duration: const Duration(milliseconds: 300),
transitionBuilder: (child, anim) {
final offset = Tween<Offset>(
begin: const Offset(0, -0.1),
end: Offset.zero,
).animate(
CurvedAnimation(parent: anim, curve: Curves.easeOut),
);
final fade = CurvedAnimation(
parent: anim,
curve: Curves.easeOut,
);
return FadeTransition(
opacity: fade,
child: SlideTransition(position: offset, child: child),
);
},
child: Column( child: Column(
key: const ValueKey('submitted_expanded'),
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_buildBody(context), _buildSubmittedView(context, poll),
_buildStats(context, _current), _buildNavBar(context, poll),
], ],
), ),
), ),
const SizedBox(height: 16),
_buildNavBar(context),
], ],
), );
), }
],
// If poll is in readonly mode, show readonly view
if (widget.isReadonly) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildCollapsedView(context, poll),
const SizedBox(height: 8),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, anim) {
final offset = Tween<Offset>(
begin: const Offset(0, -0.1),
end: Offset.zero,
).animate(
CurvedAnimation(parent: anim, curve: Curves.easeOut),
);
final fade = CurvedAnimation(
parent: anim,
curve: Curves.easeOut,
);
return FadeTransition(
opacity: fade,
child: SlideTransition(position: offset, child: child),
);
},
child: _buildReadonlyView(context, poll),
),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildCollapsedView(context, poll),
const SizedBox(height: 8),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, anim) {
final offset = Tween<Offset>(
begin: const Offset(0, -0.1),
end: Offset.zero,
).animate(CurvedAnimation(parent: anim, curve: Curves.easeOut));
final fade = CurvedAnimation(
parent: anim,
curve: Curves.easeOut,
);
return FadeTransition(
opacity: fade,
child: SlideTransition(position: offset, child: child),
);
},
child: Column(
key: const ValueKey('normal_expanded'),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(context, poll),
const SizedBox(height: 12),
_AnimatedStep(
key: ValueKey(_current.id),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildBody(context, poll),
_buildStats(context, _current, poll.stats),
],
),
),
const SizedBox(height: 16),
_buildNavBar(context, poll),
],
),
),
],
);
},
); );
} }
} }

View File

@@ -423,6 +423,7 @@ class PostComposeCard extends HookConsumerWidget {
state: composeState, 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