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.github.bumptech.glide:glide:4.16.0") | ||||
|     implementation("com.squareup.okhttp3:okhttp:4.12.0") | ||||
|     implementation("com.google.firebase:firebase-messaging-ktx") | ||||
| } | ||||
|  | ||||
| flutter { | ||||
|   | ||||
| @@ -117,14 +117,6 @@ | ||||
|             android:enabled="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 | ||||
|             android:name="androidx.core.content.FileProvider" | ||||
|             android:authorities="dev.solsynth.solian.provider" | ||||
| @@ -151,4 +143,4 @@ | ||||
|             <data android:mimeType="text/plain" /> | ||||
|         </intent> | ||||
|     </queries> | ||||
| </manifest> | ||||
| </manifest> | ||||
|   | ||||
| @@ -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.selfHarm": "Self-harm", | ||||
|   "sensitiveCategories.childAbuse": "Child Abuse", | ||||
|   "sensitiveCategories.other": "Other" | ||||
|   "sensitiveCategories.other": "Other", | ||||
|   "poll": "Poll", | ||||
|   "pollsRecent": "Recent Polls", | ||||
|   "pollCreateNew": "Create New", | ||||
|   "pollCreateNewHint": "Create a new poll for your post. Pick a publisher and continue.", | ||||
|   "publisher": "Publisher", | ||||
|   "publisherHint": "Enter the publisher name", | ||||
|   "publisherCannotBeEmpty": "Publisher cannot be empty", | ||||
|   "operationFailed": "Operation failed: {}" | ||||
| } | ||||
|   | ||||
							
								
								
									
										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/stickers/stickers.dart'; | ||||
