✨ Poll answer
This commit is contained in:
@@ -753,5 +753,13 @@
|
|||||||
"sensitiveCategories.gambling": "Gambling",
|
"sensitiveCategories.gambling": "Gambling",
|
||||||
"sensitiveCategories.selfHarm": "Self-harm",
|
"sensitiveCategories.selfHarm": "Self-harm",
|
||||||
"sensitiveCategories.childAbuse": "Child Abuse",
|
"sensitiveCategories.childAbuse": "Child Abuse",
|
||||||
"sensitiveCategories.other": "Other"
|
"sensitiveCategories.other": "Other",
|
||||||
|
"poll": "Poll",
|
||||||
|
"pollsRecent": "Recent Polls",
|
||||||
|
"pollCreateNew": "Create New",
|
||||||
|
"pollCreateNewHint": "Create a new poll for your post. Pick a publisher and continue.",
|
||||||
|
"publisher": "Publisher",
|
||||||
|
"publisherHint": "Enter the publisher name",
|
||||||
|
"publisherCannotBeEmpty": "Publisher cannot be empty",
|
||||||
|
"operationFailed": "Operation failed: {}"
|
||||||
}
|
}
|
||||||
|
@@ -4,6 +4,26 @@ import 'package:island/models/publisher.dart';
|
|||||||
part 'poll.freezed.dart';
|
part 'poll.freezed.dart';
|
||||||
part 'poll.g.dart';
|
part 'poll.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
sealed class SnPollWithStats with _$SnPollWithStats {
|
||||||
|
const factory SnPollWithStats({
|
||||||
|
required Map<String, dynamic>? userAnswer,
|
||||||
|
required Map<String, dynamic> stats,
|
||||||
|
required String id,
|
||||||
|
required List<SnPollQuestion> questions,
|
||||||
|
String? title,
|
||||||
|
String? description,
|
||||||
|
DateTime? endedAt,
|
||||||
|
required String publisherId,
|
||||||
|
required DateTime createdAt,
|
||||||
|
required DateTime updatedAt,
|
||||||
|
DateTime? deletedAt,
|
||||||
|
}) = _SnPollWithStats;
|
||||||
|
|
||||||
|
factory SnPollWithStats.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SnPollWithStatsFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
sealed class SnPoll with _$SnPoll {
|
sealed class SnPoll with _$SnPoll {
|
||||||
const factory SnPoll({
|
const factory SnPoll({
|
||||||
@@ -59,9 +79,14 @@ sealed class SnPollOption with _$SnPollOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum SnPollQuestionType {
|
enum SnPollQuestionType {
|
||||||
|
@JsonValue(0)
|
||||||
singleChoice,
|
singleChoice,
|
||||||
|
@JsonValue(1)
|
||||||
multipleChoice,
|
multipleChoice,
|
||||||
|
@JsonValue(2)
|
||||||
yesNo,
|
yesNo,
|
||||||
|
@JsonValue(3)
|
||||||
rating,
|
rating,
|
||||||
|
@JsonValue(4)
|
||||||
freeText,
|
freeText,
|
||||||
}
|
}
|
||||||
|
@@ -12,6 +12,313 @@ part of 'poll.dart';
|
|||||||
// dart format off
|
// dart format off
|
||||||
T _$identity<T>(T value) => value;
|
T _$identity<T>(T value) => value;
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$SnPollWithStats {
|
||||||
|
|
||||||
|
Map<String, dynamic>? get userAnswer; Map<String, dynamic> get stats; String get id; List<SnPollQuestion> get questions; String? get title; String? get description; DateTime? get endedAt; String get publisherId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
|
||||||
|
/// Create a copy of SnPollWithStats
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$SnPollWithStatsCopyWith<SnPollWithStats> get copyWith => _$SnPollWithStatsCopyWithImpl<SnPollWithStats>(this as SnPollWithStats, _$identity);
|
||||||
|
|
||||||
|
/// Serializes this SnPollWithStats to a JSON map.
|
||||||
|
Map<String, dynamic> toJson();
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnPollWithStats&&const DeepCollectionEquality().equals(other.userAnswer, userAnswer)&&const DeepCollectionEquality().equals(other.stats, stats)&&(identical(other.id, id) || other.id == id)&&const DeepCollectionEquality().equals(other.questions, questions)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.endedAt, endedAt) || other.endedAt == endedAt)&&(identical(other.publisherId, publisherId) || other.publisherId == publisherId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(userAnswer),const DeepCollectionEquality().hash(stats),id,const DeepCollectionEquality().hash(questions),title,description,endedAt,publisherId,createdAt,updatedAt,deletedAt);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'SnPollWithStats(userAnswer: $userAnswer, stats: $stats, id: $id, questions: $questions, title: $title, description: $description, endedAt: $endedAt, publisherId: $publisherId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class $SnPollWithStatsCopyWith<$Res> {
|
||||||
|
factory $SnPollWithStatsCopyWith(SnPollWithStats value, $Res Function(SnPollWithStats) _then) = _$SnPollWithStatsCopyWithImpl;
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
Map<String, dynamic>? userAnswer, Map<String, dynamic> stats, String id, List<SnPollQuestion> questions, String? title, String? description, DateTime? endedAt, String publisherId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class _$SnPollWithStatsCopyWithImpl<$Res>
|
||||||
|
implements $SnPollWithStatsCopyWith<$Res> {
|
||||||
|
_$SnPollWithStatsCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final SnPollWithStats _self;
|
||||||
|
final $Res Function(SnPollWithStats) _then;
|
||||||
|
|
||||||
|
/// Create a copy of SnPollWithStats
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline') @override $Res call({Object? userAnswer = freezed,Object? stats = null,Object? id = null,Object? questions = null,Object? title = freezed,Object? description = freezed,Object? endedAt = freezed,Object? publisherId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
|
||||||
|
return _then(_self.copyWith(
|
||||||
|
userAnswer: freezed == userAnswer ? _self.userAnswer : userAnswer // ignore: cast_nullable_to_non_nullable
|
||||||
|
as Map<String, dynamic>?,stats: null == stats ? _self.stats : stats // ignore: cast_nullable_to_non_nullable
|
||||||
|
as Map<String, dynamic>,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,questions: null == questions ? _self.questions : questions // ignore: cast_nullable_to_non_nullable
|
||||||
|
as List<SnPollQuestion>,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,endedAt: freezed == endedAt ? _self.endedAt : endedAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime?,publisherId: null == publisherId ? _self.publisherId : publisherId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Adds pattern-matching-related methods to [SnPollWithStats].
|
||||||
|
extension SnPollWithStatsPatterns on SnPollWithStats {
|
||||||
|
/// A variant of `map` that fallback to returning `orElse`.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return orElse();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _SnPollWithStats value)? $default,{required TResult orElse(),}){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _SnPollWithStats() when $default != null:
|
||||||
|
return $default(_that);case _:
|
||||||
|
return orElse();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A `switch`-like method, using callbacks.
|
||||||
|
///
|
||||||
|
/// Callbacks receives the raw object, upcasted.
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case final Subclass2 value:
|
||||||
|
/// return ...;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _SnPollWithStats value) $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _SnPollWithStats():
|
||||||
|
return $default(_that);}
|
||||||
|
}
|
||||||
|
/// A variant of `map` that fallback to returning `null`.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return null;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _SnPollWithStats value)? $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _SnPollWithStats() when $default != null:
|
||||||
|
return $default(_that);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A variant of `when` that fallback to an `orElse` callback.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return orElse();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( 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;
|
||||||
|
switch (_that) {
|
||||||
|
case _SnPollWithStats() when $default != null:
|
||||||
|
return $default(_that.userAnswer,_that.stats,_that.id,_that.questions,_that.title,_that.description,_that.endedAt,_that.publisherId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
|
||||||
|
return orElse();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A `switch`-like method, using callbacks.
|
||||||
|
///
|
||||||
|
/// As opposed to `map`, this offers destructuring.
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case Subclass2(:final field2):
|
||||||
|
/// return ...;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( 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;
|
||||||
|
switch (_that) {
|
||||||
|
case _SnPollWithStats():
|
||||||
|
return $default(_that.userAnswer,_that.stats,_that.id,_that.questions,_that.title,_that.description,_that.endedAt,_that.publisherId,_that.createdAt,_that.updatedAt,_that.deletedAt);}
|
||||||
|
}
|
||||||
|
/// A variant of `when` that fallback to returning `null`
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return null;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( 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;
|
||||||
|
switch (_that) {
|
||||||
|
case _SnPollWithStats() when $default != null:
|
||||||
|
return $default(_that.userAnswer,_that.stats,_that.id,_that.questions,_that.title,_that.description,_that.endedAt,_that.publisherId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
@JsonSerializable()
|
||||||
|
|
||||||
|
class _SnPollWithStats implements SnPollWithStats {
|
||||||
|
const _SnPollWithStats({required final Map<String, dynamic>? userAnswer, required final Map<String, dynamic> stats, 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;
|
||||||
|
factory _SnPollWithStats.fromJson(Map<String, dynamic> json) => _$SnPollWithStatsFromJson(json);
|
||||||
|
|
||||||
|
final Map<String, dynamic>? _userAnswer;
|
||||||
|
@override Map<String, dynamic>? get userAnswer {
|
||||||
|
final value = _userAnswer;
|
||||||
|
if (value == null) return null;
|
||||||
|
if (_userAnswer is EqualUnmodifiableMapView) return _userAnswer;
|
||||||
|
// ignore: implicit_dynamic_type
|
||||||
|
return EqualUnmodifiableMapView(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, dynamic> _stats;
|
||||||
|
@override Map<String, dynamic> get stats {
|
||||||
|
if (_stats is EqualUnmodifiableMapView) return _stats;
|
||||||
|
// ignore: implicit_dynamic_type
|
||||||
|
return EqualUnmodifiableMapView(_stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override final String id;
|
||||||
|
final List<SnPollQuestion> _questions;
|
||||||
|
@override List<SnPollQuestion> get questions {
|
||||||
|
if (_questions is EqualUnmodifiableListView) return _questions;
|
||||||
|
// ignore: implicit_dynamic_type
|
||||||
|
return EqualUnmodifiableListView(_questions);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override final String? title;
|
||||||
|
@override final String? description;
|
||||||
|
@override final DateTime? endedAt;
|
||||||
|
@override final String publisherId;
|
||||||
|
@override final DateTime createdAt;
|
||||||
|
@override final DateTime updatedAt;
|
||||||
|
@override final DateTime? deletedAt;
|
||||||
|
|
||||||
|
/// Create a copy of SnPollWithStats
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$SnPollWithStatsCopyWith<_SnPollWithStats> get copyWith => __$SnPollWithStatsCopyWithImpl<_SnPollWithStats>(this, _$identity);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return _$SnPollWithStatsToJson(this, );
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnPollWithStats&&const DeepCollectionEquality().equals(other._userAnswer, _userAnswer)&&const DeepCollectionEquality().equals(other._stats, _stats)&&(identical(other.id, id) || other.id == id)&&const DeepCollectionEquality().equals(other._questions, _questions)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.endedAt, endedAt) || other.endedAt == endedAt)&&(identical(other.publisherId, publisherId) || other.publisherId == publisherId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_userAnswer),const DeepCollectionEquality().hash(_stats),id,const DeepCollectionEquality().hash(_questions),title,description,endedAt,publisherId,createdAt,updatedAt,deletedAt);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'SnPollWithStats(userAnswer: $userAnswer, stats: $stats, id: $id, questions: $questions, title: $title, description: $description, endedAt: $endedAt, publisherId: $publisherId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class _$SnPollWithStatsCopyWith<$Res> implements $SnPollWithStatsCopyWith<$Res> {
|
||||||
|
factory _$SnPollWithStatsCopyWith(_SnPollWithStats value, $Res Function(_SnPollWithStats) _then) = __$SnPollWithStatsCopyWithImpl;
|
||||||
|
@override @useResult
|
||||||
|
$Res call({
|
||||||
|
Map<String, dynamic>? userAnswer, Map<String, dynamic> stats, String id, List<SnPollQuestion> questions, String? title, String? description, DateTime? endedAt, String publisherId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class __$SnPollWithStatsCopyWithImpl<$Res>
|
||||||
|
implements _$SnPollWithStatsCopyWith<$Res> {
|
||||||
|
__$SnPollWithStatsCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final _SnPollWithStats _self;
|
||||||
|
final $Res Function(_SnPollWithStats) _then;
|
||||||
|
|
||||||
|
/// Create a copy of SnPollWithStats
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @pragma('vm:prefer-inline') $Res call({Object? userAnswer = freezed,Object? stats = null,Object? id = null,Object? questions = null,Object? title = freezed,Object? description = freezed,Object? endedAt = freezed,Object? publisherId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
|
||||||
|
return _then(_SnPollWithStats(
|
||||||
|
userAnswer: freezed == userAnswer ? _self._userAnswer : userAnswer // ignore: cast_nullable_to_non_nullable
|
||||||
|
as Map<String, dynamic>?,stats: null == stats ? _self._stats : stats // ignore: cast_nullable_to_non_nullable
|
||||||
|
as Map<String, dynamic>,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,questions: null == questions ? _self._questions : questions // ignore: cast_nullable_to_non_nullable
|
||||||
|
as List<SnPollQuestion>,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,endedAt: freezed == endedAt ? _self.endedAt : endedAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime?,publisherId: null == publisherId ? _self.publisherId : publisherId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// @nodoc
|
/// @nodoc
|
||||||
mixin _$SnPoll {
|
mixin _$SnPoll {
|
||||||
|
|
||||||
|
@@ -6,6 +6,45 @@ part of 'poll.dart';
|
|||||||
// JsonSerializableGenerator
|
// JsonSerializableGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
|
_SnPollWithStats _$SnPollWithStatsFromJson(Map<String, dynamic> json) =>
|
||||||
|
_SnPollWithStats(
|
||||||
|
userAnswer: json['user_answer'] as Map<String, dynamic>?,
|
||||||
|
stats: json['stats'] as Map<String, dynamic>,
|
||||||
|
id: json['id'] as String,
|
||||||
|
questions:
|
||||||
|
(json['questions'] as List<dynamic>)
|
||||||
|
.map((e) => SnPollQuestion.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
title: json['title'] as String?,
|
||||||
|
description: json['description'] as String?,
|
||||||
|
endedAt:
|
||||||
|
json['ended_at'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['ended_at'] as String),
|
||||||
|
publisherId: json['publisher_id'] as String,
|
||||||
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
|
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||||
|
deletedAt:
|
||||||
|
json['deleted_at'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['deleted_at'] as String),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$SnPollWithStatsToJson(_SnPollWithStats instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'user_answer': instance.userAnswer,
|
||||||
|
'stats': instance.stats,
|
||||||
|
'id': instance.id,
|
||||||
|
'questions': instance.questions.map((e) => e.toJson()).toList(),
|
||||||
|
'title': instance.title,
|
||||||
|
'description': instance.description,
|
||||||
|
'ended_at': instance.endedAt?.toIso8601String(),
|
||||||
|
'publisher_id': instance.publisherId,
|
||||||
|
'created_at': instance.createdAt.toIso8601String(),
|
||||||
|
'updated_at': instance.updatedAt.toIso8601String(),
|
||||||
|
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
_SnPoll _$SnPollFromJson(Map<String, dynamic> json) => _SnPoll(
|
_SnPoll _$SnPollFromJson(Map<String, dynamic> json) => _SnPoll(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
questions:
|
questions:
|
||||||
@@ -70,11 +109,11 @@ Map<String, dynamic> _$SnPollQuestionToJson(_SnPollQuestion instance) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
const _$SnPollQuestionTypeEnumMap = {
|
const _$SnPollQuestionTypeEnumMap = {
|
||||||
SnPollQuestionType.singleChoice: 'singleChoice',
|
SnPollQuestionType.singleChoice: 0,
|
||||||
SnPollQuestionType.multipleChoice: 'multipleChoice',
|
SnPollQuestionType.multipleChoice: 1,
|
||||||
SnPollQuestionType.yesNo: 'yesNo',
|
SnPollQuestionType.yesNo: 2,
|
||||||
SnPollQuestionType.rating: 'rating',
|
SnPollQuestionType.rating: 3,
|
||||||
SnPollQuestionType.freeText: 'freeText',
|
SnPollQuestionType.freeText: 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
_SnPollOption _$SnPollOptionFromJson(Map<String, dynamic> json) =>
|
_SnPollOption _$SnPollOptionFromJson(Map<String, dynamic> json) =>
|
||||||
|
501
lib/widgets/poll/poll_submit.dart
Normal file
501
lib/widgets/poll/poll_submit.dart
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:island/models/poll.dart';
|
||||||
|
import 'package:island/pods/network.dart';
|
||||||
|
|
||||||
|
/// A poll answering widget that shows one question at a time and collects answers.
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
/// PollSubmit(
|
||||||
|
/// poll: poll,
|
||||||
|
/// onSubmit: (answers) {
|
||||||
|
/// // answers is Map<String, dynamic>: questionId -> answer
|
||||||
|
/// // answer types by question:
|
||||||
|
/// // - singleChoice: String optionId
|
||||||
|
/// // - multipleChoice: List<String> optionIds
|
||||||
|
/// // - yesNo: bool
|
||||||
|
/// // - rating: int (1..5)
|
||||||
|
/// // - freeText: String
|
||||||
|
/// },
|
||||||
|
/// )
|
||||||
|
class PollSubmit extends ConsumerStatefulWidget {
|
||||||
|
const PollSubmit({
|
||||||
|
super.key,
|
||||||
|
required this.poll,
|
||||||
|
required this.onSubmit,
|
||||||
|
this.initialAnswers,
|
||||||
|
this.onCancel,
|
||||||
|
this.showProgress = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final SnPollWithStats poll;
|
||||||
|
|
||||||
|
/// Callback when user submits all answers. Map questionId -> answer.
|
||||||
|
final void Function(Map<String, dynamic> answers) onSubmit;
|
||||||
|
|
||||||
|
/// Optional initial answers, keyed by questionId.
|
||||||
|
final Map<String, dynamic>? initialAnswers;
|
||||||
|
|
||||||
|
/// Optional cancel callback.
|
||||||
|
final VoidCallback? onCancel;
|
||||||
|
|
||||||
|
/// Whether to show a progress indicator (e.g., "2 / N").
|
||||||
|
final bool showProgress;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<PollSubmit> createState() => _PollSubmitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PollSubmitState extends ConsumerState<PollSubmit> {
|
||||||
|
late final List<SnPollQuestion> _questions;
|
||||||
|
int _index = 0;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
/// Collected answers, keyed by questionId
|
||||||
|
late Map<String, dynamic> _answers;
|
||||||
|
|
||||||
|
/// Local controller for free text input
|
||||||
|
final TextEditingController _textController = TextEditingController();
|
||||||
|
|
||||||
|
/// Local state holders for inputs to avoid rebuilding whole list
|
||||||
|
String? _singleChoiceSelected; // optionId
|
||||||
|
final Set<String> _multiChoiceSelected = {};
|
||||||
|
bool? _yesNoSelected;
|
||||||
|
int? _ratingSelected; // 1..5
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Ensure questions are ordered by `order`
|
||||||
|
_questions = [...widget.poll.questions]
|
||||||
|
..sort((a, b) => a.order.compareTo(b.order));
|
||||||
|
_answers = Map<String, dynamic>.from(widget.initialAnswers ?? {});
|
||||||
|
_loadCurrentIntoLocalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant PollSubmit oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.poll.id != widget.poll.id) {
|
||||||
|
_index = 0;
|
||||||
|
_answers = Map<String, dynamic>.from(widget.initialAnswers ?? {});
|
||||||
|
_questions
|
||||||
|
..clear()
|
||||||
|
..addAll(
|
||||||
|
[...widget.poll.questions]
|
||||||
|
..sort((a, b) => a.order.compareTo(b.order)),
|
||||||
|
);
|
||||||
|
_loadCurrentIntoLocalState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_textController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
SnPollQuestion get _current => _questions[_index];
|
||||||
|
|
||||||
|
void _loadCurrentIntoLocalState() {
|
||||||
|
final q = _current;
|
||||||
|
final saved = _answers[q.id];
|
||||||
|
|
||||||
|
_singleChoiceSelected = null;
|
||||||
|
_multiChoiceSelected.clear();
|
||||||
|
_yesNoSelected = null;
|
||||||
|
_ratingSelected = null;
|
||||||
|
_textController.text = '';
|
||||||
|
|
||||||
|
switch (q.type) {
|
||||||
|
case SnPollQuestionType.singleChoice:
|
||||||
|
if (saved is String) _singleChoiceSelected = saved;
|
||||||
|
break;
|
||||||
|
case SnPollQuestionType.multipleChoice:
|
||||||
|
if (saved is List) {
|
||||||
|
_multiChoiceSelected.addAll(saved.whereType<String>());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case SnPollQuestionType.yesNo:
|
||||||
|
if (saved is bool) _yesNoSelected = saved;
|
||||||
|
break;
|
||||||
|
case SnPollQuestionType.rating:
|
||||||
|
if (saved is int) _ratingSelected = saved;
|
||||||
|
break;
|
||||||
|
case SnPollQuestionType.freeText:
|
||||||
|
if (saved is String) _textController.text = saved;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isCurrentAnswered() {
|
||||||
|
final q = _current;
|
||||||
|
if (!q.isRequired) return true;
|
||||||
|
|
||||||
|
switch (q.type) {
|
||||||
|
case SnPollQuestionType.singleChoice:
|
||||||
|
return _singleChoiceSelected != null;
|
||||||
|
case SnPollQuestionType.multipleChoice:
|
||||||
|
return _multiChoiceSelected.isNotEmpty;
|
||||||
|
case SnPollQuestionType.yesNo:
|
||||||
|
return _yesNoSelected != null;
|
||||||
|
case SnPollQuestionType.rating:
|
||||||
|
return (_ratingSelected ?? 0) > 0;
|
||||||
|
case SnPollQuestionType.freeText:
|
||||||
|
return _textController.text.trim().isNotEmpty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _persistCurrentAnswer() {
|
||||||
|
final q = _current;
|
||||||
|
switch (q.type) {
|
||||||
|
case SnPollQuestionType.singleChoice:
|
||||||
|
if (_singleChoiceSelected == null) {
|
||||||
|
_answers.remove(q.id);
|
||||||
|
} else {
|
||||||
|
_answers[q.id] = _singleChoiceSelected!;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case SnPollQuestionType.multipleChoice:
|
||||||
|
if (_multiChoiceSelected.isEmpty) {
|
||||||
|
_answers.remove(q.id);
|
||||||
|
} else {
|
||||||
|
_answers[q.id] = _multiChoiceSelected.toList(growable: false);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case SnPollQuestionType.yesNo:
|
||||||
|
if (_yesNoSelected == null) {
|
||||||
|
_answers.remove(q.id);
|
||||||
|
} else {
|
||||||
|
_answers[q.id] = _yesNoSelected!;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case SnPollQuestionType.rating:
|
||||||
|
if (_ratingSelected == null) {
|
||||||
|
_answers.remove(q.id);
|
||||||
|
} else {
|
||||||
|
_answers[q.id] = _ratingSelected!;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case SnPollQuestionType.freeText:
|
||||||
|
final text = _textController.text.trim();
|
||||||
|
if (text.isEmpty) {
|
||||||
|
_answers.remove(q.id);
|
||||||
|
} else {
|
||||||
|
_answers[q.id] = text;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submitToServer() async {
|
||||||
|
// Persist current question before final submit
|
||||||
|
_persistCurrentAnswer();
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_submitting = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final dio = ref.read(apiClientProvider);
|
||||||
|
|
||||||
|
await dio.post(
|
||||||
|
'/sphere/polls/${widget.poll.id}/answer',
|
||||||
|
data: {'answer': _answers},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only call onSubmit after server accepts
|
||||||
|
widget.onSubmit(Map<String, dynamic>.unmodifiable(_answers));
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text('Failed to submit poll: $e')));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_submitting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _next() {
|
||||||
|
if (_submitting) return;
|
||||||
|
_persistCurrentAnswer();
|
||||||
|
if (_index < _questions.length - 1) {
|
||||||
|
setState(() {
|
||||||
|
_index++;
|
||||||
|
_loadCurrentIntoLocalState();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Final submit to API
|
||||||
|
_submitToServer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _back() {
|
||||||
|
if (_submitting) return;
|
||||||
|
_persistCurrentAnswer();
|
||||||
|
if (_index > 0) {
|
||||||
|
setState(() {
|
||||||
|
_index--;
|
||||||
|
_loadCurrentIntoLocalState();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// at the first question; allow cancel if provided
|
||||||
|
widget.onCancel?.call();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader(BuildContext context) {
|
||||||
|
final q = _current;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (widget.poll.title != null || widget.poll.description != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (widget.poll.title != null)
|
||||||
|
Text(
|
||||||
|
widget.poll.title!,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
if (widget.poll.description != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(
|
||||||
|
widget.poll.description!,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyMedium?.color?.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.showProgress)
|
||||||
|
Text(
|
||||||
|
'${_index + 1} / ${_questions.length}',
|
||||||
|
style: Theme.of(context).textTheme.labelMedium,
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
q.title,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (q.isRequired)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8),
|
||||||
|
child: Text(
|
||||||
|
'*',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (q.description != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(
|
||||||
|
q.description!,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodySmall?.color?.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody(BuildContext context) {
|
||||||
|
final q = _current;
|
||||||
|
switch (q.type) {
|
||||||
|
case SnPollQuestionType.singleChoice:
|
||||||
|
return _buildSingleChoice(context, q);
|
||||||
|
case SnPollQuestionType.multipleChoice:
|
||||||
|
return _buildMultipleChoice(context, q);
|
||||||
|
case SnPollQuestionType.yesNo:
|
||||||
|
return _buildYesNo(context, q);
|
||||||
|
case SnPollQuestionType.rating:
|
||||||
|
return _buildRating(context, q);
|
||||||
|
case SnPollQuestionType.freeText:
|
||||||
|
return _buildFreeText(context, q);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSingleChoice(BuildContext context, SnPollQuestion q) {
|
||||||
|
final options = [...?q.options]..sort((a, b) => a.order.compareTo(b.order));
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
for (final opt in options)
|
||||||
|
RadioListTile<String>(
|
||||||
|
value: opt.id,
|
||||||
|
groupValue: _singleChoiceSelected,
|
||||||
|
onChanged: (val) => setState(() => _singleChoiceSelected = val),
|
||||||
|
title: Text(opt.label),
|
||||||
|
subtitle: opt.description != null ? Text(opt.description!) : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMultipleChoice(BuildContext context, SnPollQuestion q) {
|
||||||
|
final options = [...?q.options]..sort((a, b) => a.order.compareTo(b.order));
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
for (final opt in options)
|
||||||
|
CheckboxListTile(
|
||||||
|
value: _multiChoiceSelected.contains(opt.id),
|
||||||
|
onChanged: (val) {
|
||||||
|
setState(() {
|
||||||
|
if (val == true) {
|
||||||
|
_multiChoiceSelected.add(opt.id);
|
||||||
|
} else {
|
||||||
|
_multiChoiceSelected.remove(opt.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
title: Text(opt.label),
|
||||||
|
subtitle: opt.description != null ? Text(opt.description!) : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildYesNo(BuildContext context, SnPollQuestion q) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SegmentedButton<bool>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(value: true, label: Text('Yes')),
|
||||||
|
ButtonSegment(value: false, label: Text('No')),
|
||||||
|
],
|
||||||
|
selected: _yesNoSelected == null ? {} : {_yesNoSelected!},
|
||||||
|
onSelectionChanged: (sel) {
|
||||||
|
setState(() {
|
||||||
|
_yesNoSelected = sel.isEmpty ? null : sel.first;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
multiSelectionEnabled: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRating(BuildContext context, SnPollQuestion q) {
|
||||||
|
const max = 5;
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: List.generate(max, (i) {
|
||||||
|
final value = i + 1;
|
||||||
|
final selected = (_ratingSelected ?? 0) >= value;
|
||||||
|
return IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
selected ? Icons.star : Icons.star_border,
|
||||||
|
color: selected ? Colors.amber : null,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_ratingSelected = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFreeText(BuildContext context, SnPollQuestion q) {
|
||||||
|
return TextField(
|
||||||
|
controller: _textController,
|
||||||
|
maxLines: 6,
|
||||||
|
decoration: const InputDecoration(border: OutlineInputBorder()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNavBar(BuildContext context) {
|
||||||
|
final isLast = _index == _questions.length - 1;
|
||||||
|
final canProceed = _isCurrentAnswered() && !_submitting;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
label: Text(_index == 0 ? 'Cancel' : 'Back'),
|
||||||
|
onPressed: _submitting ? null : _back,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
FilledButton.icon(
|
||||||
|
icon:
|
||||||
|
_submitting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: Icon(isLast ? Icons.check : Icons.arrow_forward),
|
||||||
|
label: Text(isLast ? 'Submit' : 'Next'),
|
||||||
|
onPressed: canProceed ? _next : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_questions.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildHeader(context),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_AnimatedStep(key: ValueKey(_current.id), child: _buildBody(context)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildNavBar(context),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple fade/slide transition between questions.
|
||||||
|
class _AnimatedStep extends StatelessWidget {
|
||||||
|
const _AnimatedStep({super.key, required this.child});
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
transitionBuilder: (child, anim) {
|
||||||
|
final offset = Tween<Offset>(
|
||||||
|
begin: const Offset(0.1, 0),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(anim);
|
||||||
|
final fade = CurvedAnimation(parent: anim, curve: Curves.easeInOut);
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: fade,
|
||||||
|
child: SlideTransition(position: offset, child: child),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
201
lib/widgets/post/compose_poll.dart
Normal file
201
lib/widgets/post/compose_poll.dart
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:island/models/poll.dart';
|
||||||
|
import 'package:island/models/publisher.dart';
|
||||||
|
import 'package:island/screens/creators/poll/poll_list.dart';
|
||||||
|
import 'package:island/widgets/content/sheet.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
import 'package:island/widgets/post/publishers_modal.dart';
|
||||||
|
|
||||||
|
/// Bottom sheet for selecting or creating a poll. Returns SnPoll via Navigator.pop.
|
||||||
|
class ComposePollSheet extends HookConsumerWidget {
|
||||||
|
/// Optional publisher name to filter polls and prefill creation.
|
||||||
|
final String? pubName;
|
||||||
|
|
||||||
|
const ComposePollSheet({super.key, this.pubName});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final selectedPublisher = useState<String?>(pubName);
|
||||||
|
final isPushing = useState(false);
|
||||||
|
final errorText = useState<String?>(null);
|
||||||
|
|
||||||
|
return SheetScaffold(
|
||||||
|
heightFactor: 0.6,
|
||||||
|
titleText: 'poll'.tr(),
|
||||||
|
child: DefaultTabController(
|
||||||
|
length: 2,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
TabBar(
|
||||||
|
tabs: [
|
||||||
|
Tab(text: 'pollsRecent'.tr()),
|
||||||
|
Tab(text: 'pollCreateNew'.tr()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: TabBarView(
|
||||||
|
children: [
|
||||||
|
// Link/Select existing poll list
|
||||||
|
PagingHelperView(
|
||||||
|
provider: pollListNotifierProvider(pubName),
|
||||||
|
futureRefreshable: pollListNotifierProvider(pubName).future,
|
||||||
|
notifierRefreshable:
|
||||||
|
pollListNotifierProvider(pubName).notifier,
|
||||||
|
contentBuilder:
|
||||||
|
(data, widgetCount, endItemView) => ListView.builder(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
itemCount: widgetCount,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index == widgetCount - 1) {
|
||||||
|
return endItemView;
|
||||||
|
}
|
||||||
|
|
||||||
|
final poll = data.items[index];
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: const Icon(Symbols.how_to_vote, fill: 1),
|
||||||
|
title: Text(poll.title ?? 'untitled'.tr()),
|
||||||
|
subtitle: _buildPollSubtitle(poll),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).pop(poll);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Create new poll and return it
|
||||||
|
SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'pollCreateNewHint',
|
||||||
|
).tr().fontSize(13).opacity(0.85).padding(bottom: 8),
|
||||||
|
ListTile(
|
||||||
|
title: Text(
|
||||||
|
selectedPublisher.value == null
|
||||||
|
? 'publisher'.tr()
|
||||||
|
: '@${selectedPublisher.value}',
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
selectedPublisher.value == null
|
||||||
|
? 'publisherHint'.tr()
|
||||||
|
: 'selected'.tr(),
|
||||||
|
),
|
||||||
|
leading: const Icon(Symbols.account_circle),
|
||||||
|
trailing: const Icon(Symbols.chevron_right),
|
||||||
|
onTap: () async {
|
||||||
|
final picked =
|
||||||
|
await showModalBottomSheet<SnPublisher>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => const PublisherModal(),
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
try {
|
||||||
|
final name = picked.name;
|
||||||
|
if (name.isNotEmpty) {
|
||||||
|
selectedPublisher.value = name;
|
||||||
|
errorText.value = null;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (errorText.value != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
top: 4,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
errorText.value!,
|
||||||
|
style: TextStyle(color: Colors.red[700]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(16),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
icon:
|
||||||
|
isPushing.value
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Symbols.add_circle),
|
||||||
|
label: Text('create'.tr()),
|
||||||
|
onPressed:
|
||||||
|
isPushing.value
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
final pub = selectedPublisher.value ?? '';
|
||||||
|
if (pub.isEmpty) {
|
||||||
|
errorText.value =
|
||||||
|
'publisherCannotBeEmpty'.tr();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
errorText.value = null;
|
||||||
|
|
||||||
|
isPushing.value = true;
|
||||||
|
// Push to creatorPollNew route and await result
|
||||||
|
final result = await GoRouter.of(
|
||||||
|
context,
|
||||||
|
).push<SnPoll>(
|
||||||
|
'/creators/$pub/polls/new',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result == null) {
|
||||||
|
isPushing.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
// Return created poll to caller of this bottom sheet
|
||||||
|
Navigator.of(context).pop(result);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).padding(horizontal: 24, vertical: 24),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget? _buildPollSubtitle(SnPoll poll) {
|
||||||
|
try {
|
||||||
|
final SnPoll dyn = poll;
|
||||||
|
final List<SnPollQuestion>? options = dyn.questions;
|
||||||
|
if (options == null || options.isEmpty) return null;
|
||||||
|
final preview = options.take(3).map((e) => e.title).join(' · ');
|
||||||
|
if (preview.trim().isEmpty) return null;
|
||||||
|
return Text(preview);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -14,6 +14,7 @@ import 'package:island/services/file.dart';
|
|||||||
import 'package:island/services/compose_storage_db.dart';
|
import 'package:island/services/compose_storage_db.dart';
|
||||||
import 'package:island/widgets/alert.dart';
|
import 'package:island/widgets/alert.dart';
|
||||||
import 'package:island/widgets/post/compose_link_attachments.dart';
|
import 'package:island/widgets/post/compose_link_attachments.dart';
|
||||||
|
import 'package:island/widgets/post/compose_poll.dart';
|
||||||
import 'package:island/widgets/post/compose_recorder.dart';
|
import 'package:island/widgets/post/compose_recorder.dart';
|
||||||
import 'package:pasteboard/pasteboard.dart';
|
import 'package:pasteboard/pasteboard.dart';
|
||||||
import 'package:textfield_tags/textfield_tags.dart';
|
import 'package:textfield_tags/textfield_tags.dart';
|
||||||
@@ -33,6 +34,8 @@ class ComposeState {
|
|||||||
StringTagController categoriesController;
|
StringTagController categoriesController;
|
||||||
final String draftId;
|
final String draftId;
|
||||||
int postType;
|
int postType;
|
||||||
|
// Linked poll id for this compose session (nullable)
|
||||||
|
final ValueNotifier<String?> pollId;
|
||||||
Timer? _autoSaveTimer;
|
Timer? _autoSaveTimer;
|
||||||
|
|
||||||
ComposeState({
|
ComposeState({
|
||||||
@@ -48,7 +51,8 @@ class ComposeState {
|
|||||||
required this.categoriesController,
|
required this.categoriesController,
|
||||||
required this.draftId,
|
required this.draftId,
|
||||||
this.postType = 0,
|
this.postType = 0,
|
||||||
});
|
String? pollId,
|
||||||
|
}) : pollId = ValueNotifier<String?>(pollId);
|
||||||
|
|
||||||
void startAutoSave(WidgetRef ref) {
|
void startAutoSave(WidgetRef ref) {
|
||||||
_autoSaveTimer?.cancel();
|
_autoSaveTimer?.cancel();
|
||||||
@@ -111,6 +115,8 @@ class ComposeLogic {
|
|||||||
categoriesController: categoriesController,
|
categoriesController: categoriesController,
|
||||||
draftId: id,
|
draftId: id,
|
||||||
postType: postType,
|
postType: postType,
|
||||||
|
// initialize without poll by default
|
||||||
|
pollId: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +144,7 @@ class ComposeLogic {
|
|||||||
categoriesController: categoriesController,
|
categoriesController: categoriesController,
|
||||||
draftId: draft.id,
|
draftId: draft.id,
|
||||||
postType: postType,
|
postType: postType,
|
||||||
|
pollId: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,6 +562,27 @@ class ComposeLogic {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<void> pickPoll(
|
||||||
|
WidgetRef ref,
|
||||||
|
ComposeState state,
|
||||||
|
BuildContext context,
|
||||||
|
) async {
|
||||||
|
if (state.pollId.value != null) {
|
||||||
|
state.pollId.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final poll = await showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
useRootNavigator: true,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => const ComposePollSheet(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (poll == null) return;
|
||||||
|
state.pollId.value = poll.id;
|
||||||
|
}
|
||||||
|
|
||||||
static Future<void> performAction(
|
static Future<void> performAction(
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
ComposeState state,
|
ComposeState state,
|
||||||
@@ -613,6 +641,7 @@ class ComposeLogic {
|
|||||||
if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id,
|
if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id,
|
||||||
'tags': state.tagsController.getTags,
|
'tags': state.tagsController.getTags,
|
||||||
'categories': state.categoriesController.getTags,
|
'categories': state.categoriesController.getTags,
|
||||||
|
if (state.pollId.value != null) 'poll_id': state.pollId.value,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send request
|
// Send request
|
||||||
@@ -703,5 +732,6 @@ class ComposeLogic {
|
|||||||
state.currentPublisher.dispose();
|
state.currentPublisher.dispose();
|
||||||
state.tagsController.dispose();
|
state.tagsController.dispose();
|
||||||
state.categoriesController.dispose();
|
state.categoriesController.dispose();
|
||||||
|
state.pollId.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -36,6 +36,10 @@ class ComposeToolbar extends HookConsumerWidget {
|
|||||||
ComposeLogic.saveDraft(ref, state);
|
ComposeLogic.saveDraft(ref, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void pickPoll() {
|
||||||
|
ComposeLogic.pickPoll(ref, state, context);
|
||||||
|
}
|
||||||
|
|
||||||
void showDraftManager() {
|
void showDraftManager() {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -88,6 +92,25 @@ class ComposeToolbar extends HookConsumerWidget {
|
|||||||
tooltip: 'linkAttachment'.tr(),
|
tooltip: 'linkAttachment'.tr(),
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
|
// Poll button with visual state when a poll is linked
|
||||||
|
ListenableBuilder(
|
||||||
|
listenable: state.pollId,
|
||||||
|
builder: (context, _) {
|
||||||
|
return IconButton(
|
||||||
|
onPressed: pickPoll,
|
||||||
|
icon: const Icon(Symbols.how_to_vote),
|
||||||
|
tooltip: 'poll'.tr(),
|
||||||
|
color: colorScheme.primary,
|
||||||
|
style: ButtonStyle(
|
||||||
|
backgroundColor: WidgetStatePropertyAll(
|
||||||
|
state.pollId.value != null
|
||||||
|
? colorScheme.primary.withOpacity(0.15)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (originalPost == null && state.isEmpty)
|
if (originalPost == null && state.isEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
|
@@ -8,6 +8,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:island/models/embed.dart';
|
import 'package:island/models/embed.dart';
|
||||||
|
import 'package:island/models/poll.dart';
|
||||||
import 'package:island/models/post.dart';
|
import 'package:island/models/post.dart';
|
||||||
import 'package:island/pods/network.dart';
|
import 'package:island/pods/network.dart';
|
||||||
import 'package:island/pods/translate.dart';
|
import 'package:island/pods/translate.dart';
|
||||||
@@ -21,6 +22,7 @@ import 'package:island/widgets/content/cloud_file_collection.dart';
|
|||||||
import 'package:island/widgets/content/cloud_files.dart';
|
import 'package:island/widgets/content/cloud_files.dart';
|
||||||
import 'package:island/widgets/content/embed/link.dart';
|
import 'package:island/widgets/content/embed/link.dart';
|
||||||
import 'package:island/widgets/content/markdown.dart';
|
import 'package:island/widgets/content/markdown.dart';
|
||||||
|
import 'package:island/widgets/poll/poll_submit.dart';
|
||||||
import 'package:island/widgets/post/post_replies_sheet.dart';
|
import 'package:island/widgets/post/post_replies_sheet.dart';
|
||||||
import 'package:island/widgets/safety/abuse_report_helper.dart';
|
import 'package:island/widgets/safety/abuse_report_helper.dart';
|
||||||
import 'package:island/widgets/share/share_sheet.dart';
|
import 'package:island/widgets/share/share_sheet.dart';
|
||||||
@@ -542,10 +544,9 @@ class PostItem extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (item.meta?['embeds'] != null)
|
if (item.meta?['embeds'] != null)
|
||||||
...((item.meta!['embeds'] as List<dynamic>)
|
...((item.meta!['embeds'] as List<dynamic>).map(
|
||||||
.where((embed) => embed['Type'] == 'link')
|
(embedData) => switch (embedData['type']) {
|
||||||
.map(
|
'link' => EmbedLinkWidget(
|
||||||
(embedData) => EmbedLinkWidget(
|
|
||||||
link: SnEmbedLink.fromJson(embedData as Map<String, dynamic>),
|
link: SnEmbedLink.fromJson(embedData as Map<String, dynamic>),
|
||||||
maxWidth: math.min(
|
maxWidth: math.min(
|
||||||
MediaQuery.of(context).size.width,
|
MediaQuery.of(context).size.width,
|
||||||
@@ -558,6 +559,19 @@ class PostItem extends HookConsumerWidget {
|
|||||||
right: renderingPadding.horizontal,
|
right: renderingPadding.horizontal,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
'poll' => Card(
|
||||||
|
margin: EdgeInsets.symmetric(
|
||||||
|
horizontal: renderingPadding.horizontal,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
child: PollSubmit(
|
||||||
|
initialAnswers: embedData['poll']?['user_answer']?['answer'],
|
||||||
|
poll: SnPollWithStats.fromJson(embedData['poll']),
|
||||||
|
onSubmit: (_) {},
|
||||||
|
).padding(horizontal: 12, vertical: 8),
|
||||||
|
),
|
||||||
|
_ => const Placeholder(),
|
||||||
|
},
|
||||||
)),
|
)),
|
||||||
if (isShowReference)
|
if (isShowReference)
|
||||||
_buildReferencePost(context, item, renderingPadding),
|
_buildReferencePost(context, item, renderingPadding),
|
||||||
|
Reference in New Issue
Block a user