From a6d869ebf6ded8524a79bea9a69e4cc27a51124b Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Wed, 6 Aug 2025 01:37:38 +0800 Subject: [PATCH] :sparkles: Poll answer --- assets/i18n/en-US.json | 10 +- lib/models/poll.dart | 25 ++ lib/models/poll.freezed.dart | 307 ++++++++++++++++ lib/models/poll.g.dart | 49 ++- lib/widgets/poll/poll_submit.dart | 501 ++++++++++++++++++++++++++ lib/widgets/post/compose_poll.dart | 201 +++++++++++ lib/widgets/post/compose_shared.dart | 32 +- lib/widgets/post/compose_toolbar.dart | 23 ++ lib/widgets/post/post_item.dart | 46 ++- 9 files changed, 1171 insertions(+), 23 deletions(-) create mode 100644 lib/widgets/poll/poll_submit.dart create mode 100644 lib/widgets/post/compose_poll.dart diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 6811e6c..d5b7716 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -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: {}" } diff --git a/lib/models/poll.dart b/lib/models/poll.dart index f5f9067..996f145 100644 --- a/lib/models/poll.dart +++ b/lib/models/poll.dart @@ -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? userAnswer, + required Map stats, + required String id, + required List questions, + String? title, + String? description, + DateTime? endedAt, + required String publisherId, + required DateTime createdAt, + required DateTime updatedAt, + DateTime? deletedAt, + }) = _SnPollWithStats; + + factory SnPollWithStats.fromJson(Map 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, } diff --git a/lib/models/poll.freezed.dart b/lib/models/poll.freezed.dart index c1cfc92..7ba4909 100644 --- a/lib/models/poll.freezed.dart +++ b/lib/models/poll.freezed.dart @@ -12,6 +12,313 @@ part of 'poll.dart'; // dart format off T _$identity(T value) => value; +/// @nodoc +mixin _$SnPollWithStats { + + Map? get userAnswer; Map get stats; String get id; List 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 get copyWith => _$SnPollWithStatsCopyWithImpl(this as SnPollWithStats, _$identity); + + /// Serializes this SnPollWithStats to a JSON map. + Map 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? userAnswer, Map stats, String id, List 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?,stats: null == stats ? _self.stats : stats // ignore: cast_nullable_to_non_nullable +as Map,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,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 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 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? 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 Function( Map? userAnswer, Map stats, String id, List 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 Function( Map? userAnswer, Map stats, String id, List 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? Function( Map? userAnswer, Map stats, String id, List 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? userAnswer, required final Map stats, required this.id, required final List 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 json) => _$SnPollWithStatsFromJson(json); + + final Map? _userAnswer; +@override Map? 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 _stats; +@override Map get stats { + if (_stats is EqualUnmodifiableMapView) return _stats; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_stats); +} + +@override final String id; + final List _questions; +@override List 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 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? userAnswer, Map stats, String id, List 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?,stats: null == stats ? _self._stats : stats // ignore: cast_nullable_to_non_nullable +as Map,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,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 { diff --git a/lib/models/poll.g.dart b/lib/models/poll.g.dart index b607a68..9a07fde 100644 --- a/lib/models/poll.g.dart +++ b/lib/models/poll.g.dart @@ -6,6 +6,45 @@ part of 'poll.dart'; // JsonSerializableGenerator // ************************************************************************** +_SnPollWithStats _$SnPollWithStatsFromJson(Map json) => + _SnPollWithStats( + userAnswer: json['user_answer'] as Map?, + stats: json['stats'] as Map, + id: json['id'] as String, + questions: + (json['questions'] as List) + .map((e) => SnPollQuestion.fromJson(e as Map)) + .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 _$SnPollWithStatsToJson(_SnPollWithStats instance) => + { + '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 json) => _SnPoll( id: json['id'] as String, questions: @@ -70,11 +109,11 @@ Map _$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 json) => diff --git a/lib/widgets/poll/poll_submit.dart b/lib/widgets/poll/poll_submit.dart new file mode 100644 index 0000000..9d34da6 --- /dev/null +++ b/lib/widgets/poll/poll_submit.dart @@ -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: questionId -> answer +/// // answer types by question: +/// // - singleChoice: String optionId +/// // - multipleChoice: List 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 answers) onSubmit; + + /// Optional initial answers, keyed by questionId. + final Map? initialAnswers; + + /// Optional cancel callback. + final VoidCallback? onCancel; + + /// Whether to show a progress indicator (e.g., "2 / N"). + final bool showProgress; + + @override + ConsumerState createState() => _PollSubmitState(); +} + +class _PollSubmitState extends ConsumerState { + late final List _questions; + int _index = 0; + bool _submitting = false; + + /// Collected answers, keyed by questionId + late Map _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 _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.from(widget.initialAnswers ?? {}); + _loadCurrentIntoLocalState(); + } + + @override + void didUpdateWidget(covariant PollSubmit oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.poll.id != widget.poll.id) { + _index = 0; + _answers = Map.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()); + } + 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 _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.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( + 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( + 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( + 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, + ); + } +} diff --git a/lib/widgets/post/compose_poll.dart b/lib/widgets/post/compose_poll.dart new file mode 100644 index 0000000..35b200a --- /dev/null +++ b/lib/widgets/post/compose_poll.dart @@ -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(pubName); + final isPushing = useState(false); + final errorText = useState(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( + 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( + '/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? 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; + } + } +} diff --git a/lib/widgets/post/compose_shared.dart b/lib/widgets/post/compose_shared.dart index ae9c0fe..0fe3e1e 100644 --- a/lib/widgets/post/compose_shared.dart +++ b/lib/widgets/post/compose_shared.dart @@ -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 pollId; Timer? _autoSaveTimer; ComposeState({ @@ -48,7 +51,8 @@ class ComposeState { required this.categoriesController, required this.draftId, this.postType = 0, - }); + String? pollId, + }) : pollId = ValueNotifier(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 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 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(); } } diff --git a/lib/widgets/post/compose_toolbar.dart b/lib/widgets/post/compose_toolbar.dart index f433fab..33be281 100644 --- a/lib/widgets/post/compose_toolbar.dart +++ b/lib/widgets/post/compose_toolbar.dart @@ -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( diff --git a/lib/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart index 047aba7..6539491 100644 --- a/lib/widgets/post/post_item.dart +++ b/lib/widgets/post/post_item.dart @@ -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) - .where((embed) => embed['Type'] == 'link') - .map( - (embedData) => EmbedLinkWidget( - link: SnEmbedLink.fromJson(embedData as Map), - 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).map( + (embedData) => switch (embedData['type']) { + 'link' => EmbedLinkWidget( + link: SnEmbedLink.fromJson(embedData as Map), + 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)