| import 'package:island/screens/creators/stickers/pack_detail.dart'; | ||||
| import 'package:island/screens/creators/poll/poll_list.dart'; | ||||
| import 'package:island/screens/creators/publishers.dart'; | ||||
| import 'package:island/screens/creators/webfeed/webfeed_list.dart'; | ||||
| import 'package:island/screens/creators/webfeed/webfeed_edit.dart'; | ||||
| import 'package:island/screens/poll/poll_editor.dart'; | ||||
| import 'package:island/screens/posts/compose.dart'; | ||||
| import 'package:island/screens/posts/post_detail.dart'; | ||||
| import 'package:island/screens/posts/pub_profile.dart'; | ||||
| @@ -144,6 +146,37 @@ final routerProvider = Provider<GoRouter>((ref) { | ||||
|                   return CreatorPostListScreen(pubName: name); | ||||
|                 }, | ||||
|               ), | ||||
|               // Poll list route | ||||
|               GoRoute( | ||||
|                 name: 'creatorPolls', | ||||
|                 path: '/creators/:name/polls', | ||||
|                 builder: (context, state) { | ||||
|                   final name = state.pathParameters['name']!; | ||||
|                   return CreatorPollListScreen(pubName: name); | ||||
|                 }, | ||||
|               ), | ||||
|               // Poll routes | ||||
|               GoRoute( | ||||
|                 name: 'creatorPollNew', | ||||
|                 path: '/creators/:name/polls/new', | ||||
|                 builder: (context, state) { | ||||
|                   final name = state.pathParameters['name']!; | ||||
|                   // initialPollId left null for create; initialPublisher prefilled | ||||
|                   return PollEditorScreen(initialPublisher: name); | ||||
|                 }, | ||||
|               ), | ||||
|               GoRoute( | ||||
|                 name: 'creatorPollEdit', | ||||
|                 path: '/creators/:name/polls/:id/edit', | ||||
|                 builder: (context, state) { | ||||
|                   final name = state.pathParameters['name']!; | ||||
|                   final id = state.pathParameters['id']!; | ||||
|                   return PollEditorScreen( | ||||
|                     initialPollId: id, | ||||
|                     initialPublisher: name, | ||||
|                   ); | ||||
|                 }, | ||||
|               ), | ||||
|               GoRoute( | ||||
|                 name: 'creatorStickers', | ||||
|                 path: '/creators/:name/stickers', | ||||
|   | ||||
| @@ -380,6 +380,23 @@ class CreatorHubScreen extends HookConsumerWidget { | ||||
|                               ); | ||||
|                             }, | ||||
|                           ), | ||||
|                           ListTile( | ||||
|                             minTileHeight: 48, | ||||
|                             title: const Text('Polls'), | ||||
|                             trailing: const Icon(Symbols.chevron_right), | ||||
|                             leading: const Icon(Symbols.poll), | ||||
|                             contentPadding: const EdgeInsets.symmetric( | ||||
|                               horizontal: 24, | ||||
|                             ), | ||||
|                             onTap: () { | ||||
|                               context.pushNamed( | ||||
|                                 'creatorPolls', | ||||
|                                 pathParameters: { | ||||
|                                   'name': currentPublisher.value!.name, | ||||
|                                 }, | ||||
|                               ); | ||||
|                             }, | ||||
|                           ), | ||||
|                           ListTile( | ||||
|                             minTileHeight: 48, | ||||
|                             title: Text('publisherMembers').tr(), | ||||
|   | ||||
							
								
								
									
										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/widgets/alert.dart'; | ||||
| import 'package:island/widgets/post/compose_link_attachments.dart'; | ||||
| import 'package:island/widgets/post/compose_poll.dart'; | ||||
| import 'package:island/widgets/post/compose_recorder.dart'; | ||||
| import 'package:pasteboard/pasteboard.dart'; | ||||
| import 'package:textfield_tags/textfield_tags.dart'; | ||||
| @@ -33,6 +34,8 @@ class ComposeState { | ||||
|   StringTagController categoriesController; | ||||
|   final String draftId; | ||||
|   int postType; | ||||
|   // Linked poll id for this compose session (nullable) | ||||
|   final ValueNotifier<String?> pollId; | ||||
|   Timer? _autoSaveTimer; | ||||
|  | ||||
|   ComposeState({ | ||||
| @@ -48,7 +51,8 @@ class ComposeState { | ||||
|     required this.categoriesController, | ||||
|     required this.draftId, | ||||
|     this.postType = 0, | ||||
|   }); | ||||
|     String? pollId, | ||||
|   }) : pollId = ValueNotifier<String?>(pollId); | ||||
|  | ||||
|   void startAutoSave(WidgetRef ref) { | ||||
|     _autoSaveTimer?.cancel(); | ||||
| @@ -111,6 +115,8 @@ class ComposeLogic { | ||||
|       categoriesController: categoriesController, | ||||
|       draftId: id, | ||||
|       postType: postType, | ||||
|       // initialize without poll by default | ||||
|       pollId: null, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| @@ -138,6 +144,7 @@ class ComposeLogic { | ||||
|       categoriesController: categoriesController, | ||||
|       draftId: draft.id, | ||||
|       postType: postType, | ||||
|       pollId: null, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| @@ -555,6 +562,27 @@ class ComposeLogic { | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   static Future<void> pickPoll( | ||||
|     WidgetRef ref, | ||||
|     ComposeState state, | ||||
|     BuildContext context, | ||||
|   ) async { | ||||
|     if (state.pollId.value != null) { | ||||
|       state.pollId.value = null; | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     final poll = await showModalBottomSheet( | ||||
|       context: context, | ||||
|       useRootNavigator: true, | ||||
|       isScrollControlled: true, | ||||
|       builder: (context) => const ComposePollSheet(), | ||||
|     ); | ||||
|  | ||||
|     if (poll == null) return; | ||||
|     state.pollId.value = poll.id; | ||||
|   } | ||||
|  | ||||
|   static Future<void> performAction( | ||||
|     WidgetRef ref, | ||||
|     ComposeState state, | ||||
| @@ -613,6 +641,7 @@ class ComposeLogic { | ||||
|         if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id, | ||||
|         'tags': state.tagsController.getTags, | ||||
|         'categories': state.categoriesController.getTags, | ||||
|         if (state.pollId.value != null) 'poll_id': state.pollId.value, | ||||
|       }; | ||||
|  | ||||
|       // Send request | ||||
| @@ -703,5 +732,6 @@ class ComposeLogic { | ||||
|     state.currentPublisher.dispose(); | ||||
|     state.tagsController.dispose(); | ||||
|     state.categoriesController.dispose(); | ||||
|     state.pollId.dispose(); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -36,6 +36,10 @@ class ComposeToolbar extends HookConsumerWidget { | ||||
|       ComposeLogic.saveDraft(ref, state); | ||||
|     } | ||||
|  | ||||
|     void pickPoll() { | ||||
|       ComposeLogic.pickPoll(ref, state, context); | ||||
|     } | ||||
|  | ||||
|     void showDraftManager() { | ||||
|       showModalBottomSheet( | ||||
|         context: context, | ||||
| @@ -88,6 +92,25 @@ class ComposeToolbar extends HookConsumerWidget { | ||||
|                 tooltip: 'linkAttachment'.tr(), | ||||
|                 color: colorScheme.primary, | ||||
|               ), | ||||
|               // Poll button with visual state when a poll is linked | ||||
|               ListenableBuilder( | ||||
|                 listenable: state.pollId, | ||||
|                 builder: (context, _) { | ||||
|                   return IconButton( | ||||
|                     onPressed: pickPoll, | ||||
|                     icon: const Icon(Symbols.how_to_vote), | ||||
|                     tooltip: 'poll'.tr(), | ||||
|                     color: colorScheme.primary, | ||||
|                     style: ButtonStyle( | ||||
|                       backgroundColor: WidgetStatePropertyAll( | ||||
|                         state.pollId.value != null | ||||
|                             ? colorScheme.primary.withOpacity(0.15) | ||||
|                             : null, | ||||
|                       ), | ||||
|                     ), | ||||
|                   ); | ||||
|                 }, | ||||
|               ), | ||||
|               const Spacer(), | ||||
|               if (originalPost == null && state.isEmpty) | ||||
|                 IconButton( | ||||
|   | ||||
| @@ -8,6 +8,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/embed.dart'; | ||||
| import 'package:island/models/poll.dart'; | ||||
| import 'package:island/models/post.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/pods/translate.dart'; | ||||
| @@ -21,6 +22,7 @@ import 'package:island/widgets/content/cloud_file_collection.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:island/widgets/content/embed/link.dart'; | ||||
| import 'package:island/widgets/content/markdown.dart'; | ||||
| import 'package:island/widgets/poll/poll_submit.dart'; | ||||
| import 'package:island/widgets/post/post_replies_sheet.dart'; | ||||
| import 'package:island/widgets/safety/abuse_report_helper.dart'; | ||||
| import 'package:island/widgets/share/share_sheet.dart'; | ||||
| @@ -542,23 +544,36 @@ class PostItem extends HookConsumerWidget { | ||||
|             ), | ||||
|           ), | ||||
|         if (item.meta?['embeds'] != null) | ||||
|           ...((item.meta!['embeds'] as List<dynamic>) | ||||
|               .where((embed) => embed['Type'] == 'link') | ||||
|               .map( | ||||
|                 (embedData) => EmbedLinkWidget( | ||||
|                   link: SnEmbedLink.fromJson(embedData as Map<String, dynamic>), | ||||
|                   maxWidth: math.min( | ||||
|                     MediaQuery.of(context).size.width, | ||||
|                     kWideScreenWidth, | ||||
|                   ), | ||||
|                   margin: EdgeInsets.only( | ||||
|                     top: 4, | ||||
|                     bottom: 4, | ||||
|                     left: renderingPadding.horizontal, | ||||
|                     right: renderingPadding.horizontal, | ||||
|                   ), | ||||
|           ...((item.meta!['embeds'] as List<dynamic>).map( | ||||
|             (embedData) => switch (embedData['type']) { | ||||
|               'link' => EmbedLinkWidget( | ||||
|                 link: SnEmbedLink.fromJson(embedData as Map<String, dynamic>), | ||||
|                 maxWidth: math.min( | ||||
|                   MediaQuery.of(context).size.width, | ||||
|                   kWideScreenWidth, | ||||
|                 ), | ||||
|               )), | ||||
|                 margin: EdgeInsets.only( | ||||
|                   top: 4, | ||||
|                   bottom: 4, | ||||
|                   left: renderingPadding.horizontal, | ||||
|                   right: renderingPadding.horizontal, | ||||
|                 ), | ||||
|               ), | ||||
|               'poll' => Card( | ||||
|                 margin: EdgeInsets.symmetric( | ||||
|                   horizontal: renderingPadding.horizontal, | ||||
|                   vertical: 8, | ||||
|                 ), | ||||
|                 child: PollSubmit( | ||||
|                   initialAnswers: embedData['poll']?['user_answer']?['answer'], | ||||
|                   stats: embedData['poll']?['stats'], | ||||
|                   poll: SnPollWithStats.fromJson(embedData['poll']), | ||||
|                   onSubmit: (_) {}, | ||||
|                 ).padding(horizontal: 16, vertical: 12), | ||||
|               ), | ||||
|               _ => Text('Unable show embed: ${embedData['type']}'), | ||||
|             }, | ||||
|           )), | ||||
|         if (isShowReference) | ||||
|           _buildReferencePost(context, item, renderingPadding), | ||||
|         if (item.repliesCount > 0 && isEmbedReply) | ||||
|   | ||||
| @@ -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 | ||||
| # 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. | ||||
| version: 3.1.0+116 | ||||
| version: 3.1.0+117 | ||||
|  | ||||
| environment: | ||||
|   sdk: ^3.7.2 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user