Compare commits
	
		
			6 Commits
		
	
	
		
			a706f127b6
			...
			3.1.0+116
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 8956723ac5 | |||
| ccc3ac415e | |||
| 8c47a59b80 | |||
| a6d869ebf6 | |||
| f3a8699389 | |||
| d345c00e84 | 
@@ -59,7 +59,6 @@ dependencies {
 | 
				
			|||||||
    implementation("com.google.android.material:material:1.12.0")
 | 
					    implementation("com.google.android.material:material:1.12.0")
 | 
				
			||||||
    implementation("com.github.bumptech.glide:glide:4.16.0")
 | 
					    implementation("com.github.bumptech.glide:glide:4.16.0")
 | 
				
			||||||
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
 | 
					    implementation("com.squareup.okhttp3:okhttp:4.12.0")
 | 
				
			||||||
    implementation("com.google.firebase:firebase-messaging-ktx")
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
flutter {
 | 
					flutter {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -117,14 +117,6 @@
 | 
				
			|||||||
            android:enabled="true"
 | 
					            android:enabled="true"
 | 
				
			||||||
            android:exported="true" />
 | 
					            android:exported="true" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <service
 | 
					 | 
				
			||||||
            android:name=".service.MessagingService"
 | 
					 | 
				
			||||||
            android:exported="false">
 | 
					 | 
				
			||||||
            <intent-filter>
 | 
					 | 
				
			||||||
                <action android:name="com.google.firebase.MESSAGING_EVENT" />
 | 
					 | 
				
			||||||
            </intent-filter>
 | 
					 | 
				
			||||||
        </service>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        <provider
 | 
					        <provider
 | 
				
			||||||
            android:name="androidx.core.content.FileProvider"
 | 
					            android:name="androidx.core.content.FileProvider"
 | 
				
			||||||
            android:authorities="dev.solsynth.solian.provider"
 | 
					            android:authorities="dev.solsynth.solian.provider"
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,102 +0,0 @@
 | 
				
			|||||||
package dev.solsynth.solian.service
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import android.app.PendingIntent
 | 
					 | 
				
			||||||
import android.content.Intent
 | 
					 | 
				
			||||||
import android.graphics.Bitmap
 | 
					 | 
				
			||||||
import android.graphics.drawable.Drawable
 | 
					 | 
				
			||||||
import android.os.Build
 | 
					 | 
				
			||||||
import androidx.core.app.NotificationCompat
 | 
					 | 
				
			||||||
import androidx.core.app.NotificationManagerCompat
 | 
					 | 
				
			||||||
import androidx.core.app.RemoteInput
 | 
					 | 
				
			||||||
import com.bumptech.glide.Glide
 | 
					 | 
				
			||||||
import com.bumptech.glide.request.target.CustomTarget
 | 
					 | 
				
			||||||
import com.bumptech.glide.request.transition.Transition
 | 
					 | 
				
			||||||
import com.google.firebase.messaging.FirebaseMessagingService
 | 
					 | 
				
			||||||
import com.google.firebase.messaging.RemoteMessage
 | 
					 | 
				
			||||||
import dev.solsynth.solian.MainActivity
 | 
					 | 
				
			||||||
import dev.solsynth.solian.receiver.ReplyReceiver
 | 
					 | 
				
			||||||
import org.json.JSONObject
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class MessagingService: FirebaseMessagingService() {
 | 
					 | 
				
			||||||
    override fun onMessageReceived(remoteMessage: RemoteMessage) {
 | 
					 | 
				
			||||||
        val type = remoteMessage.data["type"]
 | 
					 | 
				
			||||||
        if (type == "messages.new") {
 | 
					 | 
				
			||||||
            handleMessageNotification(remoteMessage)
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
            // Handle other notification types
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private fun handleMessageNotification(remoteMessage: RemoteMessage) {
 | 
					 | 
				
			||||||
        val data = remoteMessage.data
 | 
					 | 
				
			||||||
        val metaString = data["meta"] ?: return
 | 
					 | 
				
			||||||
        val meta = JSONObject(metaString)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        val pfp = meta.optString("pfp", null)
 | 
					 | 
				
			||||||
        val roomId = meta.optString("room_id", null)
 | 
					 | 
				
			||||||
        val messageId = meta.optString("message_id", null)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        val notificationId = System.currentTimeMillis().toInt()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        val replyLabel = "Reply"
 | 
					 | 
				
			||||||
        val remoteInput = RemoteInput.Builder("key_text_reply")
 | 
					 | 
				
			||||||
            .setLabel(replyLabel)
 | 
					 | 
				
			||||||
            .build()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        val replyIntent = Intent(this, ReplyReceiver::class.java).apply {
 | 
					 | 
				
			||||||
            putExtra("room_id", roomId)
 | 
					 | 
				
			||||||
            putExtra("message_id", messageId)
 | 
					 | 
				
			||||||
            putExtra("notification_id", notificationId)
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
 | 
					 | 
				
			||||||
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
            PendingIntent.FLAG_UPDATE_CURRENT
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        val replyPendingIntent = PendingIntent.getBroadcast(
 | 
					 | 
				
			||||||
            applicationContext,
 | 
					 | 
				
			||||||
            notificationId,
 | 
					 | 
				
			||||||
            replyIntent,
 | 
					 | 
				
			||||||
            pendingIntentFlags
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        val action = NotificationCompat.Action.Builder(
 | 
					 | 
				
			||||||
            android.R.drawable.ic_menu_send,
 | 
					 | 
				
			||||||
            replyLabel,
 | 
					 | 
				
			||||||
            replyPendingIntent
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
            .addRemoteInput(remoteInput)
 | 
					 | 
				
			||||||
            .build()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        val intent = Intent(this, MainActivity::class.java)
 | 
					 | 
				
			||||||
        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
 | 
					 | 
				
			||||||
        intent.putExtra("room_id", roomId)
 | 
					 | 
				
			||||||
        val pendingIntent = PendingIntent.getActivity(this, 0, intent, pendingIntentFlags)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        val notificationBuilder = NotificationCompat.Builder(this, "messages")
 | 
					 | 
				
			||||||
            .setSmallIcon(android.R.drawable.ic_dialog_info)
 | 
					 | 
				
			||||||
            .setContentTitle(remoteMessage.notification?.title)
 | 
					 | 
				
			||||||
            .setContentText(remoteMessage.notification?.body)
 | 
					 | 
				
			||||||
            .setPriority(NotificationCompat.PRIORITY_HIGH)
 | 
					 | 
				
			||||||
            .setContentIntent(pendingIntent)
 | 
					 | 
				
			||||||
            .addAction(action)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if (pfp != null) {
 | 
					 | 
				
			||||||
            Glide.with(applicationContext)
 | 
					 | 
				
			||||||
                .asBitmap()
 | 
					 | 
				
			||||||
                .load(pfp)
 | 
					 | 
				
			||||||
                .into(object : CustomTarget<Bitmap>() {
 | 
					 | 
				
			||||||
                    override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
 | 
					 | 
				
			||||||
                        notificationBuilder.setLargeIcon(resource)
 | 
					 | 
				
			||||||
                        NotificationManagerCompat.from(applicationContext).notify(notificationId, notificationBuilder.build())
 | 
					 | 
				
			||||||
                    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                    override fun onLoadCleared(placeholder: Drawable?) {}
 | 
					 | 
				
			||||||
                })
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
            NotificationManagerCompat.from(this).notify(notificationId, notificationBuilder.build())
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -753,5 +753,13 @@
 | 
				
			|||||||
  "sensitiveCategories.gambling": "Gambling",
 | 
					  "sensitiveCategories.gambling": "Gambling",
 | 
				
			||||||
  "sensitiveCategories.selfHarm": "Self-harm",
 | 
					  "sensitiveCategories.selfHarm": "Self-harm",
 | 
				
			||||||
  "sensitiveCategories.childAbuse": "Child Abuse",
 | 
					  "sensitiveCategories.childAbuse": "Child Abuse",
 | 
				
			||||||
  "sensitiveCategories.other": "Other"
 | 
					  "sensitiveCategories.other": "Other",
 | 
				
			||||||
 | 
					  "poll": "Poll",
 | 
				
			||||||
 | 
					  "pollsRecent": "Recent Polls",
 | 
				
			||||||
 | 
					  "pollCreateNew": "Create New",
 | 
				
			||||||
 | 
					  "pollCreateNewHint": "Create a new poll for your post. Pick a publisher and continue.",
 | 
				
			||||||
 | 
					  "publisher": "Publisher",
 | 
				
			||||||
 | 
					  "publisherHint": "Enter the publisher name",
 | 
				
			||||||
 | 
					  "publisherCannotBeEmpty": "Publisher cannot be empty",
 | 
				
			||||||
 | 
					  "operationFailed": "Operation failed: {}"
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										92
									
								
								lib/models/poll.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								lib/models/poll.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,92 @@
 | 
				
			|||||||
 | 
					import 'package:freezed_annotation/freezed_annotation.dart';
 | 
				
			||||||
 | 
					import 'package:island/models/publisher.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					part 'poll.freezed.dart';
 | 
				
			||||||
 | 
					part 'poll.g.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@freezed
 | 
				
			||||||
 | 
					sealed class SnPollWithStats with _$SnPollWithStats {
 | 
				
			||||||
 | 
					  const factory SnPollWithStats({
 | 
				
			||||||
 | 
					    required Map<String, dynamic>? userAnswer,
 | 
				
			||||||
 | 
					    required Map<String, dynamic> stats,
 | 
				
			||||||
 | 
					    required String id,
 | 
				
			||||||
 | 
					    required List<SnPollQuestion> questions,
 | 
				
			||||||
 | 
					    String? title,
 | 
				
			||||||
 | 
					    String? description,
 | 
				
			||||||
 | 
					    DateTime? endedAt,
 | 
				
			||||||
 | 
					    required String publisherId,
 | 
				
			||||||
 | 
					    required DateTime createdAt,
 | 
				
			||||||
 | 
					    required DateTime updatedAt,
 | 
				
			||||||
 | 
					    DateTime? deletedAt,
 | 
				
			||||||
 | 
					  }) = _SnPollWithStats;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  factory SnPollWithStats.fromJson(Map<String, dynamic> json) =>
 | 
				
			||||||
 | 
					      _$SnPollWithStatsFromJson(json);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@freezed
 | 
				
			||||||
 | 
					sealed class SnPoll with _$SnPoll {
 | 
				
			||||||
 | 
					  const factory SnPoll({
 | 
				
			||||||
 | 
					    required String id,
 | 
				
			||||||
 | 
					    required List<SnPollQuestion> 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<String, dynamic> json) => _$SnPollFromJson(json);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@freezed
 | 
				
			||||||
 | 
					sealed class SnPollQuestion with _$SnPollQuestion {
 | 
				
			||||||
 | 
					  const factory SnPollQuestion({
 | 
				
			||||||
 | 
					    required String id,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    required SnPollQuestionType type,
 | 
				
			||||||
 | 
					    List<SnPollOption>? options,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    required String title,
 | 
				
			||||||
 | 
					    String? description,
 | 
				
			||||||
 | 
					    required int order,
 | 
				
			||||||
 | 
					    required bool isRequired,
 | 
				
			||||||
 | 
					  }) = _SnPollQuestion;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  factory SnPollQuestion.fromJson(Map<String, dynamic> 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<String, dynamic> json) =>
 | 
				
			||||||
 | 
					      _$SnPollOptionFromJson(json);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					enum SnPollQuestionType {
 | 
				
			||||||
 | 
					  @JsonValue(0)
 | 
				
			||||||
 | 
					  singleChoice,
 | 
				
			||||||
 | 
					  @JsonValue(1)
 | 
				
			||||||
 | 
					  multipleChoice,
 | 
				
			||||||
 | 
					  @JsonValue(2)
 | 
				
			||||||
 | 
					  yesNo,
 | 
				
			||||||
 | 
					  @JsonValue(3)
 | 
				
			||||||
 | 
					  rating,
 | 
				
			||||||
 | 
					  @JsonValue(4)
 | 
				
			||||||
 | 
					  freeText,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										1186
									
								
								lib/models/poll.freezed.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1186
									
								
								lib/models/poll.freezed.dart
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										133
									
								
								lib/models/poll.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								lib/models/poll.g.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,133 @@
 | 
				
			|||||||
 | 
					// GENERATED CODE - DO NOT MODIFY BY HAND
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					part of 'poll.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// **************************************************************************
 | 
				
			||||||
 | 
					// JsonSerializableGenerator
 | 
				
			||||||
 | 
					// **************************************************************************
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					_SnPollWithStats _$SnPollWithStatsFromJson(Map<String, dynamic> json) =>
 | 
				
			||||||
 | 
					    _SnPollWithStats(
 | 
				
			||||||
 | 
					      userAnswer: json['user_answer'] as Map<String, dynamic>?,
 | 
				
			||||||
 | 
					      stats: json['stats'] as Map<String, dynamic>,
 | 
				
			||||||
 | 
					      id: json['id'] as String,
 | 
				
			||||||
 | 
					      questions:
 | 
				
			||||||
 | 
					          (json['questions'] as List<dynamic>)
 | 
				
			||||||
 | 
					              .map((e) => SnPollQuestion.fromJson(e as Map<String, dynamic>))
 | 
				
			||||||
 | 
					              .toList(),
 | 
				
			||||||
 | 
					      title: json['title'] as String?,
 | 
				
			||||||
 | 
					      description: json['description'] as String?,
 | 
				
			||||||
 | 
					      endedAt:
 | 
				
			||||||
 | 
					          json['ended_at'] == null
 | 
				
			||||||
 | 
					              ? null
 | 
				
			||||||
 | 
					              : DateTime.parse(json['ended_at'] as String),
 | 
				
			||||||
 | 
					      publisherId: json['publisher_id'] as String,
 | 
				
			||||||
 | 
					      createdAt: DateTime.parse(json['created_at'] as String),
 | 
				
			||||||
 | 
					      updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
				
			||||||
 | 
					      deletedAt:
 | 
				
			||||||
 | 
					          json['deleted_at'] == null
 | 
				
			||||||
 | 
					              ? null
 | 
				
			||||||
 | 
					              : DateTime.parse(json['deleted_at'] as String),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Map<String, dynamic> _$SnPollWithStatsToJson(_SnPollWithStats instance) =>
 | 
				
			||||||
 | 
					    <String, dynamic>{
 | 
				
			||||||
 | 
					      'user_answer': instance.userAnswer,
 | 
				
			||||||
 | 
					      'stats': instance.stats,
 | 
				
			||||||
 | 
					      'id': instance.id,
 | 
				
			||||||
 | 
					      'questions': instance.questions.map((e) => e.toJson()).toList(),
 | 
				
			||||||
 | 
					      'title': instance.title,
 | 
				
			||||||
 | 
					      'description': instance.description,
 | 
				
			||||||
 | 
					      'ended_at': instance.endedAt?.toIso8601String(),
 | 
				
			||||||
 | 
					      'publisher_id': instance.publisherId,
 | 
				
			||||||
 | 
					      'created_at': instance.createdAt.toIso8601String(),
 | 
				
			||||||
 | 
					      'updated_at': instance.updatedAt.toIso8601String(),
 | 
				
			||||||
 | 
					      'deleted_at': instance.deletedAt?.toIso8601String(),
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					_SnPoll _$SnPollFromJson(Map<String, dynamic> json) => _SnPoll(
 | 
				
			||||||
 | 
					  id: json['id'] as String,
 | 
				
			||||||
 | 
					  questions:
 | 
				
			||||||
 | 
					      (json['questions'] as List<dynamic>)
 | 
				
			||||||
 | 
					          .map((e) => SnPollQuestion.fromJson(e as Map<String, dynamic>))
 | 
				
			||||||
 | 
					          .toList(),
 | 
				
			||||||
 | 
					  title: json['title'] as String?,
 | 
				
			||||||
 | 
					  description: json['description'] as String?,
 | 
				
			||||||
 | 
					  endedAt:
 | 
				
			||||||
 | 
					      json['ended_at'] == null
 | 
				
			||||||
 | 
					          ? null
 | 
				
			||||||
 | 
					          : DateTime.parse(json['ended_at'] as String),
 | 
				
			||||||
 | 
					  publisherId: json['publisher_id'] as String,
 | 
				
			||||||
 | 
					  publisher:
 | 
				
			||||||
 | 
					      json['publisher'] == null
 | 
				
			||||||
 | 
					          ? null
 | 
				
			||||||
 | 
					          : SnPublisher.fromJson(json['publisher'] as Map<String, dynamic>),
 | 
				
			||||||
 | 
					  createdAt: DateTime.parse(json['created_at'] as String),
 | 
				
			||||||
 | 
					  updatedAt: DateTime.parse(json['updated_at'] as String),
 | 
				
			||||||
 | 
					  deletedAt:
 | 
				
			||||||
 | 
					      json['deleted_at'] == null
 | 
				
			||||||
 | 
					          ? null
 | 
				
			||||||
 | 
					          : DateTime.parse(json['deleted_at'] as String),
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Map<String, dynamic> _$SnPollToJson(_SnPoll instance) => <String, dynamic>{
 | 
				
			||||||
 | 
					  '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<String, dynamic> json) =>
 | 
				
			||||||
 | 
					    _SnPollQuestion(
 | 
				
			||||||
 | 
					      id: json['id'] as String,
 | 
				
			||||||
 | 
					      type: $enumDecode(_$SnPollQuestionTypeEnumMap, json['type']),
 | 
				
			||||||
 | 
					      options:
 | 
				
			||||||
 | 
					          (json['options'] as List<dynamic>?)
 | 
				
			||||||
 | 
					              ?.map((e) => SnPollOption.fromJson(e as Map<String, dynamic>))
 | 
				
			||||||
 | 
					              .toList(),
 | 
				
			||||||
 | 
					      title: json['title'] as String,
 | 
				
			||||||
 | 
					      description: json['description'] as String?,
 | 
				
			||||||
 | 
					      order: (json['order'] as num).toInt(),
 | 
				
			||||||
 | 
					      isRequired: json['is_required'] as bool,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Map<String, dynamic> _$SnPollQuestionToJson(_SnPollQuestion instance) =>
 | 
				
			||||||
 | 
					    <String, dynamic>{
 | 
				
			||||||
 | 
					      '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: 0,
 | 
				
			||||||
 | 
					  SnPollQuestionType.multipleChoice: 1,
 | 
				
			||||||
 | 
					  SnPollQuestionType.yesNo: 2,
 | 
				
			||||||
 | 
					  SnPollQuestionType.rating: 3,
 | 
				
			||||||
 | 
					  SnPollQuestionType.freeText: 4,
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					_SnPollOption _$SnPollOptionFromJson(Map<String, dynamic> json) =>
 | 
				
			||||||
 | 
					    _SnPollOption(
 | 
				
			||||||
 | 
					      id: json['id'] as String,
 | 
				
			||||||
 | 
					      label: json['label'] as String,
 | 
				
			||||||
 | 
					      description: json['description'] as String?,
 | 
				
			||||||
 | 
					      order: (json['order'] as num).toInt(),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Map<String, dynamic> _$SnPollOptionToJson(_SnPollOption instance) =>
 | 
				
			||||||
 | 
					    <String, dynamic>{
 | 
				
			||||||
 | 
					      'id': instance.id,
 | 
				
			||||||
 | 
					      'label': instance.label,
 | 
				
			||||||
 | 
					      'description': instance.description,
 | 
				
			||||||
 | 
					      'order': instance.order,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
@@ -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/posts/post_manage_list.dart';
 | 
				
			||||||
import 'package:island/screens/creators/stickers/stickers.dart';
 | 
					import 'package:island/screens/creators/stickers/stickers.dart';
 | 
				
			||||||
import 'package:island/screens/creators/stickers/pack_detail.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/publishers.dart';
 | 
				
			||||||
import 'package:island/screens/creators/webfeed/webfeed_list.dart';
 | 
					import 'package:island/screens/creators/webfeed/webfeed_list.dart';
 | 
				
			||||||
import 'package:island/screens/creators/webfeed/webfeed_edit.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/compose.dart';
 | 
				
			||||||
import 'package:island/screens/posts/post_detail.dart';
 | 
					import 'package:island/screens/posts/post_detail.dart';
 | 
				
			||||||
import 'package:island/screens/posts/pub_profile.dart';
 | 
					import 'package:island/screens/posts/pub_profile.dart';
 | 
				
			||||||
@@ -144,6 +146,37 @@ final routerProvider = Provider<GoRouter>((ref) {
 | 
				
			|||||||
                  return CreatorPostListScreen(pubName: name);
 | 
					                  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(
 | 
					              GoRoute(
 | 
				
			||||||
                name: 'creatorStickers',
 | 
					                name: 'creatorStickers',
 | 
				
			||||||
                path: '/creators/:name/stickers',
 | 
					                path: '/creators/:name/stickers',
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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(
 | 
					                          ListTile(
 | 
				
			||||||
                            minTileHeight: 48,
 | 
					                            minTileHeight: 48,
 | 
				
			||||||
                            title: Text('publisherMembers').tr(),
 | 
					                            title: Text('publisherMembers').tr(),
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										175
									
								
								lib/screens/creators/poll/poll_list.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										175
									
								
								lib/screens/creators/poll/poll_list.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,175 @@
 | 
				
			|||||||
 | 
					import 'package:flutter/material.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/pods/network.dart';
 | 
				
			||||||
 | 
					import 'package:material_symbols_icons/symbols.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<SnPoll> {
 | 
				
			||||||
 | 
					  static const int _pageSize = 20;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Future<CursorPagingData<SnPoll>> build(String? pubName) {
 | 
				
			||||||
 | 
					    // immediately load first page
 | 
				
			||||||
 | 
					    return fetch(cursor: null);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Future<CursorPagingData<SnPoll>> 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<dynamic> 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<void> _createPoll(BuildContext context) async {
 | 
				
			||||||
 | 
					    final result = await GoRouter.of(
 | 
				
			||||||
 | 
					      context,
 | 
				
			||||||
 | 
					    ).pushNamed('creatorPollNew', pathParameters: {'name': pubName});
 | 
				
			||||||
 | 
					    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, pubName: pubName);
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _CreatorPollItem extends StatelessWidget {
 | 
				
			||||||
 | 
					  final String pubName;
 | 
				
			||||||
 | 
					  const _CreatorPollItem({required this.poll, required this.pubName});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  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<String>(
 | 
				
			||||||
 | 
					          itemBuilder:
 | 
				
			||||||
 | 
					              (context) => [
 | 
				
			||||||
 | 
					                PopupMenuItem(
 | 
				
			||||||
 | 
					                  child: Row(
 | 
				
			||||||
 | 
					                    children: [
 | 
				
			||||||
 | 
					                      const Icon(Symbols.edit),
 | 
				
			||||||
 | 
					                      const Gap(16),
 | 
				
			||||||
 | 
					                      Text('Edit'),
 | 
				
			||||||
 | 
					                    ],
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                  onTap: () {
 | 
				
			||||||
 | 
					                    GoRouter.of(context).pushNamed(
 | 
				
			||||||
 | 
					                      'creatorPollEdit',
 | 
				
			||||||
 | 
					                      pathParameters: {'name': pubName, 'id': poll.id},
 | 
				
			||||||
 | 
					                    );
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ],
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        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.
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										179
									
								
								lib/screens/creators/poll/poll_list.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								lib/screens/creators/poll/poll_list.g.dart
									
									
									
									
									
										Normal file
									
								
							@@ -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<CursorPagingData<SnPoll>> {
 | 
				
			||||||
 | 
					  late final String? pubName;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  FutureOr<CursorPagingData<SnPoll>> build(String? pubName);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// See also [PollListNotifier].
 | 
				
			||||||
 | 
					@ProviderFor(PollListNotifier)
 | 
				
			||||||
 | 
					const pollListNotifierProvider = PollListNotifierFamily();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// See also [PollListNotifier].
 | 
				
			||||||
 | 
					class PollListNotifierFamily
 | 
				
			||||||
 | 
					    extends Family<AsyncValue<CursorPagingData<SnPoll>>> {
 | 
				
			||||||
 | 
					  /// 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<ProviderOrFamily>? _dependencies = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Iterable<ProviderOrFamily>? get dependencies => _dependencies;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
 | 
				
			||||||
 | 
					      _allTransitiveDependencies;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  String? get name => r'pollListNotifierProvider';
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// See also [PollListNotifier].
 | 
				
			||||||
 | 
					class PollListNotifierProvider
 | 
				
			||||||
 | 
					    extends
 | 
				
			||||||
 | 
					        AutoDisposeAsyncNotifierProviderImpl<
 | 
				
			||||||
 | 
					          PollListNotifier,
 | 
				
			||||||
 | 
					          CursorPagingData<SnPoll>
 | 
				
			||||||
 | 
					        > {
 | 
				
			||||||
 | 
					  /// 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<CursorPagingData<SnPoll>> 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<SnPoll>
 | 
				
			||||||
 | 
					  >
 | 
				
			||||||
 | 
					  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<CursorPagingData<SnPoll>> {
 | 
				
			||||||
 | 
					  /// The parameter `pubName` of this provider.
 | 
				
			||||||
 | 
					  String? get pubName;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _PollListNotifierProviderElement
 | 
				
			||||||
 | 
					    extends
 | 
				
			||||||
 | 
					        AutoDisposeAsyncNotifierProviderElement<
 | 
				
			||||||
 | 
					          PollListNotifier,
 | 
				
			||||||
 | 
					          CursorPagingData<SnPoll>
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					    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
 | 
				
			||||||
							
								
								
									
										1094
									
								
								lib/screens/poll/poll_editor.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1094
									
								
								lib/screens/poll/poll_editor.dart
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										728
									
								
								lib/widgets/poll/poll_submit.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										728
									
								
								lib/widgets/poll/poll_submit.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,728 @@
 | 
				
			|||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:flutter_riverpod/flutter_riverpod.dart';
 | 
				
			||||||
 | 
					import 'package:island/models/poll.dart';
 | 
				
			||||||
 | 
					import 'package:island/pods/network.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// A poll answering widget that shows one question at a time and collects answers.
 | 
				
			||||||
 | 
					///
 | 
				
			||||||
 | 
					/// Usage:
 | 
				
			||||||
 | 
					/// PollSubmit(
 | 
				
			||||||
 | 
					///   poll: poll,
 | 
				
			||||||
 | 
					///   onSubmit: (answers) {
 | 
				
			||||||
 | 
					///     // answers is Map<String, dynamic>: questionId -> answer
 | 
				
			||||||
 | 
					///     // answer types by question:
 | 
				
			||||||
 | 
					///     // - singleChoice: String optionId
 | 
				
			||||||
 | 
					///     // - multipleChoice: List<String> optionIds
 | 
				
			||||||
 | 
					///     // - yesNo: bool
 | 
				
			||||||
 | 
					///     // - rating: int (1..5)
 | 
				
			||||||
 | 
					///     // - freeText: String
 | 
				
			||||||
 | 
					///   },
 | 
				
			||||||
 | 
					/// )
 | 
				
			||||||
 | 
					class PollSubmit extends ConsumerStatefulWidget {
 | 
				
			||||||
 | 
					  const PollSubmit({
 | 
				
			||||||
 | 
					    super.key,
 | 
				
			||||||
 | 
					    required this.poll,
 | 
				
			||||||
 | 
					    required this.onSubmit,
 | 
				
			||||||
 | 
					    required this.stats,
 | 
				
			||||||
 | 
					    this.initialAnswers,
 | 
				
			||||||
 | 
					    this.onCancel,
 | 
				
			||||||
 | 
					    this.showProgress = true,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final SnPollWithStats poll;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /// Callback when user submits all answers. Map questionId -> answer.
 | 
				
			||||||
 | 
					  final void Function(Map<String, dynamic> answers) onSubmit;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /// Optional initial answers, keyed by questionId.
 | 
				
			||||||
 | 
					  final Map<String, dynamic>? initialAnswers;
 | 
				
			||||||
 | 
					  final Map<String, dynamic>? stats;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /// Optional cancel callback.
 | 
				
			||||||
 | 
					  final VoidCallback? onCancel;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /// Whether to show a progress indicator (e.g., "2 / N").
 | 
				
			||||||
 | 
					  final bool showProgress;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  ConsumerState<PollSubmit> createState() => _PollSubmitState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _PollSubmitState extends ConsumerState<PollSubmit> {
 | 
				
			||||||
 | 
					  late final List<SnPollQuestion> _questions;
 | 
				
			||||||
 | 
					  int _index = 0;
 | 
				
			||||||
 | 
					  bool _submitting = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /// Collected answers, keyed by questionId
 | 
				
			||||||
 | 
					  late Map<String, dynamic> _answers;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /// Local controller for free text input
 | 
				
			||||||
 | 
					  final TextEditingController _textController = TextEditingController();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /// Local state holders for inputs to avoid rebuilding whole list
 | 
				
			||||||
 | 
					  String? _singleChoiceSelected; // optionId
 | 
				
			||||||
 | 
					  final Set<String> _multiChoiceSelected = {};
 | 
				
			||||||
 | 
					  bool? _yesNoSelected;
 | 
				
			||||||
 | 
					  int? _ratingSelected; // 1..5
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    // Ensure questions are ordered by `order`
 | 
				
			||||||
 | 
					    _questions = [...widget.poll.questions]
 | 
				
			||||||
 | 
					      ..sort((a, b) => a.order.compareTo(b.order));
 | 
				
			||||||
 | 
					    _answers = Map<String, dynamic>.from(widget.initialAnswers ?? {});
 | 
				
			||||||
 | 
					    _loadCurrentIntoLocalState();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void didUpdateWidget(covariant PollSubmit oldWidget) {
 | 
				
			||||||
 | 
					    super.didUpdateWidget(oldWidget);
 | 
				
			||||||
 | 
					    if (oldWidget.poll.id != widget.poll.id) {
 | 
				
			||||||
 | 
					      _index = 0;
 | 
				
			||||||
 | 
					      _answers = Map<String, dynamic>.from(widget.initialAnswers ?? {});
 | 
				
			||||||
 | 
					      _questions
 | 
				
			||||||
 | 
					        ..clear()
 | 
				
			||||||
 | 
					        ..addAll(
 | 
				
			||||||
 | 
					          [...widget.poll.questions]
 | 
				
			||||||
 | 
					            ..sort((a, b) => a.order.compareTo(b.order)),
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      _loadCurrentIntoLocalState();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void dispose() {
 | 
				
			||||||
 | 
					    _textController.dispose();
 | 
				
			||||||
 | 
					    super.dispose();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  SnPollQuestion get _current => _questions[_index];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _loadCurrentIntoLocalState() {
 | 
				
			||||||
 | 
					    final q = _current;
 | 
				
			||||||
 | 
					    final saved = _answers[q.id];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    _singleChoiceSelected = null;
 | 
				
			||||||
 | 
					    _multiChoiceSelected.clear();
 | 
				
			||||||
 | 
					    _yesNoSelected = null;
 | 
				
			||||||
 | 
					    _ratingSelected = null;
 | 
				
			||||||
 | 
					    _textController.text = '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    switch (q.type) {
 | 
				
			||||||
 | 
					      case SnPollQuestionType.singleChoice:
 | 
				
			||||||
 | 
					        if (saved is String) _singleChoiceSelected = saved;
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					      case SnPollQuestionType.multipleChoice:
 | 
				
			||||||
 | 
					        if (saved is List) {
 | 
				
			||||||
 | 
					          _multiChoiceSelected.addAll(saved.whereType<String>());
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					      case SnPollQuestionType.yesNo:
 | 
				
			||||||
 | 
					        if (saved is bool) _yesNoSelected = saved;
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					      case SnPollQuestionType.rating:
 | 
				
			||||||
 | 
					        if (saved is int) _ratingSelected = saved;
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					      case SnPollQuestionType.freeText:
 | 
				
			||||||
 | 
					        if (saved is String) _textController.text = saved;
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool _isCurrentAnswered() {
 | 
				
			||||||
 | 
					    final q = _current;
 | 
				
			||||||
 | 
					    if (!q.isRequired) return true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    switch (q.type) {
 | 
				
			||||||
 | 
					      case SnPollQuestionType.singleChoice:
 | 
				
			||||||
 | 
					        return _singleChoiceSelected != null;
 | 
				
			||||||
 | 
					      case SnPollQuestionType.multipleChoice:
 | 
				
			||||||
 | 
					        return _multiChoiceSelected.isNotEmpty;
 | 
				
			||||||
 | 
					      case SnPollQuestionType.yesNo:
 | 
				
			||||||
 | 
					        return _yesNoSelected != null;
 | 
				
			||||||
 | 
					      case SnPollQuestionType.rating:
 | 
				
			||||||
 | 
					        return (_ratingSelected ?? 0) > 0;
 | 
				
			||||||
 | 
					      case SnPollQuestionType.freeText:
 | 
				
			||||||
 | 
					        return _textController.text.trim().isNotEmpty;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _persistCurrentAnswer() {
 | 
				
			||||||
 | 
					    final q = _current;
 | 
				
			||||||
 | 
					    switch (q.type) {
 | 
				
			||||||
 | 
					      case SnPollQuestionType.singleChoice:
 | 
				
			||||||
 | 
					        if (_singleChoiceSelected == null) {
 | 
				
			||||||
 | 
					          _answers.remove(q.id);
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          _answers[q.id] = _singleChoiceSelected!;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					      case SnPollQuestionType.multipleChoice:
 | 
				
			||||||
 | 
					        if (_multiChoiceSelected.isEmpty) {
 | 
				
			||||||
 | 
					          _answers.remove(q.id);
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          _answers[q.id] = _multiChoiceSelected.toList(growable: false);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					      case SnPollQuestionType.yesNo:
 | 
				
			||||||
 | 
					        if (_yesNoSelected == null) {
 | 
				
			||||||
 | 
					          _answers.remove(q.id);
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          _answers[q.id] = _yesNoSelected!;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					      case SnPollQuestionType.rating:
 | 
				
			||||||
 | 
					        if (_ratingSelected == null) {
 | 
				
			||||||
 | 
					          _answers.remove(q.id);
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          _answers[q.id] = _ratingSelected!;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					      case SnPollQuestionType.freeText:
 | 
				
			||||||
 | 
					        final text = _textController.text.trim();
 | 
				
			||||||
 | 
					        if (text.isEmpty) {
 | 
				
			||||||
 | 
					          _answers.remove(q.id);
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          _answers[q.id] = text;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _submitToServer() async {
 | 
				
			||||||
 | 
					    // Persist current question before final submit
 | 
				
			||||||
 | 
					    _persistCurrentAnswer();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setState(() {
 | 
				
			||||||
 | 
					      _submitting = true;
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final dio = ref.read(apiClientProvider);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await dio.post(
 | 
				
			||||||
 | 
					        '/sphere/polls/${widget.poll.id}/answer',
 | 
				
			||||||
 | 
					        data: {'answer': _answers},
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Only call onSubmit after server accepts
 | 
				
			||||||
 | 
					      widget.onSubmit(Map<String, dynamic>.unmodifiable(_answers));
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      if (mounted) {
 | 
				
			||||||
 | 
					        ScaffoldMessenger.of(
 | 
				
			||||||
 | 
					          context,
 | 
				
			||||||
 | 
					        ).showSnackBar(SnackBar(content: Text('Failed to submit poll: $e')));
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      if (mounted) {
 | 
				
			||||||
 | 
					        setState(() {
 | 
				
			||||||
 | 
					          _submitting = false;
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _next() {
 | 
				
			||||||
 | 
					    if (_submitting) return;
 | 
				
			||||||
 | 
					    _persistCurrentAnswer();
 | 
				
			||||||
 | 
					    if (_index < _questions.length - 1) {
 | 
				
			||||||
 | 
					      setState(() {
 | 
				
			||||||
 | 
					        _index++;
 | 
				
			||||||
 | 
					        _loadCurrentIntoLocalState();
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      // Final submit to API
 | 
				
			||||||
 | 
					      _submitToServer();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _back() {
 | 
				
			||||||
 | 
					    if (_submitting) return;
 | 
				
			||||||
 | 
					    _persistCurrentAnswer();
 | 
				
			||||||
 | 
					    if (_index > 0) {
 | 
				
			||||||
 | 
					      setState(() {
 | 
				
			||||||
 | 
					        _index--;
 | 
				
			||||||
 | 
					        _loadCurrentIntoLocalState();
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      // at the first question; allow cancel if provided
 | 
				
			||||||
 | 
					      widget.onCancel?.call();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Widget _buildHeader(BuildContext context) {
 | 
				
			||||||
 | 
					    final q = _current;
 | 
				
			||||||
 | 
					    return Column(
 | 
				
			||||||
 | 
					      crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					      children: [
 | 
				
			||||||
 | 
					        if (widget.poll.title != null || widget.poll.description != null)
 | 
				
			||||||
 | 
					          Padding(
 | 
				
			||||||
 | 
					            padding: const EdgeInsets.only(bottom: 12),
 | 
				
			||||||
 | 
					            child: Column(
 | 
				
			||||||
 | 
					              crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					              children: [
 | 
				
			||||||
 | 
					                if (widget.poll.title != null)
 | 
				
			||||||
 | 
					                  Text(
 | 
				
			||||||
 | 
					                    widget.poll.title!,
 | 
				
			||||||
 | 
					                    style: Theme.of(context).textTheme.titleLarge,
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                if (widget.poll.description != null)
 | 
				
			||||||
 | 
					                  Padding(
 | 
				
			||||||
 | 
					                    padding: const EdgeInsets.only(top: 4),
 | 
				
			||||||
 | 
					                    child: Text(
 | 
				
			||||||
 | 
					                      widget.poll.description!,
 | 
				
			||||||
 | 
					                      style: Theme.of(context).textTheme.bodyMedium?.copyWith(
 | 
				
			||||||
 | 
					                        color: Theme.of(
 | 
				
			||||||
 | 
					                          context,
 | 
				
			||||||
 | 
					                        ).textTheme.bodyMedium?.color?.withOpacity(0.7),
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					              ],
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        if (widget.showProgress)
 | 
				
			||||||
 | 
					          Text(
 | 
				
			||||||
 | 
					            '${_index + 1} / ${_questions.length}',
 | 
				
			||||||
 | 
					            style: Theme.of(context).textTheme.labelMedium,
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        Row(
 | 
				
			||||||
 | 
					          children: [
 | 
				
			||||||
 | 
					            Expanded(
 | 
				
			||||||
 | 
					              child: Text(
 | 
				
			||||||
 | 
					                q.title,
 | 
				
			||||||
 | 
					                style: Theme.of(context).textTheme.titleMedium,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            if (q.isRequired)
 | 
				
			||||||
 | 
					              Padding(
 | 
				
			||||||
 | 
					                padding: const EdgeInsets.only(left: 8),
 | 
				
			||||||
 | 
					                child: Text(
 | 
				
			||||||
 | 
					                  '*',
 | 
				
			||||||
 | 
					                  style: Theme.of(context).textTheme.titleMedium?.copyWith(
 | 
				
			||||||
 | 
					                    color: Theme.of(context).colorScheme.error,
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        if (q.description != null)
 | 
				
			||||||
 | 
					          Padding(
 | 
				
			||||||
 | 
					            padding: const EdgeInsets.only(top: 4),
 | 
				
			||||||
 | 
					            child: Text(
 | 
				
			||||||
 | 
					              q.description!,
 | 
				
			||||||
 | 
					              style: Theme.of(context).textTheme.bodySmall?.copyWith(
 | 
				
			||||||
 | 
					                color: Theme.of(
 | 
				
			||||||
 | 
					                  context,
 | 
				
			||||||
 | 
					                ).textTheme.bodySmall?.color?.withOpacity(0.7),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Widget _buildStats(BuildContext context, SnPollQuestion q) {
 | 
				
			||||||
 | 
					    if (widget.stats == null) return const SizedBox.shrink();
 | 
				
			||||||
 | 
					    final raw = widget.stats![q.id];
 | 
				
			||||||
 | 
					    if (raw == null) return const SizedBox.shrink();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Widget? body;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    switch (q.type) {
 | 
				
			||||||
 | 
					      case SnPollQuestionType.rating:
 | 
				
			||||||
 | 
					        // rating: avg score (double or int)
 | 
				
			||||||
 | 
					        final avg = (raw['rating'] as num?)?.toDouble();
 | 
				
			||||||
 | 
					        if (avg == null) break;
 | 
				
			||||||
 | 
					        final theme = Theme.of(context);
 | 
				
			||||||
 | 
					        body = Row(
 | 
				
			||||||
 | 
					          mainAxisAlignment: MainAxisAlignment.start,
 | 
				
			||||||
 | 
					          children: [
 | 
				
			||||||
 | 
					            Icon(Icons.star, color: Colors.amber.shade600, size: 18),
 | 
				
			||||||
 | 
					            const SizedBox(width: 6),
 | 
				
			||||||
 | 
					            Text(
 | 
				
			||||||
 | 
					              avg.toStringAsFixed(1),
 | 
				
			||||||
 | 
					              style: theme.textTheme.labelMedium?.copyWith(
 | 
				
			||||||
 | 
					                color: theme.colorScheme.onSurfaceVariant,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      case SnPollQuestionType.yesNo:
 | 
				
			||||||
 | 
					        // yes/no: map {true: count, false: count}
 | 
				
			||||||
 | 
					        if (raw is Map) {
 | 
				
			||||||
 | 
					          final int yes =
 | 
				
			||||||
 | 
					              (raw[true] is int)
 | 
				
			||||||
 | 
					                  ? raw[true] as int
 | 
				
			||||||
 | 
					                  : int.tryParse('${raw[true]}') ?? 0;
 | 
				
			||||||
 | 
					          final int no =
 | 
				
			||||||
 | 
					              (raw[false] is int)
 | 
				
			||||||
 | 
					                  ? raw[false] as int
 | 
				
			||||||
 | 
					                  : int.tryParse('${raw[false]}') ?? 0;
 | 
				
			||||||
 | 
					          final total = (yes + no).clamp(0, 1 << 31);
 | 
				
			||||||
 | 
					          final yesPct = total == 0 ? 0.0 : yes / total;
 | 
				
			||||||
 | 
					          final noPct = total == 0 ? 0.0 : no / total;
 | 
				
			||||||
 | 
					          final theme = Theme.of(context);
 | 
				
			||||||
 | 
					          body = Column(
 | 
				
			||||||
 | 
					            crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					            children: [
 | 
				
			||||||
 | 
					              _BarStatRow(
 | 
				
			||||||
 | 
					                label: 'Yes',
 | 
				
			||||||
 | 
					                count: yes,
 | 
				
			||||||
 | 
					                fraction: yesPct,
 | 
				
			||||||
 | 
					                color: Colors.green.shade600,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					              const SizedBox(height: 6),
 | 
				
			||||||
 | 
					              _BarStatRow(
 | 
				
			||||||
 | 
					                label: 'No',
 | 
				
			||||||
 | 
					                count: no,
 | 
				
			||||||
 | 
					                fraction: noPct,
 | 
				
			||||||
 | 
					                color: Colors.red.shade600,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					              const SizedBox(height: 4),
 | 
				
			||||||
 | 
					              Text(
 | 
				
			||||||
 | 
					                'Total: $total',
 | 
				
			||||||
 | 
					                style: theme.textTheme.labelSmall?.copyWith(
 | 
				
			||||||
 | 
					                  color: theme.colorScheme.onSurfaceVariant,
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					          );
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      case SnPollQuestionType.singleChoice:
 | 
				
			||||||
 | 
					      case SnPollQuestionType.multipleChoice:
 | 
				
			||||||
 | 
					        // map optionId -> count
 | 
				
			||||||
 | 
					        if (raw is Map) {
 | 
				
			||||||
 | 
					          final options = [...?q.options]
 | 
				
			||||||
 | 
					            ..sort((a, b) => a.order.compareTo(b.order));
 | 
				
			||||||
 | 
					          final List<_OptionCount> items = [];
 | 
				
			||||||
 | 
					          int total = 0;
 | 
				
			||||||
 | 
					          for (final opt in options) {
 | 
				
			||||||
 | 
					            final dynamic v = raw[opt.id];
 | 
				
			||||||
 | 
					            final int count = v is int ? v : int.tryParse('$v') ?? 0;
 | 
				
			||||||
 | 
					            total += count;
 | 
				
			||||||
 | 
					            items.add(_OptionCount(id: opt.id, label: opt.label, count: count));
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					          if (items.isNotEmpty) {
 | 
				
			||||||
 | 
					            items.sort(
 | 
				
			||||||
 | 
					              (a, b) => b.count.compareTo(a.count),
 | 
				
			||||||
 | 
					            ); // show highest first
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					          body = Column(
 | 
				
			||||||
 | 
					            crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					            children: [
 | 
				
			||||||
 | 
					              for (final it in items)
 | 
				
			||||||
 | 
					                Padding(
 | 
				
			||||||
 | 
					                  padding: const EdgeInsets.only(bottom: 6),
 | 
				
			||||||
 | 
					                  child: _BarStatRow(
 | 
				
			||||||
 | 
					                    label: it.label,
 | 
				
			||||||
 | 
					                    count: it.count,
 | 
				
			||||||
 | 
					                    fraction: total == 0 ? 0 : it.count / total,
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              if (items.isNotEmpty)
 | 
				
			||||||
 | 
					                Text(
 | 
				
			||||||
 | 
					                  'Total: $total',
 | 
				
			||||||
 | 
					                  style: Theme.of(context).textTheme.labelSmall?.copyWith(
 | 
				
			||||||
 | 
					                    color: Theme.of(context).colorScheme.onSurfaceVariant,
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					          );
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      case SnPollQuestionType.freeText:
 | 
				
			||||||
 | 
					        // No stats
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (body == null) return const SizedBox.shrink();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return Padding(
 | 
				
			||||||
 | 
					      padding: const EdgeInsets.only(top: 8),
 | 
				
			||||||
 | 
					      child: DecoratedBox(
 | 
				
			||||||
 | 
					        decoration: BoxDecoration(
 | 
				
			||||||
 | 
					          color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.35),
 | 
				
			||||||
 | 
					          borderRadius: BorderRadius.circular(8),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        child: Padding(
 | 
				
			||||||
 | 
					          padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
 | 
				
			||||||
 | 
					          child: Column(
 | 
				
			||||||
 | 
					            crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					            children: [
 | 
				
			||||||
 | 
					              Text(
 | 
				
			||||||
 | 
					                'Stats',
 | 
				
			||||||
 | 
					                style: Theme.of(context).textTheme.labelLarge?.copyWith(
 | 
				
			||||||
 | 
					                  color: Theme.of(context).colorScheme.onSurfaceVariant,
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					              const SizedBox(height: 8),
 | 
				
			||||||
 | 
					              body,
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Widget _buildBody(BuildContext context) {
 | 
				
			||||||
 | 
					    final q = _current;
 | 
				
			||||||
 | 
					    switch (q.type) {
 | 
				
			||||||
 | 
					      case SnPollQuestionType.singleChoice:
 | 
				
			||||||
 | 
					        return _buildSingleChoice(context, q);
 | 
				
			||||||
 | 
					      case SnPollQuestionType.multipleChoice:
 | 
				
			||||||
 | 
					        return _buildMultipleChoice(context, q);
 | 
				
			||||||
 | 
					      case SnPollQuestionType.yesNo:
 | 
				
			||||||
 | 
					        return _buildYesNo(context, q);
 | 
				
			||||||
 | 
					      case SnPollQuestionType.rating:
 | 
				
			||||||
 | 
					        return _buildRating(context, q);
 | 
				
			||||||
 | 
					      case SnPollQuestionType.freeText:
 | 
				
			||||||
 | 
					        return _buildFreeText(context, q);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Widget _buildSingleChoice(BuildContext context, SnPollQuestion q) {
 | 
				
			||||||
 | 
					    final options = [...?q.options]..sort((a, b) => a.order.compareTo(b.order));
 | 
				
			||||||
 | 
					    return Column(
 | 
				
			||||||
 | 
					      children: [
 | 
				
			||||||
 | 
					        for (final opt in options)
 | 
				
			||||||
 | 
					          RadioListTile<String>(
 | 
				
			||||||
 | 
					            value: opt.id,
 | 
				
			||||||
 | 
					            groupValue: _singleChoiceSelected,
 | 
				
			||||||
 | 
					            onChanged: (val) => setState(() => _singleChoiceSelected = val),
 | 
				
			||||||
 | 
					            title: Text(opt.label),
 | 
				
			||||||
 | 
					            subtitle: opt.description != null ? Text(opt.description!) : null,
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Widget _buildMultipleChoice(BuildContext context, SnPollQuestion q) {
 | 
				
			||||||
 | 
					    final options = [...?q.options]..sort((a, b) => a.order.compareTo(b.order));
 | 
				
			||||||
 | 
					    return Column(
 | 
				
			||||||
 | 
					      children: [
 | 
				
			||||||
 | 
					        for (final opt in options)
 | 
				
			||||||
 | 
					          CheckboxListTile(
 | 
				
			||||||
 | 
					            value: _multiChoiceSelected.contains(opt.id),
 | 
				
			||||||
 | 
					            onChanged: (val) {
 | 
				
			||||||
 | 
					              setState(() {
 | 
				
			||||||
 | 
					                if (val == true) {
 | 
				
			||||||
 | 
					                  _multiChoiceSelected.add(opt.id);
 | 
				
			||||||
 | 
					                } else {
 | 
				
			||||||
 | 
					                  _multiChoiceSelected.remove(opt.id);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					              });
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            title: Text(opt.label),
 | 
				
			||||||
 | 
					            subtitle: opt.description != null ? Text(opt.description!) : null,
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Widget _buildYesNo(BuildContext context, SnPollQuestion q) {
 | 
				
			||||||
 | 
					    return Row(
 | 
				
			||||||
 | 
					      children: [
 | 
				
			||||||
 | 
					        Expanded(
 | 
				
			||||||
 | 
					          child: SegmentedButton<bool>(
 | 
				
			||||||
 | 
					            segments: const [
 | 
				
			||||||
 | 
					              ButtonSegment(value: true, label: Text('Yes')),
 | 
				
			||||||
 | 
					              ButtonSegment(value: false, label: Text('No')),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					            selected: _yesNoSelected == null ? {} : {_yesNoSelected!},
 | 
				
			||||||
 | 
					            onSelectionChanged: (sel) {
 | 
				
			||||||
 | 
					              setState(() {
 | 
				
			||||||
 | 
					                _yesNoSelected = sel.isEmpty ? null : sel.first;
 | 
				
			||||||
 | 
					              });
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            multiSelectionEnabled: false,
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Widget _buildRating(BuildContext context, SnPollQuestion q) {
 | 
				
			||||||
 | 
					    const max = 5;
 | 
				
			||||||
 | 
					    return Row(
 | 
				
			||||||
 | 
					      mainAxisAlignment: MainAxisAlignment.center,
 | 
				
			||||||
 | 
					      children: List.generate(max, (i) {
 | 
				
			||||||
 | 
					        final value = i + 1;
 | 
				
			||||||
 | 
					        final selected = (_ratingSelected ?? 0) >= value;
 | 
				
			||||||
 | 
					        return IconButton(
 | 
				
			||||||
 | 
					          icon: Icon(
 | 
				
			||||||
 | 
					            selected ? Icons.star : Icons.star_border,
 | 
				
			||||||
 | 
					            color: selected ? Colors.amber : null,
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          onPressed: () {
 | 
				
			||||||
 | 
					            setState(() {
 | 
				
			||||||
 | 
					              _ratingSelected = value;
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Widget _buildFreeText(BuildContext context, SnPollQuestion q) {
 | 
				
			||||||
 | 
					    return TextField(
 | 
				
			||||||
 | 
					      controller: _textController,
 | 
				
			||||||
 | 
					      maxLines: 6,
 | 
				
			||||||
 | 
					      decoration: const InputDecoration(border: OutlineInputBorder()),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Widget _buildNavBar(BuildContext context) {
 | 
				
			||||||
 | 
					    final isLast = _index == _questions.length - 1;
 | 
				
			||||||
 | 
					    final canProceed = _isCurrentAnswered() && !_submitting;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return Row(
 | 
				
			||||||
 | 
					      children: [
 | 
				
			||||||
 | 
					        OutlinedButton.icon(
 | 
				
			||||||
 | 
					          icon: const Icon(Icons.arrow_back),
 | 
				
			||||||
 | 
					          label: Text(_index == 0 ? 'Cancel' : 'Back'),
 | 
				
			||||||
 | 
					          onPressed: _submitting ? null : _back,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        const Spacer(),
 | 
				
			||||||
 | 
					        FilledButton.icon(
 | 
				
			||||||
 | 
					          icon:
 | 
				
			||||||
 | 
					              _submitting
 | 
				
			||||||
 | 
					                  ? const SizedBox(
 | 
				
			||||||
 | 
					                    width: 16,
 | 
				
			||||||
 | 
					                    height: 16,
 | 
				
			||||||
 | 
					                    child: CircularProgressIndicator(strokeWidth: 2),
 | 
				
			||||||
 | 
					                  )
 | 
				
			||||||
 | 
					                  : Icon(isLast ? Icons.check : Icons.arrow_forward),
 | 
				
			||||||
 | 
					          label: Text(isLast ? 'Submit' : 'Next'),
 | 
				
			||||||
 | 
					          onPressed: canProceed ? _next : null,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    if (_questions.isEmpty) {
 | 
				
			||||||
 | 
					      return const SizedBox.shrink();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return Column(
 | 
				
			||||||
 | 
					      crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
				
			||||||
 | 
					      children: [
 | 
				
			||||||
 | 
					        _buildHeader(context),
 | 
				
			||||||
 | 
					        const SizedBox(height: 12),
 | 
				
			||||||
 | 
					        _AnimatedStep(
 | 
				
			||||||
 | 
					          key: ValueKey(_current.id),
 | 
				
			||||||
 | 
					          child: Column(
 | 
				
			||||||
 | 
					            crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
				
			||||||
 | 
					            children: [_buildBody(context), _buildStats(context, _current)],
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        const SizedBox(height: 16),
 | 
				
			||||||
 | 
					        _buildNavBar(context),
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _OptionCount {
 | 
				
			||||||
 | 
					  final String id;
 | 
				
			||||||
 | 
					  final String label;
 | 
				
			||||||
 | 
					  final int count;
 | 
				
			||||||
 | 
					  const _OptionCount({
 | 
				
			||||||
 | 
					    required this.id,
 | 
				
			||||||
 | 
					    required this.label,
 | 
				
			||||||
 | 
					    required this.count,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _BarStatRow extends StatelessWidget {
 | 
				
			||||||
 | 
					  const _BarStatRow({
 | 
				
			||||||
 | 
					    required this.label,
 | 
				
			||||||
 | 
					    required this.count,
 | 
				
			||||||
 | 
					    required this.fraction,
 | 
				
			||||||
 | 
					    this.color,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final String label;
 | 
				
			||||||
 | 
					  final int count;
 | 
				
			||||||
 | 
					  final double fraction;
 | 
				
			||||||
 | 
					  final Color? color;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    final barColor = color ?? Theme.of(context).colorScheme.primary;
 | 
				
			||||||
 | 
					    final bgColor = Theme.of(
 | 
				
			||||||
 | 
					      context,
 | 
				
			||||||
 | 
					    ).colorScheme.surfaceVariant.withOpacity(0.6);
 | 
				
			||||||
 | 
					    final fg =
 | 
				
			||||||
 | 
					        (fraction.isNaN || fraction.isInfinite)
 | 
				
			||||||
 | 
					            ? 0.0
 | 
				
			||||||
 | 
					            : fraction.clamp(0.0, 1.0);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return Column(
 | 
				
			||||||
 | 
					      crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					      children: [
 | 
				
			||||||
 | 
					        Text('$label · $count', style: Theme.of(context).textTheme.labelMedium),
 | 
				
			||||||
 | 
					        const SizedBox(height: 4),
 | 
				
			||||||
 | 
					        LayoutBuilder(
 | 
				
			||||||
 | 
					          builder: (context, constraints) {
 | 
				
			||||||
 | 
					            final width = constraints.maxWidth;
 | 
				
			||||||
 | 
					            final filled = width * fg;
 | 
				
			||||||
 | 
					            return Stack(
 | 
				
			||||||
 | 
					              children: [
 | 
				
			||||||
 | 
					                Container(
 | 
				
			||||||
 | 
					                  height: 8,
 | 
				
			||||||
 | 
					                  width: width,
 | 
				
			||||||
 | 
					                  decoration: BoxDecoration(
 | 
				
			||||||
 | 
					                    color: bgColor,
 | 
				
			||||||
 | 
					                    borderRadius: BorderRadius.circular(999),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                Container(
 | 
				
			||||||
 | 
					                  height: 8,
 | 
				
			||||||
 | 
					                  width: filled,
 | 
				
			||||||
 | 
					                  decoration: BoxDecoration(
 | 
				
			||||||
 | 
					                    color: barColor,
 | 
				
			||||||
 | 
					                    borderRadius: BorderRadius.circular(999),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ],
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Simple fade/slide transition between questions.
 | 
				
			||||||
 | 
					class _AnimatedStep extends StatelessWidget {
 | 
				
			||||||
 | 
					  const _AnimatedStep({super.key, required this.child});
 | 
				
			||||||
 | 
					  final Widget child;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    return AnimatedSwitcher(
 | 
				
			||||||
 | 
					      duration: const Duration(milliseconds: 250),
 | 
				
			||||||
 | 
					      transitionBuilder: (child, anim) {
 | 
				
			||||||
 | 
					        final offset = Tween<Offset>(
 | 
				
			||||||
 | 
					          begin: const Offset(0.1, 0),
 | 
				
			||||||
 | 
					          end: Offset.zero,
 | 
				
			||||||
 | 
					        ).animate(anim);
 | 
				
			||||||
 | 
					        final fade = CurvedAnimation(parent: anim, curve: Curves.easeInOut);
 | 
				
			||||||
 | 
					        return FadeTransition(
 | 
				
			||||||
 | 
					          opacity: fade,
 | 
				
			||||||
 | 
					          child: SlideTransition(position: offset, child: child),
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      child: child,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										201
									
								
								lib/widgets/post/compose_poll.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										201
									
								
								lib/widgets/post/compose_poll.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,201 @@
 | 
				
			|||||||
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:flutter_hooks/flutter_hooks.dart';
 | 
				
			||||||
 | 
					import 'package:gap/gap.dart';
 | 
				
			||||||
 | 
					import 'package:go_router/go_router.dart';
 | 
				
			||||||
 | 
					import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
				
			||||||
 | 
					import 'package:island/models/poll.dart';
 | 
				
			||||||
 | 
					import 'package:island/models/publisher.dart';
 | 
				
			||||||
 | 
					import 'package:island/screens/creators/poll/poll_list.dart';
 | 
				
			||||||
 | 
					import 'package:island/widgets/content/sheet.dart';
 | 
				
			||||||
 | 
					import 'package:material_symbols_icons/symbols.dart';
 | 
				
			||||||
 | 
					import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
 | 
				
			||||||
 | 
					import 'package:styled_widget/styled_widget.dart';
 | 
				
			||||||
 | 
					import 'package:island/widgets/post/publishers_modal.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Bottom sheet for selecting or creating a poll. Returns SnPoll via Navigator.pop.
 | 
				
			||||||
 | 
					class ComposePollSheet extends HookConsumerWidget {
 | 
				
			||||||
 | 
					  /// Optional publisher name to filter polls and prefill creation.
 | 
				
			||||||
 | 
					  final String? pubName;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const ComposePollSheet({super.key, this.pubName});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context, WidgetRef ref) {
 | 
				
			||||||
 | 
					    final selectedPublisher = useState<String?>(pubName);
 | 
				
			||||||
 | 
					    final isPushing = useState(false);
 | 
				
			||||||
 | 
					    final errorText = useState<String?>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return SheetScaffold(
 | 
				
			||||||
 | 
					      heightFactor: 0.6,
 | 
				
			||||||
 | 
					      titleText: 'poll'.tr(),
 | 
				
			||||||
 | 
					      child: DefaultTabController(
 | 
				
			||||||
 | 
					        length: 2,
 | 
				
			||||||
 | 
					        child: Column(
 | 
				
			||||||
 | 
					          mainAxisSize: MainAxisSize.min,
 | 
				
			||||||
 | 
					          crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
				
			||||||
 | 
					          children: [
 | 
				
			||||||
 | 
					            TabBar(
 | 
				
			||||||
 | 
					              tabs: [
 | 
				
			||||||
 | 
					                Tab(text: 'pollsRecent'.tr()),
 | 
				
			||||||
 | 
					                Tab(text: 'pollCreateNew'.tr()),
 | 
				
			||||||
 | 
					              ],
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            Expanded(
 | 
				
			||||||
 | 
					              child: TabBarView(
 | 
				
			||||||
 | 
					                children: [
 | 
				
			||||||
 | 
					                  // Link/Select existing poll list
 | 
				
			||||||
 | 
					                  PagingHelperView(
 | 
				
			||||||
 | 
					                    provider: pollListNotifierProvider(pubName),
 | 
				
			||||||
 | 
					                    futureRefreshable: pollListNotifierProvider(pubName).future,
 | 
				
			||||||
 | 
					                    notifierRefreshable:
 | 
				
			||||||
 | 
					                        pollListNotifierProvider(pubName).notifier,
 | 
				
			||||||
 | 
					                    contentBuilder:
 | 
				
			||||||
 | 
					                        (data, widgetCount, endItemView) => ListView.builder(
 | 
				
			||||||
 | 
					                          padding: EdgeInsets.zero,
 | 
				
			||||||
 | 
					                          itemCount: widgetCount,
 | 
				
			||||||
 | 
					                          itemBuilder: (context, index) {
 | 
				
			||||||
 | 
					                            if (index == widgetCount - 1) {
 | 
				
			||||||
 | 
					                              return endItemView;
 | 
				
			||||||
 | 
					                            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                            final poll = data.items[index];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                            return ListTile(
 | 
				
			||||||
 | 
					                              leading: const Icon(Symbols.how_to_vote, fill: 1),
 | 
				
			||||||
 | 
					                              title: Text(poll.title ?? 'untitled'.tr()),
 | 
				
			||||||
 | 
					                              subtitle: _buildPollSubtitle(poll),
 | 
				
			||||||
 | 
					                              onTap: () {
 | 
				
			||||||
 | 
					                                Navigator.of(context).pop(poll);
 | 
				
			||||||
 | 
					                              },
 | 
				
			||||||
 | 
					                            );
 | 
				
			||||||
 | 
					                          },
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                  // Create new poll and return it
 | 
				
			||||||
 | 
					                  SingleChildScrollView(
 | 
				
			||||||
 | 
					                    child: Column(
 | 
				
			||||||
 | 
					                      crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					                      children: [
 | 
				
			||||||
 | 
					                        Text(
 | 
				
			||||||
 | 
					                          'pollCreateNewHint',
 | 
				
			||||||
 | 
					                        ).tr().fontSize(13).opacity(0.85).padding(bottom: 8),
 | 
				
			||||||
 | 
					                        ListTile(
 | 
				
			||||||
 | 
					                          title: Text(
 | 
				
			||||||
 | 
					                            selectedPublisher.value == null
 | 
				
			||||||
 | 
					                                ? 'publisher'.tr()
 | 
				
			||||||
 | 
					                                : '@${selectedPublisher.value}',
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                          subtitle: Text(
 | 
				
			||||||
 | 
					                            selectedPublisher.value == null
 | 
				
			||||||
 | 
					                                ? 'publisherHint'.tr()
 | 
				
			||||||
 | 
					                                : 'selected'.tr(),
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                          leading: const Icon(Symbols.account_circle),
 | 
				
			||||||
 | 
					                          trailing: const Icon(Symbols.chevron_right),
 | 
				
			||||||
 | 
					                          onTap: () async {
 | 
				
			||||||
 | 
					                            final picked =
 | 
				
			||||||
 | 
					                                await showModalBottomSheet<SnPublisher>(
 | 
				
			||||||
 | 
					                                  context: context,
 | 
				
			||||||
 | 
					                                  isScrollControlled: true,
 | 
				
			||||||
 | 
					                                  builder: (context) => const PublisherModal(),
 | 
				
			||||||
 | 
					                                );
 | 
				
			||||||
 | 
					                            if (picked != null) {
 | 
				
			||||||
 | 
					                              try {
 | 
				
			||||||
 | 
					                                final name = picked.name;
 | 
				
			||||||
 | 
					                                if (name.isNotEmpty) {
 | 
				
			||||||
 | 
					                                  selectedPublisher.value = name;
 | 
				
			||||||
 | 
					                                  errorText.value = null;
 | 
				
			||||||
 | 
					                                }
 | 
				
			||||||
 | 
					                              } catch (_) {
 | 
				
			||||||
 | 
					                                // ignore
 | 
				
			||||||
 | 
					                              }
 | 
				
			||||||
 | 
					                            }
 | 
				
			||||||
 | 
					                          },
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                        if (errorText.value != null)
 | 
				
			||||||
 | 
					                          Padding(
 | 
				
			||||||
 | 
					                            padding: const EdgeInsets.only(
 | 
				
			||||||
 | 
					                              left: 16,
 | 
				
			||||||
 | 
					                              right: 16,
 | 
				
			||||||
 | 
					                              top: 4,
 | 
				
			||||||
 | 
					                            ),
 | 
				
			||||||
 | 
					                            child: Text(
 | 
				
			||||||
 | 
					                              errorText.value!,
 | 
				
			||||||
 | 
					                              style: TextStyle(color: Colors.red[700]),
 | 
				
			||||||
 | 
					                            ),
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                        const Gap(16),
 | 
				
			||||||
 | 
					                        Align(
 | 
				
			||||||
 | 
					                          alignment: Alignment.centerRight,
 | 
				
			||||||
 | 
					                          child: FilledButton.icon(
 | 
				
			||||||
 | 
					                            icon:
 | 
				
			||||||
 | 
					                                isPushing.value
 | 
				
			||||||
 | 
					                                    ? const SizedBox(
 | 
				
			||||||
 | 
					                                      width: 18,
 | 
				
			||||||
 | 
					                                      height: 18,
 | 
				
			||||||
 | 
					                                      child: CircularProgressIndicator(
 | 
				
			||||||
 | 
					                                        strokeWidth: 2,
 | 
				
			||||||
 | 
					                                        color: Colors.white,
 | 
				
			||||||
 | 
					                                      ),
 | 
				
			||||||
 | 
					                                    )
 | 
				
			||||||
 | 
					                                    : const Icon(Symbols.add_circle),
 | 
				
			||||||
 | 
					                            label: Text('create'.tr()),
 | 
				
			||||||
 | 
					                            onPressed:
 | 
				
			||||||
 | 
					                                isPushing.value
 | 
				
			||||||
 | 
					                                    ? null
 | 
				
			||||||
 | 
					                                    : () async {
 | 
				
			||||||
 | 
					                                      final pub = selectedPublisher.value ?? '';
 | 
				
			||||||
 | 
					                                      if (pub.isEmpty) {
 | 
				
			||||||
 | 
					                                        errorText.value =
 | 
				
			||||||
 | 
					                                            'publisherCannotBeEmpty'.tr();
 | 
				
			||||||
 | 
					                                        return;
 | 
				
			||||||
 | 
					                                      }
 | 
				
			||||||
 | 
					                                      errorText.value = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                                      isPushing.value = true;
 | 
				
			||||||
 | 
					                                      // Push to creatorPollNew route and await result
 | 
				
			||||||
 | 
					                                      final result = await GoRouter.of(
 | 
				
			||||||
 | 
					                                        context,
 | 
				
			||||||
 | 
					                                      ).push<SnPoll>(
 | 
				
			||||||
 | 
					                                        '/creators/$pub/polls/new',
 | 
				
			||||||
 | 
					                                      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                                      if (result == null) {
 | 
				
			||||||
 | 
					                                        isPushing.value = false;
 | 
				
			||||||
 | 
					                                        return;
 | 
				
			||||||
 | 
					                                      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                                      if (!context.mounted) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                                      // Return created poll to caller of this bottom sheet
 | 
				
			||||||
 | 
					                                      Navigator.of(context).pop(result);
 | 
				
			||||||
 | 
					                                    },
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                      ],
 | 
				
			||||||
 | 
					                    ).padding(horizontal: 24, vertical: 24),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Widget? _buildPollSubtitle(SnPoll poll) {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final SnPoll dyn = poll;
 | 
				
			||||||
 | 
					      final List<SnPollQuestion>? options = dyn.questions;
 | 
				
			||||||
 | 
					      if (options == null || options.isEmpty) return null;
 | 
				
			||||||
 | 
					      final preview = options.take(3).map((e) => e.title).join(' · ');
 | 
				
			||||||
 | 
					      if (preview.trim().isEmpty) return null;
 | 
				
			||||||
 | 
					      return Text(preview);
 | 
				
			||||||
 | 
					    } catch (_) {
 | 
				
			||||||
 | 
					      return null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -14,6 +14,7 @@ import 'package:island/services/file.dart';
 | 
				
			|||||||
import 'package:island/services/compose_storage_db.dart';
 | 
					import 'package:island/services/compose_storage_db.dart';
 | 
				
			||||||
import 'package:island/widgets/alert.dart';
 | 
					import 'package:island/widgets/alert.dart';
 | 
				
			||||||
import 'package:island/widgets/post/compose_link_attachments.dart';
 | 
					import 'package:island/widgets/post/compose_link_attachments.dart';
 | 
				
			||||||
 | 
					import 'package:island/widgets/post/compose_poll.dart';
 | 
				
			||||||
import 'package:island/widgets/post/compose_recorder.dart';
 | 
					import 'package:island/widgets/post/compose_recorder.dart';
 | 
				
			||||||
import 'package:pasteboard/pasteboard.dart';
 | 
					import 'package:pasteboard/pasteboard.dart';
 | 
				
			||||||
import 'package:textfield_tags/textfield_tags.dart';
 | 
					import 'package:textfield_tags/textfield_tags.dart';
 | 
				
			||||||
@@ -33,6 +34,8 @@ class ComposeState {
 | 
				
			|||||||
  StringTagController categoriesController;
 | 
					  StringTagController categoriesController;
 | 
				
			||||||
  final String draftId;
 | 
					  final String draftId;
 | 
				
			||||||
  int postType;
 | 
					  int postType;
 | 
				
			||||||
 | 
					  // Linked poll id for this compose session (nullable)
 | 
				
			||||||
 | 
					  final ValueNotifier<String?> pollId;
 | 
				
			||||||
  Timer? _autoSaveTimer;
 | 
					  Timer? _autoSaveTimer;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  ComposeState({
 | 
					  ComposeState({
 | 
				
			||||||
@@ -48,7 +51,8 @@ class ComposeState {
 | 
				
			|||||||
    required this.categoriesController,
 | 
					    required this.categoriesController,
 | 
				
			||||||
    required this.draftId,
 | 
					    required this.draftId,
 | 
				
			||||||
    this.postType = 0,
 | 
					    this.postType = 0,
 | 
				
			||||||
  });
 | 
					    String? pollId,
 | 
				
			||||||
 | 
					  }) : pollId = ValueNotifier<String?>(pollId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  void startAutoSave(WidgetRef ref) {
 | 
					  void startAutoSave(WidgetRef ref) {
 | 
				
			||||||
    _autoSaveTimer?.cancel();
 | 
					    _autoSaveTimer?.cancel();
 | 
				
			||||||
@@ -111,6 +115,8 @@ class ComposeLogic {
 | 
				
			|||||||
      categoriesController: categoriesController,
 | 
					      categoriesController: categoriesController,
 | 
				
			||||||
      draftId: id,
 | 
					      draftId: id,
 | 
				
			||||||
      postType: postType,
 | 
					      postType: postType,
 | 
				
			||||||
 | 
					      // initialize without poll by default
 | 
				
			||||||
 | 
					      pollId: null,
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -138,6 +144,7 @@ class ComposeLogic {
 | 
				
			|||||||
      categoriesController: categoriesController,
 | 
					      categoriesController: categoriesController,
 | 
				
			||||||
      draftId: draft.id,
 | 
					      draftId: draft.id,
 | 
				
			||||||
      postType: postType,
 | 
					      postType: postType,
 | 
				
			||||||
 | 
					      pollId: null,
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -555,6 +562,27 @@ class ComposeLogic {
 | 
				
			|||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static Future<void> pickPoll(
 | 
				
			||||||
 | 
					    WidgetRef ref,
 | 
				
			||||||
 | 
					    ComposeState state,
 | 
				
			||||||
 | 
					    BuildContext context,
 | 
				
			||||||
 | 
					  ) async {
 | 
				
			||||||
 | 
					    if (state.pollId.value != null) {
 | 
				
			||||||
 | 
					      state.pollId.value = null;
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final poll = await showModalBottomSheet(
 | 
				
			||||||
 | 
					      context: context,
 | 
				
			||||||
 | 
					      useRootNavigator: true,
 | 
				
			||||||
 | 
					      isScrollControlled: true,
 | 
				
			||||||
 | 
					      builder: (context) => const ComposePollSheet(),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (poll == null) return;
 | 
				
			||||||
 | 
					    state.pollId.value = poll.id;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static Future<void> performAction(
 | 
					  static Future<void> performAction(
 | 
				
			||||||
    WidgetRef ref,
 | 
					    WidgetRef ref,
 | 
				
			||||||
    ComposeState state,
 | 
					    ComposeState state,
 | 
				
			||||||
@@ -613,6 +641,7 @@ class ComposeLogic {
 | 
				
			|||||||
        if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id,
 | 
					        if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id,
 | 
				
			||||||
        'tags': state.tagsController.getTags,
 | 
					        'tags': state.tagsController.getTags,
 | 
				
			||||||
        'categories': state.categoriesController.getTags,
 | 
					        'categories': state.categoriesController.getTags,
 | 
				
			||||||
 | 
					        if (state.pollId.value != null) 'poll_id': state.pollId.value,
 | 
				
			||||||
      };
 | 
					      };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Send request
 | 
					      // Send request
 | 
				
			||||||
@@ -703,5 +732,6 @@ class ComposeLogic {
 | 
				
			|||||||
    state.currentPublisher.dispose();
 | 
					    state.currentPublisher.dispose();
 | 
				
			||||||
    state.tagsController.dispose();
 | 
					    state.tagsController.dispose();
 | 
				
			||||||
    state.categoriesController.dispose();
 | 
					    state.categoriesController.dispose();
 | 
				
			||||||
 | 
					    state.pollId.dispose();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -36,6 +36,10 @@ class ComposeToolbar extends HookConsumerWidget {
 | 
				
			|||||||
      ComposeLogic.saveDraft(ref, state);
 | 
					      ComposeLogic.saveDraft(ref, state);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    void pickPoll() {
 | 
				
			||||||
 | 
					      ComposeLogic.pickPoll(ref, state, context);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    void showDraftManager() {
 | 
					    void showDraftManager() {
 | 
				
			||||||
      showModalBottomSheet(
 | 
					      showModalBottomSheet(
 | 
				
			||||||
        context: context,
 | 
					        context: context,
 | 
				
			||||||
@@ -88,6 +92,25 @@ class ComposeToolbar extends HookConsumerWidget {
 | 
				
			|||||||
                tooltip: 'linkAttachment'.tr(),
 | 
					                tooltip: 'linkAttachment'.tr(),
 | 
				
			||||||
                color: colorScheme.primary,
 | 
					                color: colorScheme.primary,
 | 
				
			||||||
              ),
 | 
					              ),
 | 
				
			||||||
 | 
					              // Poll button with visual state when a poll is linked
 | 
				
			||||||
 | 
					              ListenableBuilder(
 | 
				
			||||||
 | 
					                listenable: state.pollId,
 | 
				
			||||||
 | 
					                builder: (context, _) {
 | 
				
			||||||
 | 
					                  return IconButton(
 | 
				
			||||||
 | 
					                    onPressed: pickPoll,
 | 
				
			||||||
 | 
					                    icon: const Icon(Symbols.how_to_vote),
 | 
				
			||||||
 | 
					                    tooltip: 'poll'.tr(),
 | 
				
			||||||
 | 
					                    color: colorScheme.primary,
 | 
				
			||||||
 | 
					                    style: ButtonStyle(
 | 
				
			||||||
 | 
					                      backgroundColor: WidgetStatePropertyAll(
 | 
				
			||||||
 | 
					                        state.pollId.value != null
 | 
				
			||||||
 | 
					                            ? colorScheme.primary.withOpacity(0.15)
 | 
				
			||||||
 | 
					                            : null,
 | 
				
			||||||
 | 
					                      ),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  );
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
              const Spacer(),
 | 
					              const Spacer(),
 | 
				
			||||||
              if (originalPost == null && state.isEmpty)
 | 
					              if (originalPost == null && state.isEmpty)
 | 
				
			||||||
                IconButton(
 | 
					                IconButton(
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,6 +8,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
 | 
				
			|||||||
import 'package:gap/gap.dart';
 | 
					import 'package:gap/gap.dart';
 | 
				
			||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
					import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
				
			||||||
import 'package:island/models/embed.dart';
 | 
					import 'package:island/models/embed.dart';
 | 
				
			||||||
 | 
					import 'package:island/models/poll.dart';
 | 
				
			||||||
import 'package:island/models/post.dart';
 | 
					import 'package:island/models/post.dart';
 | 
				
			||||||
import 'package:island/pods/network.dart';
 | 
					import 'package:island/pods/network.dart';
 | 
				
			||||||
import 'package:island/pods/translate.dart';
 | 
					import 'package:island/pods/translate.dart';
 | 
				
			||||||
@@ -21,6 +22,7 @@ import 'package:island/widgets/content/cloud_file_collection.dart';
 | 
				
			|||||||
import 'package:island/widgets/content/cloud_files.dart';
 | 
					import 'package:island/widgets/content/cloud_files.dart';
 | 
				
			||||||
import 'package:island/widgets/content/embed/link.dart';
 | 
					import 'package:island/widgets/content/embed/link.dart';
 | 
				
			||||||
import 'package:island/widgets/content/markdown.dart';
 | 
					import 'package:island/widgets/content/markdown.dart';
 | 
				
			||||||
 | 
					import 'package:island/widgets/poll/poll_submit.dart';
 | 
				
			||||||
import 'package:island/widgets/post/post_replies_sheet.dart';
 | 
					import 'package:island/widgets/post/post_replies_sheet.dart';
 | 
				
			||||||
import 'package:island/widgets/safety/abuse_report_helper.dart';
 | 
					import 'package:island/widgets/safety/abuse_report_helper.dart';
 | 
				
			||||||
import 'package:island/widgets/share/share_sheet.dart';
 | 
					import 'package:island/widgets/share/share_sheet.dart';
 | 
				
			||||||
@@ -542,10 +544,9 @@ class PostItem extends HookConsumerWidget {
 | 
				
			|||||||
            ),
 | 
					            ),
 | 
				
			||||||
          ),
 | 
					          ),
 | 
				
			||||||
        if (item.meta?['embeds'] != null)
 | 
					        if (item.meta?['embeds'] != null)
 | 
				
			||||||
          ...((item.meta!['embeds'] as List<dynamic>)
 | 
					          ...((item.meta!['embeds'] as List<dynamic>).map(
 | 
				
			||||||
              .where((embed) => embed['Type'] == 'link')
 | 
					            (embedData) => switch (embedData['type']) {
 | 
				
			||||||
              .map(
 | 
					              'link' => EmbedLinkWidget(
 | 
				
			||||||
                (embedData) => EmbedLinkWidget(
 | 
					 | 
				
			||||||
                link: SnEmbedLink.fromJson(embedData as Map<String, dynamic>),
 | 
					                link: SnEmbedLink.fromJson(embedData as Map<String, dynamic>),
 | 
				
			||||||
                maxWidth: math.min(
 | 
					                maxWidth: math.min(
 | 
				
			||||||
                  MediaQuery.of(context).size.width,
 | 
					                  MediaQuery.of(context).size.width,
 | 
				
			||||||
@@ -558,6 +559,20 @@ class PostItem extends HookConsumerWidget {
 | 
				
			|||||||
                  right: renderingPadding.horizontal,
 | 
					                  right: renderingPadding.horizontal,
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
              ),
 | 
					              ),
 | 
				
			||||||
 | 
					              'poll' => Card(
 | 
				
			||||||
 | 
					                margin: EdgeInsets.symmetric(
 | 
				
			||||||
 | 
					                  horizontal: renderingPadding.horizontal,
 | 
				
			||||||
 | 
					                  vertical: 8,
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                child: PollSubmit(
 | 
				
			||||||
 | 
					                  initialAnswers: embedData['poll']?['user_answer']?['answer'],
 | 
				
			||||||
 | 
					                  stats: embedData['poll']?['stats'],
 | 
				
			||||||
 | 
					                  poll: SnPollWithStats.fromJson(embedData['poll']),
 | 
				
			||||||
 | 
					                  onSubmit: (_) {},
 | 
				
			||||||
 | 
					                ).padding(horizontal: 16, vertical: 12),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					              _ => Text('Unable show embed: ${embedData['type']}'),
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
          )),
 | 
					          )),
 | 
				
			||||||
        if (isShowReference)
 | 
					        if (isShowReference)
 | 
				
			||||||
          _buildReferencePost(context, item, renderingPadding),
 | 
					          _buildReferencePost(context, item, renderingPadding),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
 | 
				
			|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
 | 
					# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
 | 
				
			||||||
# In Windows, build-name is used as the major, minor, and patch parts
 | 
					# In Windows, build-name is used as the major, minor, and patch parts
 | 
				
			||||||
# of the product and file versions while build-number is used as the build suffix.
 | 
					# of the product and file versions while build-number is used as the build suffix.
 | 
				
			||||||
version: 3.1.0+116
 | 
					version: 3.1.0+117
 | 
				
			||||||
 | 
					
 | 
				
			||||||
environment:
 | 
					environment:
 | 
				
			||||||
  sdk: ^3.7.2
 | 
					  sdk: ^3.7.2
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user