diff --git a/lib/models/poll.dart b/lib/models/poll.dart new file mode 100644 index 0000000..f5f9067 --- /dev/null +++ b/lib/models/poll.dart @@ -0,0 +1,67 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:island/models/publisher.dart'; + +part 'poll.freezed.dart'; +part 'poll.g.dart'; + +@freezed +sealed class SnPoll with _$SnPoll { + const factory SnPoll({ + required String id, + required List questions, + + String? title, + String? description, + + DateTime? endedAt, + + required String publisherId, + SnPublisher? publisher, + + // ModelBase fields + required DateTime createdAt, + required DateTime updatedAt, + DateTime? deletedAt, + }) = _SnPoll; + + factory SnPoll.fromJson(Map json) => _$SnPollFromJson(json); +} + +@freezed +sealed class SnPollQuestion with _$SnPollQuestion { + const factory SnPollQuestion({ + required String id, + + required SnPollQuestionType type, + List? options, + + required String title, + String? description, + required int order, + required bool isRequired, + }) = _SnPollQuestion; + + factory SnPollQuestion.fromJson(Map json) => + _$SnPollQuestionFromJson(json); +} + +@freezed +sealed class SnPollOption with _$SnPollOption { + const factory SnPollOption({ + required String id, + required String label, + String? description, + required int order, + }) = _SnPollOption; + + factory SnPollOption.fromJson(Map json) => + _$SnPollOptionFromJson(json); +} + +enum SnPollQuestionType { + singleChoice, + multipleChoice, + yesNo, + rating, + freeText, +} diff --git a/lib/models/poll.freezed.dart b/lib/models/poll.freezed.dart new file mode 100644 index 0000000..c1cfc92 --- /dev/null +++ b/lib/models/poll.freezed.dart @@ -0,0 +1,879 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'poll.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$SnPoll { + + String get id; List get questions; String? get title; String? get description; DateTime? get endedAt; String get publisherId; SnPublisher? get publisher;// ModelBase fields + DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; +/// Create a copy of SnPoll +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SnPollCopyWith get copyWith => _$SnPollCopyWithImpl(this as SnPoll, _$identity); + + /// Serializes this SnPoll to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnPoll&&(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.publisher, publisher) || other.publisher == publisher)&&(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,id,const DeepCollectionEquality().hash(questions),title,description,endedAt,publisherId,publisher,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'SnPoll(id: $id, questions: $questions, title: $title, description: $description, endedAt: $endedAt, publisherId: $publisherId, publisher: $publisher, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class $SnPollCopyWith<$Res> { + factory $SnPollCopyWith(SnPoll value, $Res Function(SnPoll) _then) = _$SnPollCopyWithImpl; +@useResult +$Res call({ + String id, List questions, String? title, String? description, DateTime? endedAt, String publisherId, SnPublisher? publisher, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + +$SnPublisherCopyWith<$Res>? get publisher; + +} +/// @nodoc +class _$SnPollCopyWithImpl<$Res> + implements $SnPollCopyWith<$Res> { + _$SnPollCopyWithImpl(this._self, this._then); + + final SnPoll _self; + final $Res Function(SnPoll) _then; + +/// Create a copy of SnPoll +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? questions = null,Object? title = freezed,Object? description = freezed,Object? endedAt = freezed,Object? publisherId = null,Object? publisher = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { + return _then(_self.copyWith( +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,publisher: freezed == publisher ? _self.publisher : publisher // ignore: cast_nullable_to_non_nullable +as SnPublisher?,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?, + )); +} +/// Create a copy of SnPoll +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SnPublisherCopyWith<$Res>? get publisher { + if (_self.publisher == null) { + return null; + } + + return $SnPublisherCopyWith<$Res>(_self.publisher!, (value) { + return _then(_self.copyWith(publisher: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [SnPoll]. +extension SnPollPatterns on SnPoll { +/// 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( _SnPoll value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _SnPoll() 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( _SnPoll value) $default,){ +final _that = this; +switch (_that) { +case _SnPoll(): +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( _SnPoll value)? $default,){ +final _that = this; +switch (_that) { +case _SnPoll() 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( String id, List questions, String? title, String? description, DateTime? endedAt, String publisherId, SnPublisher? publisher, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _SnPoll() when $default != null: +return $default(_that.id,_that.questions,_that.title,_that.description,_that.endedAt,_that.publisherId,_that.publisher,_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( String id, List questions, String? title, String? description, DateTime? endedAt, String publisherId, SnPublisher? publisher, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this; +switch (_that) { +case _SnPoll(): +return $default(_that.id,_that.questions,_that.title,_that.description,_that.endedAt,_that.publisherId,_that.publisher,_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( String id, List questions, String? title, String? description, DateTime? endedAt, String publisherId, SnPublisher? publisher, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this; +switch (_that) { +case _SnPoll() when $default != null: +return $default(_that.id,_that.questions,_that.title,_that.description,_that.endedAt,_that.publisherId,_that.publisher,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _SnPoll implements SnPoll { + const _SnPoll({required this.id, required final List questions, this.title, this.description, this.endedAt, required this.publisherId, this.publisher, required this.createdAt, required this.updatedAt, this.deletedAt}): _questions = questions; + factory _SnPoll.fromJson(Map json) => _$SnPollFromJson(json); + +@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 SnPublisher? publisher; +// ModelBase fields +@override final DateTime createdAt; +@override final DateTime updatedAt; +@override final DateTime? deletedAt; + +/// Create a copy of SnPoll +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SnPollCopyWith<_SnPoll> get copyWith => __$SnPollCopyWithImpl<_SnPoll>(this, _$identity); + +@override +Map toJson() { + return _$SnPollToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnPoll&&(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.publisher, publisher) || other.publisher == publisher)&&(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,id,const DeepCollectionEquality().hash(_questions),title,description,endedAt,publisherId,publisher,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'SnPoll(id: $id, questions: $questions, title: $title, description: $description, endedAt: $endedAt, publisherId: $publisherId, publisher: $publisher, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class _$SnPollCopyWith<$Res> implements $SnPollCopyWith<$Res> { + factory _$SnPollCopyWith(_SnPoll value, $Res Function(_SnPoll) _then) = __$SnPollCopyWithImpl; +@override @useResult +$Res call({ + String id, List questions, String? title, String? description, DateTime? endedAt, String publisherId, SnPublisher? publisher, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + +@override $SnPublisherCopyWith<$Res>? get publisher; + +} +/// @nodoc +class __$SnPollCopyWithImpl<$Res> + implements _$SnPollCopyWith<$Res> { + __$SnPollCopyWithImpl(this._self, this._then); + + final _SnPoll _self; + final $Res Function(_SnPoll) _then; + +/// Create a copy of SnPoll +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? questions = null,Object? title = freezed,Object? description = freezed,Object? endedAt = freezed,Object? publisherId = null,Object? publisher = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { + return _then(_SnPoll( +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,publisher: freezed == publisher ? _self.publisher : publisher // ignore: cast_nullable_to_non_nullable +as SnPublisher?,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?, + )); +} + +/// Create a copy of SnPoll +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SnPublisherCopyWith<$Res>? get publisher { + if (_self.publisher == null) { + return null; + } + + return $SnPublisherCopyWith<$Res>(_self.publisher!, (value) { + return _then(_self.copyWith(publisher: value)); + }); +} +} + + +/// @nodoc +mixin _$SnPollQuestion { + + String get id; SnPollQuestionType get type; List? get options; String get title; String? get description; int get order; bool get isRequired; +/// Create a copy of SnPollQuestion +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SnPollQuestionCopyWith get copyWith => _$SnPollQuestionCopyWithImpl(this as SnPollQuestion, _$identity); + + /// Serializes this SnPollQuestion to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnPollQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other.options, options)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.order, order) || other.order == order)&&(identical(other.isRequired, isRequired) || other.isRequired == isRequired)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,type,const DeepCollectionEquality().hash(options),title,description,order,isRequired); + +@override +String toString() { + return 'SnPollQuestion(id: $id, type: $type, options: $options, title: $title, description: $description, order: $order, isRequired: $isRequired)'; +} + + +} + +/// @nodoc +abstract mixin class $SnPollQuestionCopyWith<$Res> { + factory $SnPollQuestionCopyWith(SnPollQuestion value, $Res Function(SnPollQuestion) _then) = _$SnPollQuestionCopyWithImpl; +@useResult +$Res call({ + String id, SnPollQuestionType type, List? options, String title, String? description, int order, bool isRequired +}); + + + + +} +/// @nodoc +class _$SnPollQuestionCopyWithImpl<$Res> + implements $SnPollQuestionCopyWith<$Res> { + _$SnPollQuestionCopyWithImpl(this._self, this._then); + + final SnPollQuestion _self; + final $Res Function(SnPollQuestion) _then; + +/// Create a copy of SnPollQuestion +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? type = null,Object? options = freezed,Object? title = null,Object? description = freezed,Object? order = null,Object? isRequired = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as SnPollQuestionType,options: freezed == options ? _self.options : options // ignore: cast_nullable_to_non_nullable +as List?,title: null == 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?,order: null == order ? _self.order : order // ignore: cast_nullable_to_non_nullable +as int,isRequired: null == isRequired ? _self.isRequired : isRequired // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [SnPollQuestion]. +extension SnPollQuestionPatterns on SnPollQuestion { +/// 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( _SnPollQuestion value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _SnPollQuestion() 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( _SnPollQuestion value) $default,){ +final _that = this; +switch (_that) { +case _SnPollQuestion(): +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( _SnPollQuestion value)? $default,){ +final _that = this; +switch (_that) { +case _SnPollQuestion() 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( String id, SnPollQuestionType type, List? options, String title, String? description, int order, bool isRequired)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _SnPollQuestion() when $default != null: +return $default(_that.id,_that.type,_that.options,_that.title,_that.description,_that.order,_that.isRequired);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( String id, SnPollQuestionType type, List? options, String title, String? description, int order, bool isRequired) $default,) {final _that = this; +switch (_that) { +case _SnPollQuestion(): +return $default(_that.id,_that.type,_that.options,_that.title,_that.description,_that.order,_that.isRequired);} +} +/// 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( String id, SnPollQuestionType type, List? options, String title, String? description, int order, bool isRequired)? $default,) {final _that = this; +switch (_that) { +case _SnPollQuestion() when $default != null: +return $default(_that.id,_that.type,_that.options,_that.title,_that.description,_that.order,_that.isRequired);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _SnPollQuestion implements SnPollQuestion { + const _SnPollQuestion({required this.id, required this.type, final List? options, required this.title, this.description, required this.order, required this.isRequired}): _options = options; + factory _SnPollQuestion.fromJson(Map json) => _$SnPollQuestionFromJson(json); + +@override final String id; +@override final SnPollQuestionType type; + final List? _options; +@override List? get options { + final value = _options; + if (value == null) return null; + if (_options is EqualUnmodifiableListView) return _options; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); +} + +@override final String title; +@override final String? description; +@override final int order; +@override final bool isRequired; + +/// Create a copy of SnPollQuestion +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SnPollQuestionCopyWith<_SnPollQuestion> get copyWith => __$SnPollQuestionCopyWithImpl<_SnPollQuestion>(this, _$identity); + +@override +Map toJson() { + return _$SnPollQuestionToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnPollQuestion&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other._options, _options)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.order, order) || other.order == order)&&(identical(other.isRequired, isRequired) || other.isRequired == isRequired)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,type,const DeepCollectionEquality().hash(_options),title,description,order,isRequired); + +@override +String toString() { + return 'SnPollQuestion(id: $id, type: $type, options: $options, title: $title, description: $description, order: $order, isRequired: $isRequired)'; +} + + +} + +/// @nodoc +abstract mixin class _$SnPollQuestionCopyWith<$Res> implements $SnPollQuestionCopyWith<$Res> { + factory _$SnPollQuestionCopyWith(_SnPollQuestion value, $Res Function(_SnPollQuestion) _then) = __$SnPollQuestionCopyWithImpl; +@override @useResult +$Res call({ + String id, SnPollQuestionType type, List? options, String title, String? description, int order, bool isRequired +}); + + + + +} +/// @nodoc +class __$SnPollQuestionCopyWithImpl<$Res> + implements _$SnPollQuestionCopyWith<$Res> { + __$SnPollQuestionCopyWithImpl(this._self, this._then); + + final _SnPollQuestion _self; + final $Res Function(_SnPollQuestion) _then; + +/// Create a copy of SnPollQuestion +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? type = null,Object? options = freezed,Object? title = null,Object? description = freezed,Object? order = null,Object? isRequired = null,}) { + return _then(_SnPollQuestion( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as SnPollQuestionType,options: freezed == options ? _self._options : options // ignore: cast_nullable_to_non_nullable +as List?,title: null == 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?,order: null == order ? _self.order : order // ignore: cast_nullable_to_non_nullable +as int,isRequired: null == isRequired ? _self.isRequired : isRequired // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + + +/// @nodoc +mixin _$SnPollOption { + + String get id; String get label; String? get description; int get order; +/// Create a copy of SnPollOption +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SnPollOptionCopyWith get copyWith => _$SnPollOptionCopyWithImpl(this as SnPollOption, _$identity); + + /// Serializes this SnPollOption to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnPollOption&&(identical(other.id, id) || other.id == id)&&(identical(other.label, label) || other.label == label)&&(identical(other.description, description) || other.description == description)&&(identical(other.order, order) || other.order == order)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,label,description,order); + +@override +String toString() { + return 'SnPollOption(id: $id, label: $label, description: $description, order: $order)'; +} + + +} + +/// @nodoc +abstract mixin class $SnPollOptionCopyWith<$Res> { + factory $SnPollOptionCopyWith(SnPollOption value, $Res Function(SnPollOption) _then) = _$SnPollOptionCopyWithImpl; +@useResult +$Res call({ + String id, String label, String? description, int order +}); + + + + +} +/// @nodoc +class _$SnPollOptionCopyWithImpl<$Res> + implements $SnPollOptionCopyWith<$Res> { + _$SnPollOptionCopyWithImpl(this._self, this._then); + + final SnPollOption _self; + final $Res Function(SnPollOption) _then; + +/// Create a copy of SnPollOption +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? label = null,Object? description = freezed,Object? order = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable +as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String?,order: null == order ? _self.order : order // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +} + + +/// Adds pattern-matching-related methods to [SnPollOption]. +extension SnPollOptionPatterns on SnPollOption { +/// 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( _SnPollOption value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _SnPollOption() 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( _SnPollOption value) $default,){ +final _that = this; +switch (_that) { +case _SnPollOption(): +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( _SnPollOption value)? $default,){ +final _that = this; +switch (_that) { +case _SnPollOption() 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( String id, String label, String? description, int order)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _SnPollOption() when $default != null: +return $default(_that.id,_that.label,_that.description,_that.order);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( String id, String label, String? description, int order) $default,) {final _that = this; +switch (_that) { +case _SnPollOption(): +return $default(_that.id,_that.label,_that.description,_that.order);} +} +/// 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( String id, String label, String? description, int order)? $default,) {final _that = this; +switch (_that) { +case _SnPollOption() when $default != null: +return $default(_that.id,_that.label,_that.description,_that.order);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _SnPollOption implements SnPollOption { + const _SnPollOption({required this.id, required this.label, this.description, required this.order}); + factory _SnPollOption.fromJson(Map json) => _$SnPollOptionFromJson(json); + +@override final String id; +@override final String label; +@override final String? description; +@override final int order; + +/// Create a copy of SnPollOption +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SnPollOptionCopyWith<_SnPollOption> get copyWith => __$SnPollOptionCopyWithImpl<_SnPollOption>(this, _$identity); + +@override +Map toJson() { + return _$SnPollOptionToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnPollOption&&(identical(other.id, id) || other.id == id)&&(identical(other.label, label) || other.label == label)&&(identical(other.description, description) || other.description == description)&&(identical(other.order, order) || other.order == order)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,label,description,order); + +@override +String toString() { + return 'SnPollOption(id: $id, label: $label, description: $description, order: $order)'; +} + + +} + +/// @nodoc +abstract mixin class _$SnPollOptionCopyWith<$Res> implements $SnPollOptionCopyWith<$Res> { + factory _$SnPollOptionCopyWith(_SnPollOption value, $Res Function(_SnPollOption) _then) = __$SnPollOptionCopyWithImpl; +@override @useResult +$Res call({ + String id, String label, String? description, int order +}); + + + + +} +/// @nodoc +class __$SnPollOptionCopyWithImpl<$Res> + implements _$SnPollOptionCopyWith<$Res> { + __$SnPollOptionCopyWithImpl(this._self, this._then); + + final _SnPollOption _self; + final $Res Function(_SnPollOption) _then; + +/// Create a copy of SnPollOption +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? label = null,Object? description = freezed,Object? order = null,}) { + return _then(_SnPollOption( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable +as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String?,order: null == order ? _self.order : order // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +// dart format on diff --git a/lib/models/poll.g.dart b/lib/models/poll.g.dart new file mode 100644 index 0000000..b607a68 --- /dev/null +++ b/lib/models/poll.g.dart @@ -0,0 +1,94 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'poll.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_SnPoll _$SnPollFromJson(Map json) => _SnPoll( + 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, + publisher: + json['publisher'] == null + ? null + : SnPublisher.fromJson(json['publisher'] as Map), + 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 _$SnPollToJson(_SnPoll instance) => { + '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, + 'publisher': instance.publisher?.toJson(), + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'deleted_at': instance.deletedAt?.toIso8601String(), +}; + +_SnPollQuestion _$SnPollQuestionFromJson(Map json) => + _SnPollQuestion( + id: json['id'] as String, + type: $enumDecode(_$SnPollQuestionTypeEnumMap, json['type']), + options: + (json['options'] as List?) + ?.map((e) => SnPollOption.fromJson(e as Map)) + .toList(), + title: json['title'] as String, + description: json['description'] as String?, + order: (json['order'] as num).toInt(), + isRequired: json['is_required'] as bool, + ); + +Map _$SnPollQuestionToJson(_SnPollQuestion instance) => + { + 'id': instance.id, + 'type': _$SnPollQuestionTypeEnumMap[instance.type]!, + 'options': instance.options?.map((e) => e.toJson()).toList(), + 'title': instance.title, + 'description': instance.description, + 'order': instance.order, + 'is_required': instance.isRequired, + }; + +const _$SnPollQuestionTypeEnumMap = { + SnPollQuestionType.singleChoice: 'singleChoice', + SnPollQuestionType.multipleChoice: 'multipleChoice', + SnPollQuestionType.yesNo: 'yesNo', + SnPollQuestionType.rating: 'rating', + SnPollQuestionType.freeText: 'freeText', +}; + +_SnPollOption _$SnPollOptionFromJson(Map json) => + _SnPollOption( + id: json['id'] as String, + label: json['label'] as String, + description: json['description'] as String?, + order: (json['order'] as num).toInt(), + ); + +Map _$SnPollOptionToJson(_SnPollOption instance) => + { + 'id': instance.id, + 'label': instance.label, + 'description': instance.description, + 'order': instance.order, + }; diff --git a/lib/route.dart b/lib/route.dart index 1cc0e73..dcd1319 100644 --- a/lib/route.dart +++ b/lib/route.dart @@ -28,9 +28,11 @@ import 'package:island/screens/creators/hub.dart'; import 'package:island/screens/creators/posts/post_manage_list.dart'; import 'package:island/screens/creators/stickers/stickers.dart'; import 'package:island/screens/creators/stickers/pack_detail.dart'; +import 'package:island/screens/creators/poll/poll_list.dart'; import 'package:island/screens/creators/publishers.dart'; import 'package:island/screens/creators/webfeed/webfeed_list.dart'; import 'package:island/screens/creators/webfeed/webfeed_edit.dart'; +import 'package:island/screens/poll/poll_editor.dart'; import 'package:island/screens/posts/compose.dart'; import 'package:island/screens/posts/post_detail.dart'; import 'package:island/screens/posts/pub_profile.dart'; @@ -144,6 +146,37 @@ final routerProvider = Provider((ref) { return CreatorPostListScreen(pubName: name); }, ), + // Poll list route + GoRoute( + name: 'creatorPolls', + path: '/creators/:name/polls', + builder: (context, state) { + final name = state.pathParameters['name']!; + return CreatorPollListScreen(pubName: name); + }, + ), + // Poll routes + GoRoute( + name: 'creatorPollNew', + path: '/creators/:name/polls/new', + builder: (context, state) { + final name = state.pathParameters['name']!; + // initialPollId left null for create; initialPublisher prefilled + return PollEditorScreen(initialPublisher: name); + }, + ), + GoRoute( + name: 'creatorPollEdit', + path: '/creators/:name/polls/:id/edit', + builder: (context, state) { + final name = state.pathParameters['name']!; + final id = state.pathParameters['id']!; + return PollEditorScreen( + initialPollId: id, + initialPublisher: name, + ); + }, + ), GoRoute( name: 'creatorStickers', path: '/creators/:name/stickers', diff --git a/lib/screens/creators/hub.dart b/lib/screens/creators/hub.dart index 815efda..ffc612f 100644 --- a/lib/screens/creators/hub.dart +++ b/lib/screens/creators/hub.dart @@ -380,6 +380,23 @@ class CreatorHubScreen extends HookConsumerWidget { ); }, ), + ListTile( + minTileHeight: 48, + title: const Text('Polls'), + trailing: const Icon(Symbols.chevron_right), + leading: const Icon(Symbols.poll), + contentPadding: const EdgeInsets.symmetric( + horizontal: 24, + ), + onTap: () { + context.pushNamed( + 'creatorPolls', + pathParameters: { + 'name': currentPublisher.value!.name, + }, + ); + }, + ), ListTile( minTileHeight: 48, title: Text('publisherMembers').tr(), diff --git a/lib/screens/creators/poll/poll_list.dart b/lib/screens/creators/poll/poll_list.dart new file mode 100644 index 0000000..89ff31d --- /dev/null +++ b/lib/screens/creators/poll/poll_list.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/poll.dart'; +import 'package:island/pods/network.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; + +part 'poll_list.g.dart'; + +@riverpod +class PollListNotifier extends _$PollListNotifier + with CursorPagingNotifierMixin { + static const int _pageSize = 20; + + @override + Future> build(String? pubName) { + // immediately load first page + return fetch(cursor: null); + } + + @override + Future> fetch({required String? cursor}) async { + final client = ref.read(apiClientProvider); + final offset = cursor == null ? 0 : int.parse(cursor); + + // read the current family argument passed to provider + final currentPub = pubName; + final queryParams = { + 'offset': offset, + 'take': _pageSize, + if (currentPub != null) 'pub': currentPub, + }; + + final response = await client.get( + '/sphere/polls/me', + queryParameters: queryParams, + ); + final total = int.parse(response.headers.value('X-Total') ?? '0'); + final List data = response.data; + final items = data.map((json) => SnPoll.fromJson(json)).toList(); + + final hasMore = offset + items.length < total; + final nextCursor = hasMore ? (offset + items.length).toString() : null; + + return CursorPagingData( + items: items, + hasMore: hasMore, + nextCursor: nextCursor, + ); + } +} + +class CreatorPollListScreen extends HookConsumerWidget { + const CreatorPollListScreen({super.key, required this.pubName}); + + final String pubName; + + Future _createPoll(BuildContext context) async { + // Use named route defined in router with :name param (creatorPollNew) + final result = await GoRouter.of( + context, + ).pushNamed('creatorPollNew', pathParameters: {'name': pubName}); + // If PollEditorScreen returns a created SnPoll on success, pop back with it + if (result is SnPoll && context.mounted) { + Navigator.of(context).maybePop(result); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: AppBar(title: const Text('Polls')), + floatingActionButton: FloatingActionButton( + onPressed: () => _createPoll(context), + child: const Icon(Icons.add), + ), + body: RefreshIndicator( + onRefresh: () => ref.refresh(pollListNotifierProvider(pubName).future), + child: CustomScrollView( + slivers: [ + PagingHelperSliverView( + provider: pollListNotifierProvider(pubName), + futureRefreshable: pollListNotifierProvider(pubName).future, + notifierRefreshable: pollListNotifierProvider(pubName).notifier, + contentBuilder: + (data, widgetCount, endItemView) => SliverList.builder( + itemCount: widgetCount, + itemBuilder: (context, index) { + if (index == widgetCount - 1) { + return endItemView; + } + final poll = data.items[index]; + return _CreatorPollItem(poll: poll); + }, + ), + ), + ], + ), + ), + ); + } +} + +class _CreatorPollItem extends StatelessWidget { + const _CreatorPollItem({required this.poll}); + + final SnPoll poll; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final ended = poll.endedAt; + final endedText = + ended == null + ? 'No end' + : MaterialLocalizations.of(context).formatFullDate(ended); + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + clipBehavior: Clip.antiAlias, + child: ListTile( + title: Text(poll.title ?? 'Untitled poll'), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (poll.description != null && poll.description!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + poll.description!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'Questions: ${poll.questions.length} · Ends: $endedText', + style: theme.textTheme.bodySmall, + ), + ), + ], + ), + trailing: PopupMenuButton( + onSelected: (v) { + switch (v) { + case 'edit': + // Use global router helper if desired + // context.push('/creators/${poll.publisher?.name ?? ''}/polls/${poll.id}/edit'); + Navigator.of(context).pushNamed( + 'creatorPollEdit', + arguments: { + 'name': poll.publisher?.name ?? '', + 'id': poll.id, + }, + ); + break; + } + }, + itemBuilder: + (context) => [ + const PopupMenuItem(value: 'edit', child: Text('Edit')), + ], + ), + onTap: () { + // Open editor for edit + // Navigator push by path to keep consistency with rest of app: + // Note: pub name string may be required in route; when absent, route may need query or pick later. + // For safety, just do nothing if no publisher in list item. + }, + ), + ); + } +} diff --git a/lib/screens/creators/poll/poll_list.g.dart b/lib/screens/creators/poll/poll_list.g.dart new file mode 100644 index 0000000..cd4e297 --- /dev/null +++ b/lib/screens/creators/poll/poll_list.g.dart @@ -0,0 +1,179 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'poll_list.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$pollListNotifierHash() => r'd3da24ff6bbb8f35b06d57fc41625dc0312508e4'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$PollListNotifier + extends BuildlessAutoDisposeAsyncNotifier> { + late final String? pubName; + + FutureOr> build(String? pubName); +} + +/// See also [PollListNotifier]. +@ProviderFor(PollListNotifier) +const pollListNotifierProvider = PollListNotifierFamily(); + +/// See also [PollListNotifier]. +class PollListNotifierFamily + extends Family>> { + /// See also [PollListNotifier]. + const PollListNotifierFamily(); + + /// See also [PollListNotifier]. + PollListNotifierProvider call(String? pubName) { + return PollListNotifierProvider(pubName); + } + + @override + PollListNotifierProvider getProviderOverride( + covariant PollListNotifierProvider provider, + ) { + return call(provider.pubName); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'pollListNotifierProvider'; +} + +/// See also [PollListNotifier]. +class PollListNotifierProvider + extends + AutoDisposeAsyncNotifierProviderImpl< + PollListNotifier, + CursorPagingData + > { + /// See also [PollListNotifier]. + PollListNotifierProvider(String? pubName) + : this._internal( + () => PollListNotifier()..pubName = pubName, + from: pollListNotifierProvider, + name: r'pollListNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$pollListNotifierHash, + dependencies: PollListNotifierFamily._dependencies, + allTransitiveDependencies: + PollListNotifierFamily._allTransitiveDependencies, + pubName: pubName, + ); + + PollListNotifierProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.pubName, + }) : super.internal(); + + final String? pubName; + + @override + FutureOr> runNotifierBuild( + covariant PollListNotifier notifier, + ) { + return notifier.build(pubName); + } + + @override + Override overrideWith(PollListNotifier Function() create) { + return ProviderOverride( + origin: this, + override: PollListNotifierProvider._internal( + () => create()..pubName = pubName, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + pubName: pubName, + ), + ); + } + + @override + AutoDisposeAsyncNotifierProviderElement< + PollListNotifier, + CursorPagingData + > + createElement() { + return _PollListNotifierProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is PollListNotifierProvider && other.pubName == pubName; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, pubName.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin PollListNotifierRef + on AutoDisposeAsyncNotifierProviderRef> { + /// The parameter `pubName` of this provider. + String? get pubName; +} + +class _PollListNotifierProviderElement + extends + AutoDisposeAsyncNotifierProviderElement< + PollListNotifier, + CursorPagingData + > + with PollListNotifierRef { + _PollListNotifierProviderElement(super.provider); + + @override + String? get pubName => (origin as PollListNotifierProvider).pubName; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/screens/poll/poll_editor.dart b/lib/screens/poll/poll_editor.dart new file mode 100644 index 0000000..45ddd04 --- /dev/null +++ b/lib/screens/poll/poll_editor.dart @@ -0,0 +1,1095 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:dio/dio.dart'; +import 'package:gap/gap.dart'; +import 'package:island/pods/network.dart'; +import 'package:island/widgets/alert.dart'; +import 'package:island/widgets/post/publishers_modal.dart'; +import 'package:island/models/poll.dart'; + +class PollEditorState { + String? id; // for editing + String? title; + String? description; + DateTime? endedAt; + List questions; + + PollEditorState({ + this.id, + this.title, + this.description, + this.endedAt, + List? questions, + }) : questions = questions ?? const []; +} + +/// Riverpod Notifier state +class PollEditor extends Notifier { + @override + PollEditorState build() { + return PollEditorState(); + } + + void setTitle(String? value) { + state = PollEditorState( + id: state.id, + title: value, + description: state.description, + endedAt: state.endedAt, + questions: [...state.questions], + ); + } + + void setDescription(String? value) { + state = PollEditorState( + id: state.id, + title: state.title, + description: value, + endedAt: state.endedAt, + questions: [...state.questions], + ); + } + + void setEndedAt(DateTime? value) { + state = PollEditorState( + id: state.id, + title: state.title, + description: state.description, + endedAt: value, + questions: [...state.questions], + ); + } + + void setEditingId(String? id) { + state = PollEditorState( + id: id, + title: state.title, + description: state.description, + endedAt: state.endedAt, + questions: [...state.questions], + ); + } + + void addQuestion(SnPollQuestionType type) { + final nextOrder = state.questions.length; + final isOptionsType = _isOptionsType(type); + final q = SnPollQuestion( + id: 'local-$nextOrder', + type: type, + options: + isOptionsType + ? [SnPollOption(id: 'opt-0', label: 'Option 1', order: 0)] + : null, + title: '', + description: null, + order: nextOrder, + isRequired: false, + ); + state = PollEditorState( + id: state.id, + title: state.title, + description: state.description, + endedAt: state.endedAt, + questions: [...state.questions, q], + ); + } + + void removeQuestion(int index) { + if (index < 0 || index >= state.questions.length) return; + final updated = [...state.questions]..removeAt(index); + for (var i = 0; i < updated.length; i++) { + updated[i] = updated[i].copyWith(order: i); + } + state = PollEditorState( + id: state.id, + title: state.title, + description: state.description, + endedAt: state.endedAt, + questions: updated, + ); + } + + void moveQuestionUp(int index) { + if (index <= 0 || index >= state.questions.length) return; + final updated = [...state.questions]; + final tmp = updated[index - 1]; + updated[index - 1] = updated[index]; + updated[index] = tmp; + for (var i = 0; i < updated.length; i++) { + updated[i] = updated[i].copyWith(order: i); + } + state = PollEditorState( + id: state.id, + title: state.title, + description: state.description, + endedAt: state.endedAt, + questions: updated, + ); + } + + void moveQuestionDown(int index) { + if (index < 0 || index >= state.questions.length - 1) return; + final updated = [...state.questions]; + final tmp = updated[index + 1]; + updated[index + 1] = updated[index]; + updated[index] = tmp; + for (var i = 0; i < updated.length; i++) { + updated[i] = updated[i].copyWith(order: i); + } + state = PollEditorState( + id: state.id, + title: state.title, + description: state.description, + endedAt: state.endedAt, + questions: updated, + ); + } + + void setQuestionType(int index, SnPollQuestionType type) { + if (index < 0 || index >= state.questions.length) return; + final q = state.questions[index]; + final isOptionsType = _isOptionsType(type); + final newOptions = + isOptionsType + ? (q.options?.isNotEmpty == true + ? q.options + : [SnPollOption(id: 'opt-0', label: 'Option 1', order: 0)]) + : null; + _updateQuestion(index, q.copyWith(type: type, options: newOptions)); + } + + void setQuestionTitle(int index, String title) { + if (index < 0 || index >= state.questions.length) return; + final q = state.questions[index]; + _updateQuestion(index, q.copyWith(title: title)); + } + + void setQuestionDescription(int index, String? description) { + if (index < 0 || index >= state.questions.length) return; + final q = state.questions[index]; + _updateQuestion(index, q.copyWith(description: description)); + } + + void setQuestionRequired(int index, bool value) { + if (index < 0 || index >= state.questions.length) return; + final q = state.questions[index]; + _updateQuestion(index, q.copyWith(isRequired: value)); + } + + void addOption(int qIndex) { + if (qIndex < 0 || qIndex >= state.questions.length) return; + final q = state.questions[qIndex]; + if (!_isOptionsType(q.type)) return; + final opts = [...(q.options ?? [])]; + final nextOrder = opts.length; + opts.add( + SnPollOption( + id: 'opt-$nextOrder', + label: 'Option ${nextOrder + 1}', + order: nextOrder, + ), + ); + _updateQuestion(qIndex, q.copyWith(options: opts)); + } + + void removeOption(int qIndex, int optIndex) { + if (qIndex < 0 || qIndex >= state.questions.length) return; + final q = state.questions[qIndex]; + if (!_isOptionsType(q.type)) return; + final opts = [...(q.options ?? [])]; + if (optIndex < 0 || optIndex >= opts.length) return; + opts.removeAt(optIndex); + for (var i = 0; i < opts.length; i++) { + opts[i] = opts[i].copyWith(order: i); + } + _updateQuestion(qIndex, q.copyWith(options: opts)); + } + + List _moveOptionByDelta( + List original, + int idx, + int delta, + ) { + if (idx + delta < 0 || idx + delta >= original.length) { + return original; + } + final clone = List.from(original); + clone.insert(idx + delta, clone.removeAt(idx)); + for (var i = 0; i < clone.length; i++) { + clone[i] = clone[i].copyWith(order: i); + } + return clone; + } + + void moveOptionUp(int qIndex, int optIndex) { + if (qIndex < 0 || qIndex >= state.questions.length) return; + final q = state.questions[qIndex]; + if (!_isOptionsType(q.type)) return; + final original = q.options ?? const []; + if (optIndex <= 0 || optIndex >= original.length) return; + + final reordered = _moveOptionByDelta(original, optIndex, -1); + if (!identical(reordered, original)) { + _updateQuestion(qIndex, q.copyWith(options: reordered)); + } + } + + void moveOptionDown(int qIndex, int optIndex) { + if (qIndex < 0 || qIndex >= state.questions.length) return; + final q = state.questions[qIndex]; + if (!_isOptionsType(q.type)) return; + final original = q.options ?? const []; + if (optIndex < 0 || optIndex >= original.length - 1) return; + + final reordered = _moveOptionByDelta(original, optIndex, 1); + if (!identical(reordered, original)) { + _updateQuestion(qIndex, q.copyWith(options: reordered)); + } + } + + void setOptionLabel(int qIndex, int optIndex, String label) { + final q = state.questions[qIndex]; + if (!_isOptionsType(q.type)) return; + final opts = [...(q.options ?? [])]; + if (optIndex < 0 || optIndex >= opts.length) return; + opts[optIndex] = opts[optIndex].copyWith(label: label); + _updateQuestion(qIndex, q.copyWith(options: opts)); + } + + void setOptionDescription(int qIndex, int optIndex, String? description) { + final q = state.questions[qIndex]; + if (!_isOptionsType(q.type)) return; + final opts = [...(q.options ?? [])]; + if (optIndex < 0 || optIndex >= opts.length) return; + opts[optIndex] = opts[optIndex].copyWith(description: description); + _updateQuestion(qIndex, q.copyWith(options: opts)); + } + + bool _isOptionsType(SnPollQuestionType type) { + return type == SnPollQuestionType.singleChoice || + type == SnPollQuestionType.multipleChoice; + } + + void _updateQuestion(int index, SnPollQuestion newQ) { + final list = [...state.questions]; + list[index] = newQ; + state = PollEditorState( + id: state.id, + title: state.title, + description: state.description, + endedAt: state.endedAt, + questions: list, + ); + } +} + +/// The poll editor screen. +/// Note: This is UI only; wire API later. Requires riverpod_generator and build_runner to generate .g.dart. +final pollEditorProvider = NotifierProvider( + PollEditor.new, +); + +class PollEditorScreen extends ConsumerWidget { + const PollEditorScreen({ + super.key, + this.initialPollId, + this.initialPublisher, + }); + + // Submit helpers declared before build to avoid forward reference issues + static String _mapTypeToServer(SnPollQuestionType t) { + switch (t) { + case SnPollQuestionType.singleChoice: + return 'SingleChoice'; + case SnPollQuestionType.multipleChoice: + return 'MultipleChoice'; + case SnPollQuestionType.freeText: + return 'FreeText'; + case SnPollQuestionType.yesNo: + return 'YesNo'; + case SnPollQuestionType.rating: + return 'Rating'; + } + } + + static Future _submitPoll(BuildContext context, WidgetRef ref) async { + final model = ref.read(pollEditorProvider); + final dio = ref.read(apiClientProvider); + + // Pick publisher (required) + final pickedPublisher = await showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + builder: (_) => const PublisherModal(), + ); + + if (pickedPublisher == null) { + showSnackBar('Publisher is required'); + return; + } + + final String publisherName = + pickedPublisher.name ?? pickedPublisher['name'] ?? ''; + if (publisherName.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Invalid publisher selected')), + ); + return; + } + + // Build payload + final body = { + 'title': model.title, + 'description': model.description, + 'endedAt': model.endedAt?.toUtc().toIso8601String(), + 'questions': + model.questions + .map( + (q) => { + 'type': _mapTypeToServer(q.type), + 'options': + q.options + ?.map( + (o) => { + 'label': o.label, + 'description': o.description, + 'order': o.order, + }, + ) + .toList(), + 'title': q.title, + 'description': q.description, + 'order': q.order, + 'isRequired': q.isRequired, + }, + ) + .toList(), + }; + + try { + final isUpdate = model.id != null && model.id!.isNotEmpty; + final String path = + isUpdate ? '/sphere/polls/${model.id}' : '/sphere/polls'; + final Response res = + await (isUpdate + ? dio.patch( + path, + queryParameters: {'pub': publisherName}, + data: body, + ) + : dio.post( + path, + queryParameters: {'pub': publisherName}, + data: body, + )); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(isUpdate ? 'Poll updated.' : 'Poll created.')), + ); + + if (!context.mounted) return; + Navigator.of(context).maybePop(res.data); + } on DioException catch (e) { + final msg = + e.response?.data is Map && (e.response!.data['message'] != null) + ? e.response!.data['message'].toString() + : e.message ?? 'Network error'; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed: $msg'))); + } catch (e) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Unexpected error: $e'))); + } + } + + // If editing, provide existing poll id and preselected publisher name (optional) + final String? initialPollId; + final String? initialPublisher; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final model = ref.watch(pollEditorProvider); + final notifier = ref.read(pollEditorProvider.notifier); + + // initialize editing state if provided + if (initialPollId != null && model.id != initialPollId) { + notifier.setEditingId(initialPollId); + } + + return Scaffold( + appBar: AppBar( + title: Text(model.id == null ? 'Create Poll' : 'Edit Poll'), + actions: [ + if (kDebugMode) + IconButton( + tooltip: 'Preview JSON (debug)', + onPressed: () { + _showDebugPreview(context, model); + }, + icon: const Icon(Icons.visibility_outlined), + ), + const Gap(8), + ], + ), + body: SafeArea( + child: Form( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + TextFormField( + initialValue: model.title ?? '', + decoration: const InputDecoration( + labelText: 'Title', + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ), + ), + textInputAction: TextInputAction.next, + maxLength: 256, + onChanged: notifier.setTitle, + onTapOutside: + (_) => FocusManager.instance.primaryFocus?.unfocus(), + validator: (v) { + if (v == null || v.trim().isEmpty) { + return 'Title is required'; + } + return null; + }, + ), + const SizedBox(height: 12), + TextFormField( + initialValue: model.description ?? '', + decoration: const InputDecoration( + labelText: 'Description', + alignLabelWithHint: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ), + ), + maxLines: 3, + maxLength: 4096, + onChanged: notifier.setDescription, + onTapOutside: + (_) => FocusManager.instance.primaryFocus?.unfocus(), + ), + const SizedBox(height: 12), + _EndDatePicker( + value: model.endedAt, + onChanged: notifier.setEndedAt, + ), + const SizedBox(height: 24), + Row( + children: [ + Text( + 'Questions', + style: Theme.of(context).textTheme.titleLarge, + ), + const Spacer(), + MenuAnchor( + builder: (context, controller, child) { + return FilledButton.icon( + onPressed: () { + controller.isOpen + ? controller.close() + : controller.open(); + }, + icon: const Icon(Icons.add), + label: const Text('Add question'), + ); + }, + menuChildren: + SnPollQuestionType.values + .map( + (t) => MenuItemButton( + leadingIcon: Icon(_iconForType(t)), + onPressed: () => notifier.addQuestion(t), + child: Text(_labelForType(t)), + ), + ) + .toList(), + ), + ], + ), + const SizedBox(height: 8), + if (model.questions.isEmpty) + _EmptyState( + title: 'No questions yet', + subtitle: 'Use "Add question" to start building your poll.', + ) + else + ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: model.questions.length, + onReorder: (oldIndex, newIndex) { + // Convert to stepwise moves using provided functions + if (newIndex > oldIndex) newIndex -= 1; + final steps = newIndex - oldIndex; + if (steps == 0) return; + if (steps > 0) { + for (int i = 0; i < steps; i++) { + notifier.moveQuestionDown(oldIndex + i); + } + } else { + for (int i = 0; i > steps; i--) { + notifier.moveQuestionUp(oldIndex + i); + } + } + }, + buildDefaultDragHandles: false, + itemBuilder: (context, index) { + final q = model.questions[index]; + return Card( + key: ValueKey('q_$index'), + margin: const EdgeInsets.symmetric(vertical: 8), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + _QuestionHeader( + index: index, + question: q, + onMoveUp: + index > 0 + ? () => notifier.moveQuestionUp(index) + : null, + onMoveDown: + index < model.questions.length - 1 + ? () => notifier.moveQuestionDown(index) + : null, + onDelete: () => notifier.removeQuestion(index), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.all(16), + child: _QuestionEditor(index: index, question: q), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 96), + ], + ), + ), + ), + bottomNavigationBar: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Row( + children: [ + OutlinedButton.icon( + onPressed: () { + Navigator.of(context).maybePop(); + }, + icon: const Icon(Icons.close), + label: const Text('Cancel'), + ), + const Spacer(), + FilledButton.icon( + onPressed: () { + _submitPoll(context, ref); + }, + icon: const Icon(Icons.cloud_upload_outlined), + label: Text(model.id == null ? 'Create' : 'Update'), + ), + ], + ), + ), + ); + } + + void _showDebugPreview(BuildContext context, PollEditorState model) { + final buf = StringBuffer(); + buf.writeln('{'); + buf.writeln(' "title": ${_jsonStr(model.title)},'); + buf.writeln(' "description": ${_jsonStr(model.description)},'); + buf.writeln(' "endedAt": ${_jsonStr(model.endedAt?.toIso8601String())},'); + buf.writeln(' "questions": ['); + for (var i = 0; i < model.questions.length; i++) { + final q = model.questions[i]; + buf.writeln(' {'); + buf.writeln(' "type": "${q.type.name}",'); + buf.writeln(' "title": ${_jsonStr(q.title)},'); + buf.writeln(' "description": ${_jsonStr(q.description)},'); + buf.writeln(' "order": ${q.order},'); + buf.writeln(' "isRequired": ${q.isRequired},'); + if (q.options != null) { + buf.writeln(' "options": ['); + for (var j = 0; j < q.options!.length; j++) { + final o = q.options![j]; + buf.writeln( + ' { "label": ${_jsonStr(o.label)}, "description": ${_jsonStr(o.description)}, "order": ${o.order} }${j == q.options!.length - 1 ? '' : ','}', + ); + } + buf.writeln(' ]'); + } else { + buf.writeln(' "options": null'); + } + buf.writeln(' }${i == model.questions.length - 1 ? '' : ','}'); + } + buf.writeln(' ]'); + buf.writeln('}'); + showDialog( + context: context, + builder: + (_) => AlertDialog( + title: const Text('Debug Preview'), + content: SingleChildScrollView( + child: SelectableText(buf.toString()), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ); + } +} + +String _jsonStr(String? v) => + v == null ? 'null' : '"${v.replaceAll('"', '\\"')}"'; + +IconData _iconForType(SnPollQuestionType t) { + switch (t) { + case SnPollQuestionType.singleChoice: + return Icons.radio_button_checked; + case SnPollQuestionType.multipleChoice: + return Icons.check_box; + case SnPollQuestionType.freeText: + return Icons.short_text; + case SnPollQuestionType.yesNo: + return Icons.toggle_on; + case SnPollQuestionType.rating: + return Icons.star_rate; + } +} + +String _labelForType(SnPollQuestionType t) { + switch (t) { + case SnPollQuestionType.singleChoice: + return 'Single choice'; + case SnPollQuestionType.multipleChoice: + return 'Multiple choice'; + case SnPollQuestionType.freeText: + return 'Free text'; + case SnPollQuestionType.yesNo: + return 'Yes / No'; + case SnPollQuestionType.rating: + return 'Rating'; + } +} + +/// End date and time picker row +class _EndDatePicker extends StatelessWidget { + const _EndDatePicker({required this.value, required this.onChanged}); + + final DateTime? value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: InputDecorator( + decoration: const InputDecoration( + labelText: 'End date & time (optional)', + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ), + ), + child: Wrap( + spacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Icon(Icons.event, color: Theme.of(context).colorScheme.primary), + Text( + value == null + ? 'Not set' + : MaterialLocalizations.of( + context, + ).formatFullDate(value!), + ), + if (value != null) ...[ + const Text('—'), + Text( + MaterialLocalizations.of(context).formatTimeOfDay( + TimeOfDay.fromDateTime(value!), + alwaysUse24HourFormat: true, + ), + ), + ], + const SizedBox(width: 8), + TextButton( + onPressed: () async { + final now = DateTime.now(); + final initial = value ?? now.add(const Duration(days: 1)); + final pickedDate = await showDatePicker( + context: context, + initialDate: initial, + firstDate: now, + lastDate: now.add(const Duration(days: 3650)), + ); + if (pickedDate == null) return; + if (!context.mounted) return; + final pickedTime = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(initial), + builder: (ctx, child) { + return MediaQuery( + data: MediaQuery.of( + ctx, + ).copyWith(alwaysUse24HourFormat: true), + child: child!, + ); + }, + ); + final dt = DateTime( + pickedDate.year, + pickedDate.month, + pickedDate.day, + pickedTime?.hour ?? 0, + pickedTime?.minute ?? 0, + ); + onChanged(dt); + }, + child: const Text('Pick'), + ), + if (value != null) + TextButton( + onPressed: () => onChanged(null), + child: const Text('Clear'), + ), + ], + ), + ), + ), + ], + ); + } +} + +/// Question card header with actions +class _QuestionHeader extends StatelessWidget { + const _QuestionHeader({ + required this.index, + required this.question, + this.onMoveUp, + this.onMoveDown, + this.onDelete, + }); + + final int index; + final SnPollQuestion question; + final VoidCallback? onMoveUp; + final VoidCallback? onMoveDown; + final VoidCallback? onDelete; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: ReorderableDragStartListener( + index: index, + child: const Icon(Icons.drag_handle), + ), + title: Text( + question.title.isEmpty ? 'Untitled question' : question.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text(_labelForType(question.type)), + trailing: Wrap( + spacing: 4, + children: [ + IconButton( + tooltip: 'Move up', + onPressed: onMoveUp, + icon: const Icon(Icons.arrow_upward), + ), + IconButton( + tooltip: 'Move down', + onPressed: onMoveDown, + icon: const Icon(Icons.arrow_downward), + ), + IconButton( + tooltip: 'Delete', + onPressed: onDelete, + icon: const Icon(Icons.delete_outline), + color: Theme.of(context).colorScheme.error, + ), + ], + ), + ); + } +} + +/// Question details editor +class _QuestionEditor extends ConsumerWidget { + const _QuestionEditor({required this.index, required this.question}); + + final int index; + final SnPollQuestion question; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final notifier = ref.read(pollEditorProvider.notifier); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 12, + runSpacing: 12, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _QuestionTypePicker( + value: question.type, + onChanged: (t) => notifier.setQuestionType(index, t), + ), + FilterChip( + label: const Text('Required'), + selected: question.isRequired, + onSelected: (v) => notifier.setQuestionRequired(index, v), + avatar: Icon( + question.isRequired + ? Icons.check_circle + : Icons.radio_button_unchecked, + ), + ), + ], + ), + const SizedBox(height: 12), + TextFormField( + initialValue: question.title, + decoration: const InputDecoration( + labelText: 'Question title', + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ), + ), + textInputAction: TextInputAction.next, + maxLength: 1024, + onChanged: (v) => notifier.setQuestionTitle(index, v), + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + validator: (v) { + if (v == null || v.trim().isEmpty) { + return 'Question title is required'; + } + return null; + }, + ), + const SizedBox(height: 12), + TextFormField( + initialValue: question.description ?? '', + decoration: const InputDecoration( + labelText: 'Question description (optional)', + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ), + ), + maxLines: 2, + maxLength: 4096, + onChanged: + (v) => + notifier.setQuestionDescription(index, v.isEmpty ? null : v), + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + ), + if (question.options != null) ...[ + const SizedBox(height: 16), + Text('Options', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + _OptionsEditor(index: index, options: question.options!), + const SizedBox(height: 4), + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + onPressed: () => notifier.addOption(index), + icon: const Icon(Icons.add), + label: const Text('Add option'), + ), + ), + ], + if (question.options == null && + (question.type == SnPollQuestionType.freeText || + question.type == SnPollQuestionType.rating || + question.type == SnPollQuestionType.yesNo)) ...[ + const SizedBox(height: 16), + _TextAnswerPreview(long: false), + ], + ], + ); + } +} + +class _QuestionTypePicker extends StatelessWidget { + const _QuestionTypePicker({required this.value, required this.onChanged}); + + final SnPollQuestionType value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + value: value, + decoration: const InputDecoration( + labelText: 'Type', + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ), + ), + items: + SnPollQuestionType.values + .map( + (t) => DropdownMenuItem( + value: t, + child: Row( + children: [ + Icon(_iconForType(t)), + const SizedBox(width: 8), + Text(_labelForType(t)), + ], + ), + ), + ) + .toList(), + onChanged: (t) { + if (t != null) onChanged(t); + }, + ); + } +} + +class _OptionsEditor extends ConsumerWidget { + const _OptionsEditor({required this.index, required this.options}); + + final int index; + final List options; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final notifier = ref.watch(pollEditorProvider.notifier); + + return Column( + children: [ + for (var i = 0; i < options.length; i++) + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextFormField( + initialValue: options[i].label, + decoration: const InputDecoration( + labelText: 'Option label', + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ), + ), + onChanged: (v) => notifier.setOptionLabel(index, i, v), + onTapOutside: + (_) => FocusManager.instance.primaryFocus?.unfocus(), + inputFormatters: [LengthLimitingTextInputFormatter(1024)], + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 40, + child: IconButton( + tooltip: 'Move up', + onPressed: + i > 0 ? () => notifier.moveOptionUp(index, i) : null, + icon: const Icon(Icons.arrow_upward), + ), + ), + SizedBox( + width: 40, + child: IconButton( + tooltip: 'Move down', + onPressed: + i < options.length - 1 + ? () => notifier.moveOptionDown(index, i) + : null, + icon: const Icon(Icons.arrow_downward), + ), + ), + SizedBox( + width: 40, + child: IconButton( + tooltip: 'Delete', + onPressed: () => notifier.removeOption(index, i), + icon: const Icon(Icons.close), + ), + ), + ], + ), + ), + ], + ); + } +} + +class _TextAnswerPreview extends StatelessWidget { + const _TextAnswerPreview({required this.long}); + + final bool long; + + @override + Widget build(BuildContext context) { + return TextField( + enabled: false, + maxLines: long ? 4 : 1, + decoration: InputDecoration( + labelText: + long ? 'Long text answer (preview)' : 'Short text answer (preview)', + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ), + ), + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState({required this.title, required this.subtitle}); + + final String title; + final String subtitle; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.help_outline, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 4), + Text(subtitle, style: Theme.of(context).textTheme.bodyMedium), + ], + ), + ), + ], + ), + ); + } +}