Poll answer

This commit is contained in:
2025-08-06 01:37:38 +08:00
parent f3a8699389
commit a6d869ebf6
9 changed files with 1171 additions and 23 deletions

View File

@@ -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: {}"
} }

View File

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

View File

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

View File

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

View 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,
);
}
}

View 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;
}
}
}

View File

@@ -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();
} }
} }

View File

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

View File

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