Compare commits
	
		
			3 Commits
		
	
	
		
			a6d869ebf6
			...
			3.1.0+116
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 8956723ac5 | |||
| ccc3ac415e | |||
| 8c47a59b80 | 
| @@ -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" | ||||||
| @@ -151,4 +143,4 @@ | |||||||
|             <data android:mimeType="text/plain" /> |             <data android:mimeType="text/plain" /> | ||||||
|         </intent> |         </intent> | ||||||
|     </queries> |     </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()) |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,8 +1,10 @@ | |||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:gap/gap.dart'; | ||||||
| import 'package:go_router/go_router.dart'; | import 'package:go_router/go_router.dart'; | ||||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
| import 'package:island/models/poll.dart'; | import 'package:island/models/poll.dart'; | ||||||
| import 'package:island/pods/network.dart'; | import 'package:island/pods/network.dart'; | ||||||
|  | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||||
| import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | ||||||
|  |  | ||||||
| @@ -57,11 +59,9 @@ class CreatorPollListScreen extends HookConsumerWidget { | |||||||
|   final String pubName; |   final String pubName; | ||||||
|  |  | ||||||
|   Future<void> _createPoll(BuildContext context) async { |   Future<void> _createPoll(BuildContext context) async { | ||||||
|     // Use named route defined in router with :name param (creatorPollNew) |  | ||||||
|     final result = await GoRouter.of( |     final result = await GoRouter.of( | ||||||
|       context, |       context, | ||||||
|     ).pushNamed('creatorPollNew', pathParameters: {'name': pubName}); |     ).pushNamed('creatorPollNew', pathParameters: {'name': pubName}); | ||||||
|     // If PollEditorScreen returns a created SnPoll on success, pop back with it |  | ||||||
|     if (result is SnPoll && context.mounted) { |     if (result is SnPoll && context.mounted) { | ||||||
|       Navigator.of(context).maybePop(result); |       Navigator.of(context).maybePop(result); | ||||||
|     } |     } | ||||||
| @@ -91,7 +91,7 @@ class CreatorPollListScreen extends HookConsumerWidget { | |||||||
|                         return endItemView; |                         return endItemView; | ||||||
|                       } |                       } | ||||||
|                       final poll = data.items[index]; |                       final poll = data.items[index]; | ||||||
|                       return _CreatorPollItem(poll: poll); |                       return _CreatorPollItem(poll: poll, pubName: pubName); | ||||||
|                     }, |                     }, | ||||||
|                   ), |                   ), | ||||||
|             ), |             ), | ||||||
| @@ -103,7 +103,8 @@ class CreatorPollListScreen extends HookConsumerWidget { | |||||||
| } | } | ||||||
|  |  | ||||||
| class _CreatorPollItem extends StatelessWidget { | class _CreatorPollItem extends StatelessWidget { | ||||||
|   const _CreatorPollItem({required this.poll}); |   final String pubName; | ||||||
|  |   const _CreatorPollItem({required this.poll, required this.pubName}); | ||||||
|  |  | ||||||
|   final SnPoll poll; |   final SnPoll poll; | ||||||
|  |  | ||||||
| @@ -143,24 +144,23 @@ class _CreatorPollItem extends StatelessWidget { | |||||||
|           ], |           ], | ||||||
|         ), |         ), | ||||||
|         trailing: PopupMenuButton<String>( |         trailing: PopupMenuButton<String>( | ||||||
|           onSelected: (v) { |  | ||||||
|             switch (v) { |  | ||||||
|               case 'edit': |  | ||||||
|                 // Use global router helper if desired |  | ||||||
|                 // context.push('/creators/${poll.publisher?.name ?? ''}/polls/${poll.id}/edit'); |  | ||||||
|                 Navigator.of(context).pushNamed( |  | ||||||
|                   'creatorPollEdit', |  | ||||||
|                   arguments: { |  | ||||||
|                     'name': poll.publisher?.name ?? '', |  | ||||||
|                     'id': poll.id, |  | ||||||
|                   }, |  | ||||||
|                 ); |  | ||||||
|                 break; |  | ||||||
|             } |  | ||||||
|           }, |  | ||||||
|           itemBuilder: |           itemBuilder: | ||||||
|               (context) => [ |               (context) => [ | ||||||
|                 const PopupMenuItem(value: 'edit', child: Text('Edit')), |                 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: () { |         onTap: () { | ||||||
|   | |||||||
| @@ -1,3 +1,5 @@ | |||||||
|  | import 'dart:developer'; | ||||||
|  |  | ||||||
| import 'package:flutter/foundation.dart'; | import 'package:flutter/foundation.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter/services.dart'; | import 'package:flutter/services.dart'; | ||||||
| @@ -6,7 +8,6 @@ import 'package:dio/dio.dart'; | |||||||
| import 'package:gap/gap.dart'; | import 'package:gap/gap.dart'; | ||||||
| import 'package:island/pods/network.dart'; | import 'package:island/pods/network.dart'; | ||||||
| import 'package:island/widgets/alert.dart'; | import 'package:island/widgets/alert.dart'; | ||||||
| import 'package:island/widgets/post/publishers_modal.dart'; |  | ||||||
| import 'package:island/models/poll.dart'; | import 'package:island/models/poll.dart'; | ||||||
| import 'package:uuid/uuid.dart'; | import 'package:uuid/uuid.dart'; | ||||||
|  |  | ||||||
| @@ -63,14 +64,38 @@ class PollEditor extends Notifier<PollEditorState> { | |||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   void setEditingId(String? id) { |   Future<void> setEditingId(BuildContext context, String? id) async { | ||||||
|     state = PollEditorState( |     if (id == null || id.isEmpty) return; | ||||||
|       id: id, |  | ||||||
|       title: state.title, |     showLoadingModal(context); | ||||||
|       description: state.description, |     final dio = ref.read(apiClientProvider); | ||||||
|       endedAt: state.endedAt, |     try { | ||||||
|       questions: [...state.questions], |       final res = await dio.get('/sphere/polls/$id'); | ||||||
|     ); |  | ||||||
|  |       // Handle both plain object and wrapped response formats. | ||||||
|  |       final dynamic payload = res.data; | ||||||
|  |       final Map<String, dynamic> json = | ||||||
|  |           payload is Map && payload['data'] is Map<String, dynamic> | ||||||
|  |               ? Map<String, dynamic>.from(payload['data'] as Map) | ||||||
|  |               : Map<String, dynamic>.from(payload as Map); | ||||||
|  |  | ||||||
|  |       final poll = SnPoll.fromJson(json); | ||||||
|  |  | ||||||
|  |       state = PollEditorState( | ||||||
|  |         id: poll.id, | ||||||
|  |         title: poll.title, | ||||||
|  |         description: poll.description, | ||||||
|  |         endedAt: poll.endedAt, | ||||||
|  |         questions: poll.questions, | ||||||
|  |       ); | ||||||
|  |     } on DioException catch (e) { | ||||||
|  |       log('Failed to load poll $id: ${e.message}'); | ||||||
|  |       // Keep state with id set; UI may handle error display. | ||||||
|  |     } catch (e) { | ||||||
|  |       log('Unexpected error loading poll $id: $e'); | ||||||
|  |     } finally { | ||||||
|  |       if (context.mounted) hideLoadingModal(context); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   void addQuestion(SnPollQuestionType type) { |   void addQuestion(SnPollQuestionType type) { | ||||||
| @@ -313,32 +338,10 @@ class PollEditorScreen extends ConsumerWidget { | |||||||
|  |  | ||||||
|   // Submit helpers declared before build to avoid forward reference issues |   // Submit helpers declared before build to avoid forward reference issues | ||||||
|  |  | ||||||
|   static Future<void> _submitPoll(BuildContext context, WidgetRef ref) async { |   Future<void> _submitPoll(BuildContext context, WidgetRef ref) async { | ||||||
|     final model = ref.watch(pollEditorProvider); |     final model = ref.watch(pollEditorProvider); | ||||||
|     final dio = ref.read(apiClientProvider); |     final dio = ref.read(apiClientProvider); | ||||||
|  |  | ||||||
|     // Pick publisher (required) |  | ||||||
|     final pickedPublisher = await showModalBottomSheet<dynamic>( |  | ||||||
|       context: context, |  | ||||||
|       useRootNavigator: true, |  | ||||||
|       isScrollControlled: true, |  | ||||||
|       builder: (_) => const PublisherModal(), |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     if (pickedPublisher == null) { |  | ||||||
|       showSnackBar('Publisher is required'); |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     final String publisherName = |  | ||||||
|         pickedPublisher.name ?? pickedPublisher['name'] ?? ''; |  | ||||||
|     if (publisherName.isEmpty) { |  | ||||||
|       ScaffoldMessenger.of(context).showSnackBar( |  | ||||||
|         const SnackBar(content: Text('Invalid publisher selected')), |  | ||||||
|       ); |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // Build payload |     // Build payload | ||||||
|     final body = { |     final body = { | ||||||
|       'title': model.title, |       'title': model.title, | ||||||
| @@ -376,33 +379,21 @@ class PollEditorScreen extends ConsumerWidget { | |||||||
|           await (isUpdate |           await (isUpdate | ||||||
|               ? dio.patch( |               ? dio.patch( | ||||||
|                 path, |                 path, | ||||||
|                 queryParameters: {'pub': publisherName}, |                 queryParameters: {'pub': initialPublisher}, | ||||||
|                 data: body, |                 data: body, | ||||||
|               ) |               ) | ||||||
|               : dio.post( |               : dio.post( | ||||||
|                 path, |                 path, | ||||||
|                 queryParameters: {'pub': publisherName}, |                 queryParameters: {'pub': initialPublisher}, | ||||||
|                 data: body, |                 data: body, | ||||||
|               )); |               )); | ||||||
|  |  | ||||||
|       ScaffoldMessenger.of(context).showSnackBar( |       showSnackBar(isUpdate ? 'Poll updated.' : 'Poll created.'); | ||||||
|         SnackBar(content: Text(isUpdate ? 'Poll updated.' : 'Poll created.')), |  | ||||||
|       ); |  | ||||||
|  |  | ||||||
|       if (!context.mounted) return; |       if (!context.mounted) return; | ||||||
|       Navigator.of(context).maybePop(res.data); |       Navigator.of(context).maybePop(res.data); | ||||||
|     } on DioException catch (e) { |  | ||||||
|       final msg = |  | ||||||
|           e.response?.data is Map && (e.response!.data['message'] != null) |  | ||||||
|               ? e.response!.data['message'].toString() |  | ||||||
|               : e.message ?? 'Network error'; |  | ||||||
|       ScaffoldMessenger.of( |  | ||||||
|         context, |  | ||||||
|       ).showSnackBar(SnackBar(content: Text('Failed: $msg'))); |  | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       ScaffoldMessenger.of( |       showErrorAlert(e); | ||||||
|         context, |  | ||||||
|       ).showSnackBar(SnackBar(content: Text('Unexpected error: $e'))); |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -417,7 +408,9 @@ class PollEditorScreen extends ConsumerWidget { | |||||||
|  |  | ||||||
|     // initialize editing state if provided |     // initialize editing state if provided | ||||||
|     if (initialPollId != null && model.id != initialPollId) { |     if (initialPollId != null && model.id != initialPollId) { | ||||||
|       notifier.setEditingId(initialPollId); |       Future(() { | ||||||
|  |         if (context.mounted) notifier.setEditingId(context, initialPollId); | ||||||
|  |       }); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     return Scaffold( |     return Scaffold( | ||||||
| @@ -437,6 +430,7 @@ class PollEditorScreen extends ConsumerWidget { | |||||||
|       ), |       ), | ||||||
|       body: SafeArea( |       body: SafeArea( | ||||||
|         child: Form( |         child: Form( | ||||||
|  |           key: ValueKey(model.id), | ||||||
|           child: ListView( |           child: ListView( | ||||||
|             padding: const EdgeInsets.all(16), |             padding: const EdgeInsets.all(16), | ||||||
|             children: [ |             children: [ | ||||||
|   | |||||||
| @@ -23,6 +23,7 @@ class PollSubmit extends ConsumerStatefulWidget { | |||||||
|     super.key, |     super.key, | ||||||
|     required this.poll, |     required this.poll, | ||||||
|     required this.onSubmit, |     required this.onSubmit, | ||||||
|  |     required this.stats, | ||||||
|     this.initialAnswers, |     this.initialAnswers, | ||||||
|     this.onCancel, |     this.onCancel, | ||||||
|     this.showProgress = true, |     this.showProgress = true, | ||||||
| @@ -35,6 +36,7 @@ class PollSubmit extends ConsumerStatefulWidget { | |||||||
|  |  | ||||||
|   /// Optional initial answers, keyed by questionId. |   /// Optional initial answers, keyed by questionId. | ||||||
|   final Map<String, dynamic>? initialAnswers; |   final Map<String, dynamic>? initialAnswers; | ||||||
|  |   final Map<String, dynamic>? stats; | ||||||
|  |  | ||||||
|   /// Optional cancel callback. |   /// Optional cancel callback. | ||||||
|   final VoidCallback? onCancel; |   final VoidCallback? onCancel; | ||||||
| @@ -321,6 +323,154 @@ class _PollSubmitState extends ConsumerState<PollSubmit> { | |||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   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) { |   Widget _buildBody(BuildContext context) { | ||||||
|     final q = _current; |     final q = _current; | ||||||
|     switch (q.type) { |     switch (q.type) { | ||||||
| @@ -467,7 +617,13 @@ class _PollSubmitState extends ConsumerState<PollSubmit> { | |||||||
|       children: [ |       children: [ | ||||||
|         _buildHeader(context), |         _buildHeader(context), | ||||||
|         const SizedBox(height: 12), |         const SizedBox(height: 12), | ||||||
|         _AnimatedStep(key: ValueKey(_current.id), child: _buildBody(context)), |         _AnimatedStep( | ||||||
|  |           key: ValueKey(_current.id), | ||||||
|  |           child: Column( | ||||||
|  |             crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |             children: [_buildBody(context), _buildStats(context, _current)], | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|         const SizedBox(height: 16), |         const SizedBox(height: 16), | ||||||
|         _buildNavBar(context), |         _buildNavBar(context), | ||||||
|       ], |       ], | ||||||
| @@ -475,6 +631,77 @@ class _PollSubmitState extends ConsumerState<PollSubmit> { | |||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | 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. | /// Simple fade/slide transition between questions. | ||||||
| class _AnimatedStep extends StatelessWidget { | class _AnimatedStep extends StatelessWidget { | ||||||
|   const _AnimatedStep({super.key, required this.child}); |   const _AnimatedStep({super.key, required this.child}); | ||||||
|   | |||||||
| @@ -566,11 +566,12 @@ class PostItem extends HookConsumerWidget { | |||||||
|                 ), |                 ), | ||||||
|                 child: PollSubmit( |                 child: PollSubmit( | ||||||
|                   initialAnswers: embedData['poll']?['user_answer']?['answer'], |                   initialAnswers: embedData['poll']?['user_answer']?['answer'], | ||||||
|  |                   stats: embedData['poll']?['stats'], | ||||||
|                   poll: SnPollWithStats.fromJson(embedData['poll']), |                   poll: SnPollWithStats.fromJson(embedData['poll']), | ||||||
|                   onSubmit: (_) {}, |                   onSubmit: (_) {}, | ||||||
|                 ).padding(horizontal: 12, vertical: 8), |                 ).padding(horizontal: 16, vertical: 12), | ||||||
|               ), |               ), | ||||||
|               _ => const Placeholder(), |               _ => Text('Unable show embed: ${embedData['type']}'), | ||||||
|             }, |             }, | ||||||
|           )), |           )), | ||||||
|         if (isShowReference) |         if (isShowReference) | ||||||
|   | |||||||
| @@ -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