✨ Poll answer
This commit is contained in:
@@ -753,5 +753,13 @@
|
||||
"sensitiveCategories.gambling": "Gambling",
|
||||
"sensitiveCategories.selfHarm": "Self-harm",
|
||||
"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.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
|
||||
sealed class SnPoll with _$SnPoll {
|
||||
const factory SnPoll({
|
||||
@@ -59,9 +79,14 @@ sealed class SnPollOption with _$SnPollOption {
|
||||
}
|
||||
|
||||
enum SnPollQuestionType {
|
||||
@JsonValue(0)
|
||||
singleChoice,
|
||||
@JsonValue(1)
|
||||
multipleChoice,
|
||||
@JsonValue(2)
|
||||
yesNo,
|
||||
@JsonValue(3)
|
||||
rating,
|
||||
@JsonValue(4)
|
||||
freeText,
|
||||
}
|
||||
|
@@ -12,6 +12,313 @@ part of 'poll.dart';
|
||||
// dart format off
|
||||
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
|
||||
mixin _$SnPoll {
|
||||
|
||||
|
@@ -6,6 +6,45 @@ part of 'poll.dart';
|
||||
// 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(
|
||||
id: json['id'] as String,
|
||||
questions:
|
||||
@@ -70,11 +109,11 @@ Map<String, dynamic> _$SnPollQuestionToJson(_SnPollQuestion instance) =>
|
||||
};
|
||||
|
||||
const _$SnPollQuestionTypeEnumMap = {
|
||||
SnPollQuestionType.singleChoice: 'singleChoice',
|
||||
SnPollQuestionType.multipleChoice: 'multipleChoice',
|
||||
SnPollQuestionType.yesNo: 'yesNo',
|
||||
SnPollQuestionType.rating: 'rating',
|
||||
SnPollQuestionType.freeText: 'freeText',
|
||||
SnPollQuestionType.singleChoice: 0,
|
||||
SnPollQuestionType.multipleChoice: 1,
|
||||
SnPollQuestionType.yesNo: 2,
|
||||
SnPollQuestionType.rating: 3,
|
||||
SnPollQuestionType.freeText: 4,
|
||||
};
|
||||
|
||||
_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/widgets/alert.dart';
|
||||
import 'package:island/widgets/post/compose_link_attachments.dart';
|
||||
import 'package:island/widgets/post/compose_poll.dart';
|
||||
import 'package:island/widgets/post/compose_recorder.dart';
|
||||
import 'package:pasteboard/pasteboard.dart';
|
||||
import 'package:textfield_tags/textfield_tags.dart';
|
||||
@@ -33,6 +34,8 @@ class ComposeState {
|
||||
StringTagController categoriesController;
|
||||
final String draftId;
|
||||
int postType;
|
||||
// Linked poll id for this compose session (nullable)
|
||||
final ValueNotifier<String?> pollId;
|
||||
Timer? _autoSaveTimer;
|
||||
|
||||
ComposeState({
|
||||
@@ -48,7 +51,8 @@ class ComposeState {
|
||||
required this.categoriesController,
|
||||
required this.draftId,
|
||||
this.postType = 0,
|
||||
});
|
||||
String? pollId,
|
||||
}) : pollId = ValueNotifier<String?>(pollId);
|
||||
|
||||
void startAutoSave(WidgetRef ref) {
|
||||
_autoSaveTimer?.cancel();
|
||||
@@ -111,6 +115,8 @@ class ComposeLogic {
|
||||
categoriesController: categoriesController,
|
||||
draftId: id,
|
||||
postType: postType,
|
||||
// initialize without poll by default
|
||||
pollId: null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,6 +144,7 @@ class ComposeLogic {
|
||||
categoriesController: categoriesController,
|
||||
draftId: draft.id,
|
||||
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(
|
||||
WidgetRef ref,
|
||||
ComposeState state,
|
||||
@@ -613,6 +641,7 @@ class ComposeLogic {
|
||||
if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id,
|
||||
'tags': state.tagsController.getTags,
|
||||
'categories': state.categoriesController.getTags,
|
||||
if (state.pollId.value != null) 'poll_id': state.pollId.value,
|
||||
};
|
||||
|
||||
// Send request
|
||||
@@ -703,5 +732,6 @@ class ComposeLogic {
|
||||
state.currentPublisher.dispose();
|
||||
state.tagsController.dispose();
|
||||
state.categoriesController.dispose();
|
||||
state.pollId.dispose();
|
||||
}
|
||||
}
|
||||
|
@@ -36,6 +36,10 @@ class ComposeToolbar extends HookConsumerWidget {
|
||||
ComposeLogic.saveDraft(ref, state);
|
||||
}
|
||||
|
||||
void pickPoll() {
|
||||
ComposeLogic.pickPoll(ref, state, context);
|
||||
}
|
||||
|
||||
void showDraftManager() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -88,6 +92,25 @@ class ComposeToolbar extends HookConsumerWidget {
|
||||
tooltip: 'linkAttachment'.tr(),
|
||||
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(),
|
||||
if (originalPost == null && state.isEmpty)
|
||||
IconButton(
|
||||
|
@@ -8,6 +8,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/embed.dart';
|
||||
import 'package:island/models/poll.dart';
|
||||
import 'package:island/models/post.dart';
|
||||
import 'package:island/pods/network.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/embed/link.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/safety/abuse_report_helper.dart';
|
||||
import 'package:island/widgets/share/share_sheet.dart';
|
||||
@@ -542,23 +544,35 @@ class PostItem extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
if (item.meta?['embeds'] != null)
|
||||
...((item.meta!['embeds'] as List<dynamic>)
|
||||
.where((embed) => embed['Type'] == 'link')
|
||||
.map(
|
||||
(embedData) => EmbedLinkWidget(
|
||||
link: SnEmbedLink.fromJson(embedData as Map<String, dynamic>),
|
||||
maxWidth: math.min(
|
||||
MediaQuery.of(context).size.width,
|
||||
kWideScreenWidth,
|
||||
),
|
||||
margin: EdgeInsets.only(
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
left: renderingPadding.horizontal,
|
||||
right: renderingPadding.horizontal,
|
||||
),
|
||||
...((item.meta!['embeds'] as List<dynamic>).map(
|
||||
(embedData) => switch (embedData['type']) {
|
||||
'link' => EmbedLinkWidget(
|
||||
link: SnEmbedLink.fromJson(embedData as Map<String, dynamic>),
|
||||
maxWidth: math.min(
|
||||
MediaQuery.of(context).size.width,
|
||||
kWideScreenWidth,
|
||||
),
|
||||
)),
|
||||
margin: EdgeInsets.only(
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
left: 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)
|
||||
_buildReferencePost(context, item, renderingPadding),
|
||||
if (item.repliesCount > 0 && isEmbedReply)
|
||||
|
Reference in New Issue
Block a user