Compare commits
	
		
			3 Commits
		
	
	
		
			a706f127b6
			...
			a6d869ebf6
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| a6d869ebf6 | |||
| f3a8699389 | |||
| d345c00e84 | 
| @@ -753,5 +753,13 @@ | |||||||
|   "sensitiveCategories.gambling": "Gambling", |   "sensitiveCategories.gambling": "Gambling", | ||||||
|   "sensitiveCategories.selfHarm": "Self-harm", |   "sensitiveCategories.selfHarm": "Self-harm", | ||||||
|   "sensitiveCategories.childAbuse": "Child Abuse", |   "sensitiveCategories.childAbuse": "Child Abuse", | ||||||
|   "sensitiveCategories.other": "Other" |   "sensitiveCategories.other": "Other", | ||||||
|  |   "poll": "Poll", | ||||||
|  |   "pollsRecent": "Recent Polls", | ||||||
|  |   "pollCreateNew": "Create New", | ||||||
|  |   "pollCreateNewHint": "Create a new poll for your post. Pick a publisher and continue.", | ||||||
|  |   "publisher": "Publisher", | ||||||
|  |   "publisherHint": "Enter the publisher name", | ||||||
|  |   "publisherCannotBeEmpty": "Publisher cannot be empty", | ||||||
|  |   "operationFailed": "Operation failed: {}" | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										92
									
								
								lib/models/poll.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								lib/models/poll.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,92 @@ | |||||||
|  | import 'package:freezed_annotation/freezed_annotation.dart'; | ||||||
|  | import 'package:island/models/publisher.dart'; | ||||||
|  |  | ||||||
|  | part 'poll.freezed.dart'; | ||||||
|  | part 'poll.g.dart'; | ||||||
|  |  | ||||||
|  | @freezed | ||||||
|  | sealed class SnPollWithStats with _$SnPollWithStats { | ||||||
|  |   const factory SnPollWithStats({ | ||||||
|  |     required Map<String, dynamic>? userAnswer, | ||||||
|  |     required Map<String, dynamic> stats, | ||||||
|  |     required String id, | ||||||
|  |     required List<SnPollQuestion> questions, | ||||||
|  |     String? title, | ||||||
|  |     String? description, | ||||||
|  |     DateTime? endedAt, | ||||||
|  |     required String publisherId, | ||||||
|  |     required DateTime createdAt, | ||||||
|  |     required DateTime updatedAt, | ||||||
|  |     DateTime? deletedAt, | ||||||
|  |   }) = _SnPollWithStats; | ||||||
|  |  | ||||||
|  |   factory SnPollWithStats.fromJson(Map<String, dynamic> json) => | ||||||
|  |       _$SnPollWithStatsFromJson(json); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @freezed | ||||||
|  | sealed class SnPoll with _$SnPoll { | ||||||
|  |   const factory SnPoll({ | ||||||
|  |     required String id, | ||||||
|  |     required List<SnPollQuestion> questions, | ||||||
|  |  | ||||||
|  |     String? title, | ||||||
|  |     String? description, | ||||||
|  |  | ||||||
|  |     DateTime? endedAt, | ||||||
|  |  | ||||||
|  |     required String publisherId, | ||||||
|  |     SnPublisher? publisher, | ||||||
|  |  | ||||||
|  |     // ModelBase fields | ||||||
|  |     required DateTime createdAt, | ||||||
|  |     required DateTime updatedAt, | ||||||
|  |     DateTime? deletedAt, | ||||||
|  |   }) = _SnPoll; | ||||||
|  |  | ||||||
|  |   factory SnPoll.fromJson(Map<String, dynamic> json) => _$SnPollFromJson(json); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @freezed | ||||||
|  | sealed class SnPollQuestion with _$SnPollQuestion { | ||||||
|  |   const factory SnPollQuestion({ | ||||||
|  |     required String id, | ||||||
|  |  | ||||||
|  |     required SnPollQuestionType type, | ||||||
|  |     List<SnPollOption>? options, | ||||||
|  |  | ||||||
|  |     required String title, | ||||||
|  |     String? description, | ||||||
|  |     required int order, | ||||||
|  |     required bool isRequired, | ||||||
|  |   }) = _SnPollQuestion; | ||||||
|  |  | ||||||
|  |   factory SnPollQuestion.fromJson(Map<String, dynamic> json) => | ||||||
|  |       _$SnPollQuestionFromJson(json); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @freezed | ||||||
|  | sealed class SnPollOption with _$SnPollOption { | ||||||
|  |   const factory SnPollOption({ | ||||||
|  |     required String id, | ||||||
|  |     required String label, | ||||||
|  |     String? description, | ||||||
|  |     required int order, | ||||||
|  |   }) = _SnPollOption; | ||||||
|  |  | ||||||
|  |   factory SnPollOption.fromJson(Map<String, dynamic> json) => | ||||||
|  |       _$SnPollOptionFromJson(json); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | enum SnPollQuestionType { | ||||||
|  |   @JsonValue(0) | ||||||
|  |   singleChoice, | ||||||
|  |   @JsonValue(1) | ||||||
|  |   multipleChoice, | ||||||
|  |   @JsonValue(2) | ||||||
|  |   yesNo, | ||||||
|  |   @JsonValue(3) | ||||||
|  |   rating, | ||||||
|  |   @JsonValue(4) | ||||||
|  |   freeText, | ||||||
|  | } | ||||||
							
								
								
									
										1186
									
								
								lib/models/poll.freezed.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1186
									
								
								lib/models/poll.freezed.dart
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										133
									
								
								lib/models/poll.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								lib/models/poll.g.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,133 @@ | |||||||
|  | // GENERATED CODE - DO NOT MODIFY BY HAND | ||||||
|  |  | ||||||
|  | part of 'poll.dart'; | ||||||
|  |  | ||||||
|  | // ************************************************************************** | ||||||
|  | // JsonSerializableGenerator | ||||||
|  | // ************************************************************************** | ||||||
|  |  | ||||||
|  | _SnPollWithStats _$SnPollWithStatsFromJson(Map<String, dynamic> json) => | ||||||
|  |     _SnPollWithStats( | ||||||
|  |       userAnswer: json['user_answer'] as Map<String, dynamic>?, | ||||||
|  |       stats: json['stats'] as Map<String, dynamic>, | ||||||
|  |       id: json['id'] as String, | ||||||
|  |       questions: | ||||||
|  |           (json['questions'] as List<dynamic>) | ||||||
|  |               .map((e) => SnPollQuestion.fromJson(e as Map<String, dynamic>)) | ||||||
|  |               .toList(), | ||||||
|  |       title: json['title'] as String?, | ||||||
|  |       description: json['description'] as String?, | ||||||
|  |       endedAt: | ||||||
|  |           json['ended_at'] == null | ||||||
|  |               ? null | ||||||
|  |               : DateTime.parse(json['ended_at'] as String), | ||||||
|  |       publisherId: json['publisher_id'] as String, | ||||||
|  |       createdAt: DateTime.parse(json['created_at'] as String), | ||||||
|  |       updatedAt: DateTime.parse(json['updated_at'] as String), | ||||||
|  |       deletedAt: | ||||||
|  |           json['deleted_at'] == null | ||||||
|  |               ? null | ||||||
|  |               : DateTime.parse(json['deleted_at'] as String), | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  | Map<String, dynamic> _$SnPollWithStatsToJson(_SnPollWithStats instance) => | ||||||
|  |     <String, dynamic>{ | ||||||
|  |       'user_answer': instance.userAnswer, | ||||||
|  |       'stats': instance.stats, | ||||||
|  |       'id': instance.id, | ||||||
|  |       'questions': instance.questions.map((e) => e.toJson()).toList(), | ||||||
|  |       'title': instance.title, | ||||||
|  |       'description': instance.description, | ||||||
|  |       'ended_at': instance.endedAt?.toIso8601String(), | ||||||
|  |       'publisher_id': instance.publisherId, | ||||||
|  |       'created_at': instance.createdAt.toIso8601String(), | ||||||
|  |       'updated_at': instance.updatedAt.toIso8601String(), | ||||||
|  |       'deleted_at': instance.deletedAt?.toIso8601String(), | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  | _SnPoll _$SnPollFromJson(Map<String, dynamic> json) => _SnPoll( | ||||||
|  |   id: json['id'] as String, | ||||||
|  |   questions: | ||||||
|  |       (json['questions'] as List<dynamic>) | ||||||
|  |           .map((e) => SnPollQuestion.fromJson(e as Map<String, dynamic>)) | ||||||
|  |           .toList(), | ||||||
|  |   title: json['title'] as String?, | ||||||
|  |   description: json['description'] as String?, | ||||||
|  |   endedAt: | ||||||
|  |       json['ended_at'] == null | ||||||
|  |           ? null | ||||||
|  |           : DateTime.parse(json['ended_at'] as String), | ||||||
|  |   publisherId: json['publisher_id'] as String, | ||||||
|  |   publisher: | ||||||
|  |       json['publisher'] == null | ||||||
|  |           ? null | ||||||
|  |           : SnPublisher.fromJson(json['publisher'] as Map<String, dynamic>), | ||||||
|  |   createdAt: DateTime.parse(json['created_at'] as String), | ||||||
|  |   updatedAt: DateTime.parse(json['updated_at'] as String), | ||||||
|  |   deletedAt: | ||||||
|  |       json['deleted_at'] == null | ||||||
|  |           ? null | ||||||
|  |           : DateTime.parse(json['deleted_at'] as String), | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | Map<String, dynamic> _$SnPollToJson(_SnPoll instance) => <String, dynamic>{ | ||||||
|  |   'id': instance.id, | ||||||
|  |   'questions': instance.questions.map((e) => e.toJson()).toList(), | ||||||
|  |   'title': instance.title, | ||||||
|  |   'description': instance.description, | ||||||
|  |   'ended_at': instance.endedAt?.toIso8601String(), | ||||||
|  |   'publisher_id': instance.publisherId, | ||||||
|  |   'publisher': instance.publisher?.toJson(), | ||||||
|  |   'created_at': instance.createdAt.toIso8601String(), | ||||||
|  |   'updated_at': instance.updatedAt.toIso8601String(), | ||||||
|  |   'deleted_at': instance.deletedAt?.toIso8601String(), | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | _SnPollQuestion _$SnPollQuestionFromJson(Map<String, dynamic> json) => | ||||||
|  |     _SnPollQuestion( | ||||||
|  |       id: json['id'] as String, | ||||||
|  |       type: $enumDecode(_$SnPollQuestionTypeEnumMap, json['type']), | ||||||
|  |       options: | ||||||
|  |           (json['options'] as List<dynamic>?) | ||||||
|  |               ?.map((e) => SnPollOption.fromJson(e as Map<String, dynamic>)) | ||||||
|  |               .toList(), | ||||||
|  |       title: json['title'] as String, | ||||||
|  |       description: json['description'] as String?, | ||||||
|  |       order: (json['order'] as num).toInt(), | ||||||
|  |       isRequired: json['is_required'] as bool, | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  | Map<String, dynamic> _$SnPollQuestionToJson(_SnPollQuestion instance) => | ||||||
|  |     <String, dynamic>{ | ||||||
|  |       'id': instance.id, | ||||||
|  |       'type': _$SnPollQuestionTypeEnumMap[instance.type]!, | ||||||
|  |       'options': instance.options?.map((e) => e.toJson()).toList(), | ||||||
|  |       'title': instance.title, | ||||||
|  |       'description': instance.description, | ||||||
|  |       'order': instance.order, | ||||||
|  |       'is_required': instance.isRequired, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  | const _$SnPollQuestionTypeEnumMap = { | ||||||
|  |   SnPollQuestionType.singleChoice: 0, | ||||||
|  |   SnPollQuestionType.multipleChoice: 1, | ||||||
|  |   SnPollQuestionType.yesNo: 2, | ||||||
|  |   SnPollQuestionType.rating: 3, | ||||||
|  |   SnPollQuestionType.freeText: 4, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | _SnPollOption _$SnPollOptionFromJson(Map<String, dynamic> json) => | ||||||
|  |     _SnPollOption( | ||||||
|  |       id: json['id'] as String, | ||||||
|  |       label: json['label'] as String, | ||||||
|  |       description: json['description'] as String?, | ||||||
|  |       order: (json['order'] as num).toInt(), | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  | Map<String, dynamic> _$SnPollOptionToJson(_SnPollOption instance) => | ||||||
|  |     <String, dynamic>{ | ||||||
|  |       'id': instance.id, | ||||||
|  |       'label': instance.label, | ||||||
|  |       'description': instance.description, | ||||||
|  |       'order': instance.order, | ||||||
|  |     }; | ||||||
| @@ -28,9 +28,11 @@ import 'package:island/screens/creators/hub.dart'; | |||||||
| import 'package:island/screens/creators/posts/post_manage_list.dart'; | import 'package:island/screens/creators/posts/post_manage_list.dart'; | ||||||
| import 'package:island/screens/creators/stickers/stickers.dart'; | import 'package:island/screens/creators/stickers/stickers.dart'; | ||||||
| import 'package:island/screens/creators/stickers/pack_detail.dart'; | import 'package:island/screens/creators/stickers/pack_detail.dart'; | ||||||
|  | import 'package:island/screens/creators/poll/poll_list.dart'; | ||||||
| import 'package:island/screens/creators/publishers.dart'; | import 'package:island/screens/creators/publishers.dart'; | ||||||
| import 'package:island/screens/creators/webfeed/webfeed_list.dart'; | import 'package:island/screens/creators/webfeed/webfeed_list.dart'; | ||||||
| import 'package:island/screens/creators/webfeed/webfeed_edit.dart'; | import 'package:island/screens/creators/webfeed/webfeed_edit.dart'; | ||||||
|  | import 'package:island/screens/poll/poll_editor.dart'; | ||||||
| import 'package:island/screens/posts/compose.dart'; | import 'package:island/screens/posts/compose.dart'; | ||||||
| import 'package:island/screens/posts/post_detail.dart'; | import 'package:island/screens/posts/post_detail.dart'; | ||||||
| import 'package:island/screens/posts/pub_profile.dart'; | import 'package:island/screens/posts/pub_profile.dart'; | ||||||
| @@ -144,6 +146,37 @@ final routerProvider = Provider<GoRouter>((ref) { | |||||||
|                   return CreatorPostListScreen(pubName: name); |                   return CreatorPostListScreen(pubName: name); | ||||||
|                 }, |                 }, | ||||||
|               ), |               ), | ||||||
|  |               // Poll list route | ||||||
|  |               GoRoute( | ||||||
|  |                 name: 'creatorPolls', | ||||||
|  |                 path: '/creators/:name/polls', | ||||||
|  |                 builder: (context, state) { | ||||||
|  |                   final name = state.pathParameters['name']!; | ||||||
|  |                   return CreatorPollListScreen(pubName: name); | ||||||
|  |                 }, | ||||||
|  |               ), | ||||||
|  |               // Poll routes | ||||||
|  |               GoRoute( | ||||||
|  |                 name: 'creatorPollNew', | ||||||
|  |                 path: '/creators/:name/polls/new', | ||||||
|  |                 builder: (context, state) { | ||||||
|  |                   final name = state.pathParameters['name']!; | ||||||
|  |                   // initialPollId left null for create; initialPublisher prefilled | ||||||
|  |                   return PollEditorScreen(initialPublisher: name); | ||||||
|  |                 }, | ||||||
|  |               ), | ||||||
|  |               GoRoute( | ||||||
|  |                 name: 'creatorPollEdit', | ||||||
|  |                 path: '/creators/:name/polls/:id/edit', | ||||||
|  |                 builder: (context, state) { | ||||||
|  |                   final name = state.pathParameters['name']!; | ||||||
|  |                   final id = state.pathParameters['id']!; | ||||||
|  |                   return PollEditorScreen( | ||||||
|  |                     initialPollId: id, | ||||||
|  |                     initialPublisher: name, | ||||||
|  |                   ); | ||||||
|  |                 }, | ||||||
|  |               ), | ||||||
|               GoRoute( |               GoRoute( | ||||||
|                 name: 'creatorStickers', |                 name: 'creatorStickers', | ||||||
|                 path: '/creators/:name/stickers', |                 path: '/creators/:name/stickers', | ||||||
|   | |||||||
| @@ -380,6 +380,23 @@ class CreatorHubScreen extends HookConsumerWidget { | |||||||
|                               ); |                               ); | ||||||
|                             }, |                             }, | ||||||
|                           ), |                           ), | ||||||
|  |                           ListTile( | ||||||
|  |                             minTileHeight: 48, | ||||||
|  |                             title: const Text('Polls'), | ||||||
|  |                             trailing: const Icon(Symbols.chevron_right), | ||||||
|  |                             leading: const Icon(Symbols.poll), | ||||||
|  |                             contentPadding: const EdgeInsets.symmetric( | ||||||
|  |                               horizontal: 24, | ||||||
|  |                             ), | ||||||
|  |                             onTap: () { | ||||||
|  |                               context.pushNamed( | ||||||
|  |                                 'creatorPolls', | ||||||
|  |                                 pathParameters: { | ||||||
|  |                                   'name': currentPublisher.value!.name, | ||||||
|  |                                 }, | ||||||
|  |                               ); | ||||||
|  |                             }, | ||||||
|  |                           ), | ||||||
|                           ListTile( |                           ListTile( | ||||||
|                             minTileHeight: 48, |                             minTileHeight: 48, | ||||||
|                             title: Text('publisherMembers').tr(), |                             title: Text('publisherMembers').tr(), | ||||||
|   | |||||||
							
								
								
									
										175
									
								
								lib/screens/creators/poll/poll_list.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										175
									
								
								lib/screens/creators/poll/poll_list.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,175 @@ | |||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:go_router/go_router.dart'; | ||||||
|  | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
|  | import 'package:island/models/poll.dart'; | ||||||
|  | import 'package:island/pods/network.dart'; | ||||||
|  | import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||||
|  | import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | ||||||
|  |  | ||||||
|  | part 'poll_list.g.dart'; | ||||||
|  |  | ||||||
|  | @riverpod | ||||||
|  | class PollListNotifier extends _$PollListNotifier | ||||||
|  |     with CursorPagingNotifierMixin<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 { | ||||||
|  |     // Use named route defined in router with :name param (creatorPollNew) | ||||||
|  |     final result = await GoRouter.of( | ||||||
|  |       context, | ||||||
|  |     ).pushNamed('creatorPollNew', pathParameters: {'name': pubName}); | ||||||
|  |     // If PollEditorScreen returns a created SnPoll on success, pop back with it | ||||||
|  |     if (result is SnPoll && context.mounted) { | ||||||
|  |       Navigator.of(context).maybePop(result); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|  |     return Scaffold( | ||||||
|  |       appBar: AppBar(title: const Text('Polls')), | ||||||
|  |       floatingActionButton: FloatingActionButton( | ||||||
|  |         onPressed: () => _createPoll(context), | ||||||
|  |         child: const Icon(Icons.add), | ||||||
|  |       ), | ||||||
|  |       body: RefreshIndicator( | ||||||
|  |         onRefresh: () => ref.refresh(pollListNotifierProvider(pubName).future), | ||||||
|  |         child: CustomScrollView( | ||||||
|  |           slivers: [ | ||||||
|  |             PagingHelperSliverView( | ||||||
|  |               provider: pollListNotifierProvider(pubName), | ||||||
|  |               futureRefreshable: pollListNotifierProvider(pubName).future, | ||||||
|  |               notifierRefreshable: pollListNotifierProvider(pubName).notifier, | ||||||
|  |               contentBuilder: | ||||||
|  |                   (data, widgetCount, endItemView) => SliverList.builder( | ||||||
|  |                     itemCount: widgetCount, | ||||||
|  |                     itemBuilder: (context, index) { | ||||||
|  |                       if (index == widgetCount - 1) { | ||||||
|  |                         return endItemView; | ||||||
|  |                       } | ||||||
|  |                       final poll = data.items[index]; | ||||||
|  |                       return _CreatorPollItem(poll: poll); | ||||||
|  |                     }, | ||||||
|  |                   ), | ||||||
|  |             ), | ||||||
|  |           ], | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _CreatorPollItem extends StatelessWidget { | ||||||
|  |   const _CreatorPollItem({required this.poll}); | ||||||
|  |  | ||||||
|  |   final SnPoll poll; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     final theme = Theme.of(context); | ||||||
|  |     final ended = poll.endedAt; | ||||||
|  |     final endedText = | ||||||
|  |         ended == null | ||||||
|  |             ? 'No end' | ||||||
|  |             : MaterialLocalizations.of(context).formatFullDate(ended); | ||||||
|  |  | ||||||
|  |     return Card( | ||||||
|  |       margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), | ||||||
|  |       clipBehavior: Clip.antiAlias, | ||||||
|  |       child: ListTile( | ||||||
|  |         title: Text(poll.title ?? 'Untitled poll'), | ||||||
|  |         subtitle: Column( | ||||||
|  |           crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |           children: [ | ||||||
|  |             if (poll.description != null && poll.description!.isNotEmpty) | ||||||
|  |               Padding( | ||||||
|  |                 padding: const EdgeInsets.only(top: 4), | ||||||
|  |                 child: Text( | ||||||
|  |                   poll.description!, | ||||||
|  |                   maxLines: 2, | ||||||
|  |                   overflow: TextOverflow.ellipsis, | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |             Padding( | ||||||
|  |               padding: const EdgeInsets.only(top: 4), | ||||||
|  |               child: Text( | ||||||
|  |                 'Questions: ${poll.questions.length} · Ends: $endedText', | ||||||
|  |                 style: theme.textTheme.bodySmall, | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |           ], | ||||||
|  |         ), | ||||||
|  |         trailing: PopupMenuButton<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: | ||||||
|  |               (context) => [ | ||||||
|  |                 const PopupMenuItem(value: 'edit', child: Text('Edit')), | ||||||
|  |               ], | ||||||
|  |         ), | ||||||
|  |         onTap: () { | ||||||
|  |           // Open editor for edit | ||||||
|  |           // Navigator push by path to keep consistency with rest of app: | ||||||
|  |           // Note: pub name string may be required in route; when absent, route may need query or pick later. | ||||||
|  |           // For safety, just do nothing if no publisher in list item. | ||||||
|  |         }, | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										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 | ||||||
							
								
								
									
										1100
									
								
								lib/screens/poll/poll_editor.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1100
									
								
								lib/screens/poll/poll_editor.dart
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										501
									
								
								lib/widgets/poll/poll_submit.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										501
									
								
								lib/widgets/poll/poll_submit.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,501 @@ | |||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter_riverpod/flutter_riverpod.dart'; | ||||||
|  | import 'package:island/models/poll.dart'; | ||||||
|  | import 'package:island/pods/network.dart'; | ||||||
|  |  | ||||||
|  | /// A poll answering widget that shows one question at a time and collects answers. | ||||||
|  | /// | ||||||
|  | /// Usage: | ||||||
|  | /// PollSubmit( | ||||||
|  | ///   poll: poll, | ||||||
|  | ///   onSubmit: (answers) { | ||||||
|  | ///     // answers is Map<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, | ||||||
|  |     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; | ||||||
|  |  | ||||||
|  |   /// 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 _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: _buildBody(context)), | ||||||
|  |         const SizedBox(height: 16), | ||||||
|  |         _buildNavBar(context), | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// Simple fade/slide transition between questions. | ||||||
|  | class _AnimatedStep extends StatelessWidget { | ||||||
|  |   const _AnimatedStep({super.key, required this.child}); | ||||||
|  |   final Widget child; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AnimatedSwitcher( | ||||||
|  |       duration: const Duration(milliseconds: 250), | ||||||
|  |       transitionBuilder: (child, anim) { | ||||||
|  |         final offset = Tween<Offset>( | ||||||
|  |           begin: const Offset(0.1, 0), | ||||||
|  |           end: Offset.zero, | ||||||
|  |         ).animate(anim); | ||||||
|  |         final fade = CurvedAnimation(parent: anim, curve: Curves.easeInOut); | ||||||
|  |         return FadeTransition( | ||||||
|  |           opacity: fade, | ||||||
|  |           child: SlideTransition(position: offset, child: child), | ||||||
|  |         ); | ||||||
|  |       }, | ||||||
|  |       child: child, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										201
									
								
								lib/widgets/post/compose_poll.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										201
									
								
								lib/widgets/post/compose_poll.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,201 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter_hooks/flutter_hooks.dart'; | ||||||
|  | import 'package:gap/gap.dart'; | ||||||
|  | import 'package:go_router/go_router.dart'; | ||||||
|  | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
|  | import 'package:island/models/poll.dart'; | ||||||
|  | import 'package:island/models/publisher.dart'; | ||||||
|  | import 'package:island/screens/creators/poll/poll_list.dart'; | ||||||
|  | import 'package:island/widgets/content/sheet.dart'; | ||||||
|  | import 'package:material_symbols_icons/symbols.dart'; | ||||||
|  | import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | ||||||
|  | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  | import 'package:island/widgets/post/publishers_modal.dart'; | ||||||
|  |  | ||||||
|  | /// Bottom sheet for selecting or creating a poll. Returns SnPoll via Navigator.pop. | ||||||
|  | class ComposePollSheet extends HookConsumerWidget { | ||||||
|  |   /// Optional publisher name to filter polls and prefill creation. | ||||||
|  |   final String? pubName; | ||||||
|  |  | ||||||
|  |   const ComposePollSheet({super.key, this.pubName}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|  |     final selectedPublisher = useState<String?>(pubName); | ||||||
|  |     final isPushing = useState(false); | ||||||
|  |     final errorText = useState<String?>(null); | ||||||
|  |  | ||||||
|  |     return SheetScaffold( | ||||||
|  |       heightFactor: 0.6, | ||||||
|  |       titleText: 'poll'.tr(), | ||||||
|  |       child: DefaultTabController( | ||||||
|  |         length: 2, | ||||||
|  |         child: Column( | ||||||
|  |           mainAxisSize: MainAxisSize.min, | ||||||
|  |           crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |           children: [ | ||||||
|  |             TabBar( | ||||||
|  |               tabs: [ | ||||||
|  |                 Tab(text: 'pollsRecent'.tr()), | ||||||
|  |                 Tab(text: 'pollCreateNew'.tr()), | ||||||
|  |               ], | ||||||
|  |             ), | ||||||
|  |             Expanded( | ||||||
|  |               child: TabBarView( | ||||||
|  |                 children: [ | ||||||
|  |                   // Link/Select existing poll list | ||||||
|  |                   PagingHelperView( | ||||||
|  |                     provider: pollListNotifierProvider(pubName), | ||||||
|  |                     futureRefreshable: pollListNotifierProvider(pubName).future, | ||||||
|  |                     notifierRefreshable: | ||||||
|  |                         pollListNotifierProvider(pubName).notifier, | ||||||
|  |                     contentBuilder: | ||||||
|  |                         (data, widgetCount, endItemView) => ListView.builder( | ||||||
|  |                           padding: EdgeInsets.zero, | ||||||
|  |                           itemCount: widgetCount, | ||||||
|  |                           itemBuilder: (context, index) { | ||||||
|  |                             if (index == widgetCount - 1) { | ||||||
|  |                               return endItemView; | ||||||
|  |                             } | ||||||
|  |  | ||||||
|  |                             final poll = data.items[index]; | ||||||
|  |  | ||||||
|  |                             return ListTile( | ||||||
|  |                               leading: const Icon(Symbols.how_to_vote, fill: 1), | ||||||
|  |                               title: Text(poll.title ?? 'untitled'.tr()), | ||||||
|  |                               subtitle: _buildPollSubtitle(poll), | ||||||
|  |                               onTap: () { | ||||||
|  |                                 Navigator.of(context).pop(poll); | ||||||
|  |                               }, | ||||||
|  |                             ); | ||||||
|  |                           }, | ||||||
|  |                         ), | ||||||
|  |                   ), | ||||||
|  |  | ||||||
|  |                   // Create new poll and return it | ||||||
|  |                   SingleChildScrollView( | ||||||
|  |                     child: Column( | ||||||
|  |                       crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                       children: [ | ||||||
|  |                         Text( | ||||||
|  |                           'pollCreateNewHint', | ||||||
|  |                         ).tr().fontSize(13).opacity(0.85).padding(bottom: 8), | ||||||
|  |                         ListTile( | ||||||
|  |                           title: Text( | ||||||
|  |                             selectedPublisher.value == null | ||||||
|  |                                 ? 'publisher'.tr() | ||||||
|  |                                 : '@${selectedPublisher.value}', | ||||||
|  |                           ), | ||||||
|  |                           subtitle: Text( | ||||||
|  |                             selectedPublisher.value == null | ||||||
|  |                                 ? 'publisherHint'.tr() | ||||||
|  |                                 : 'selected'.tr(), | ||||||
|  |                           ), | ||||||
|  |                           leading: const Icon(Symbols.account_circle), | ||||||
|  |                           trailing: const Icon(Symbols.chevron_right), | ||||||
|  |                           onTap: () async { | ||||||
|  |                             final picked = | ||||||
|  |                                 await showModalBottomSheet<SnPublisher>( | ||||||
|  |                                   context: context, | ||||||
|  |                                   isScrollControlled: true, | ||||||
|  |                                   builder: (context) => const PublisherModal(), | ||||||
|  |                                 ); | ||||||
|  |                             if (picked != null) { | ||||||
|  |                               try { | ||||||
|  |                                 final name = picked.name; | ||||||
|  |                                 if (name.isNotEmpty) { | ||||||
|  |                                   selectedPublisher.value = name; | ||||||
|  |                                   errorText.value = null; | ||||||
|  |                                 } | ||||||
|  |                               } catch (_) { | ||||||
|  |                                 // ignore | ||||||
|  |                               } | ||||||
|  |                             } | ||||||
|  |                           }, | ||||||
|  |                         ), | ||||||
|  |                         if (errorText.value != null) | ||||||
|  |                           Padding( | ||||||
|  |                             padding: const EdgeInsets.only( | ||||||
|  |                               left: 16, | ||||||
|  |                               right: 16, | ||||||
|  |                               top: 4, | ||||||
|  |                             ), | ||||||
|  |                             child: Text( | ||||||
|  |                               errorText.value!, | ||||||
|  |                               style: TextStyle(color: Colors.red[700]), | ||||||
|  |                             ), | ||||||
|  |                           ), | ||||||
|  |                         const Gap(16), | ||||||
|  |                         Align( | ||||||
|  |                           alignment: Alignment.centerRight, | ||||||
|  |                           child: FilledButton.icon( | ||||||
|  |                             icon: | ||||||
|  |                                 isPushing.value | ||||||
|  |                                     ? const SizedBox( | ||||||
|  |                                       width: 18, | ||||||
|  |                                       height: 18, | ||||||
|  |                                       child: CircularProgressIndicator( | ||||||
|  |                                         strokeWidth: 2, | ||||||
|  |                                         color: Colors.white, | ||||||
|  |                                       ), | ||||||
|  |                                     ) | ||||||
|  |                                     : const Icon(Symbols.add_circle), | ||||||
|  |                             label: Text('create'.tr()), | ||||||
|  |                             onPressed: | ||||||
|  |                                 isPushing.value | ||||||
|  |                                     ? null | ||||||
|  |                                     : () async { | ||||||
|  |                                       final pub = selectedPublisher.value ?? ''; | ||||||
|  |                                       if (pub.isEmpty) { | ||||||
|  |                                         errorText.value = | ||||||
|  |                                             'publisherCannotBeEmpty'.tr(); | ||||||
|  |                                         return; | ||||||
|  |                                       } | ||||||
|  |                                       errorText.value = null; | ||||||
|  |  | ||||||
|  |                                       isPushing.value = true; | ||||||
|  |                                       // Push to creatorPollNew route and await result | ||||||
|  |                                       final result = await GoRouter.of( | ||||||
|  |                                         context, | ||||||
|  |                                       ).push<SnPoll>( | ||||||
|  |                                         '/creators/$pub/polls/new', | ||||||
|  |                                       ); | ||||||
|  |  | ||||||
|  |                                       if (result == null) { | ||||||
|  |                                         isPushing.value = false; | ||||||
|  |                                         return; | ||||||
|  |                                       } | ||||||
|  |  | ||||||
|  |                                       if (!context.mounted) return; | ||||||
|  |  | ||||||
|  |                                       // Return created poll to caller of this bottom sheet | ||||||
|  |                                       Navigator.of(context).pop(result); | ||||||
|  |                                     }, | ||||||
|  |                           ), | ||||||
|  |                         ), | ||||||
|  |                       ], | ||||||
|  |                     ).padding(horizontal: 24, vertical: 24), | ||||||
|  |                   ), | ||||||
|  |                 ], | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |           ], | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Widget? _buildPollSubtitle(SnPoll poll) { | ||||||
|  |     try { | ||||||
|  |       final SnPoll dyn = poll; | ||||||
|  |       final List<SnPollQuestion>? options = dyn.questions; | ||||||
|  |       if (options == null || options.isEmpty) return null; | ||||||
|  |       final preview = options.take(3).map((e) => e.title).join(' · '); | ||||||
|  |       if (preview.trim().isEmpty) return null; | ||||||
|  |       return Text(preview); | ||||||
|  |     } catch (_) { | ||||||
|  |       return null; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -14,6 +14,7 @@ import 'package:island/services/file.dart'; | |||||||
| import 'package:island/services/compose_storage_db.dart'; | import 'package:island/services/compose_storage_db.dart'; | ||||||
| import 'package:island/widgets/alert.dart'; | import 'package:island/widgets/alert.dart'; | ||||||
| import 'package:island/widgets/post/compose_link_attachments.dart'; | import 'package:island/widgets/post/compose_link_attachments.dart'; | ||||||
|  | import 'package:island/widgets/post/compose_poll.dart'; | ||||||
| import 'package:island/widgets/post/compose_recorder.dart'; | import 'package:island/widgets/post/compose_recorder.dart'; | ||||||
| import 'package:pasteboard/pasteboard.dart'; | import 'package:pasteboard/pasteboard.dart'; | ||||||
| import 'package:textfield_tags/textfield_tags.dart'; | import 'package:textfield_tags/textfield_tags.dart'; | ||||||
| @@ -33,6 +34,8 @@ class ComposeState { | |||||||
|   StringTagController categoriesController; |   StringTagController categoriesController; | ||||||
|   final String draftId; |   final String draftId; | ||||||
|   int postType; |   int postType; | ||||||
|  |   // Linked poll id for this compose session (nullable) | ||||||
|  |   final ValueNotifier<String?> pollId; | ||||||
|   Timer? _autoSaveTimer; |   Timer? _autoSaveTimer; | ||||||
|  |  | ||||||
|   ComposeState({ |   ComposeState({ | ||||||
| @@ -48,7 +51,8 @@ class ComposeState { | |||||||
|     required this.categoriesController, |     required this.categoriesController, | ||||||
|     required this.draftId, |     required this.draftId, | ||||||
|     this.postType = 0, |     this.postType = 0, | ||||||
|   }); |     String? pollId, | ||||||
|  |   }) : pollId = ValueNotifier<String?>(pollId); | ||||||
|  |  | ||||||
|   void startAutoSave(WidgetRef ref) { |   void startAutoSave(WidgetRef ref) { | ||||||
|     _autoSaveTimer?.cancel(); |     _autoSaveTimer?.cancel(); | ||||||
| @@ -111,6 +115,8 @@ class ComposeLogic { | |||||||
|       categoriesController: categoriesController, |       categoriesController: categoriesController, | ||||||
|       draftId: id, |       draftId: id, | ||||||
|       postType: postType, |       postType: postType, | ||||||
|  |       // initialize without poll by default | ||||||
|  |       pollId: null, | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -138,6 +144,7 @@ class ComposeLogic { | |||||||
|       categoriesController: categoriesController, |       categoriesController: categoriesController, | ||||||
|       draftId: draft.id, |       draftId: draft.id, | ||||||
|       postType: postType, |       postType: postType, | ||||||
|  |       pollId: null, | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -555,6 +562,27 @@ class ComposeLogic { | |||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   static Future<void> pickPoll( | ||||||
|  |     WidgetRef ref, | ||||||
|  |     ComposeState state, | ||||||
|  |     BuildContext context, | ||||||
|  |   ) async { | ||||||
|  |     if (state.pollId.value != null) { | ||||||
|  |       state.pollId.value = null; | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     final poll = await showModalBottomSheet( | ||||||
|  |       context: context, | ||||||
|  |       useRootNavigator: true, | ||||||
|  |       isScrollControlled: true, | ||||||
|  |       builder: (context) => const ComposePollSheet(), | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     if (poll == null) return; | ||||||
|  |     state.pollId.value = poll.id; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   static Future<void> performAction( |   static Future<void> performAction( | ||||||
|     WidgetRef ref, |     WidgetRef ref, | ||||||
|     ComposeState state, |     ComposeState state, | ||||||
| @@ -613,6 +641,7 @@ class ComposeLogic { | |||||||
|         if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id, |         if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id, | ||||||
|         'tags': state.tagsController.getTags, |         'tags': state.tagsController.getTags, | ||||||
|         'categories': state.categoriesController.getTags, |         'categories': state.categoriesController.getTags, | ||||||
|  |         if (state.pollId.value != null) 'poll_id': state.pollId.value, | ||||||
|       }; |       }; | ||||||
|  |  | ||||||
|       // Send request |       // Send request | ||||||
| @@ -703,5 +732,6 @@ class ComposeLogic { | |||||||
|     state.currentPublisher.dispose(); |     state.currentPublisher.dispose(); | ||||||
|     state.tagsController.dispose(); |     state.tagsController.dispose(); | ||||||
|     state.categoriesController.dispose(); |     state.categoriesController.dispose(); | ||||||
|  |     state.pollId.dispose(); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -36,6 +36,10 @@ class ComposeToolbar extends HookConsumerWidget { | |||||||
|       ComposeLogic.saveDraft(ref, state); |       ComposeLogic.saveDraft(ref, state); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     void pickPoll() { | ||||||
|  |       ComposeLogic.pickPoll(ref, state, context); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     void showDraftManager() { |     void showDraftManager() { | ||||||
|       showModalBottomSheet( |       showModalBottomSheet( | ||||||
|         context: context, |         context: context, | ||||||
| @@ -88,6 +92,25 @@ class ComposeToolbar extends HookConsumerWidget { | |||||||
|                 tooltip: 'linkAttachment'.tr(), |                 tooltip: 'linkAttachment'.tr(), | ||||||
|                 color: colorScheme.primary, |                 color: colorScheme.primary, | ||||||
|               ), |               ), | ||||||
|  |               // Poll button with visual state when a poll is linked | ||||||
|  |               ListenableBuilder( | ||||||
|  |                 listenable: state.pollId, | ||||||
|  |                 builder: (context, _) { | ||||||
|  |                   return IconButton( | ||||||
|  |                     onPressed: pickPoll, | ||||||
|  |                     icon: const Icon(Symbols.how_to_vote), | ||||||
|  |                     tooltip: 'poll'.tr(), | ||||||
|  |                     color: colorScheme.primary, | ||||||
|  |                     style: ButtonStyle( | ||||||
|  |                       backgroundColor: WidgetStatePropertyAll( | ||||||
|  |                         state.pollId.value != null | ||||||
|  |                             ? colorScheme.primary.withOpacity(0.15) | ||||||
|  |                             : null, | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |                   ); | ||||||
|  |                 }, | ||||||
|  |               ), | ||||||
|               const Spacer(), |               const Spacer(), | ||||||
|               if (originalPost == null && state.isEmpty) |               if (originalPost == null && state.isEmpty) | ||||||
|                 IconButton( |                 IconButton( | ||||||
|   | |||||||
| @@ -8,6 +8,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; | |||||||
| import 'package:gap/gap.dart'; | import 'package:gap/gap.dart'; | ||||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
| import 'package:island/models/embed.dart'; | import 'package:island/models/embed.dart'; | ||||||
|  | import 'package:island/models/poll.dart'; | ||||||
| import 'package:island/models/post.dart'; | import 'package:island/models/post.dart'; | ||||||
| import 'package:island/pods/network.dart'; | import 'package:island/pods/network.dart'; | ||||||
| import 'package:island/pods/translate.dart'; | import 'package:island/pods/translate.dart'; | ||||||
| @@ -21,6 +22,7 @@ import 'package:island/widgets/content/cloud_file_collection.dart'; | |||||||
| import 'package:island/widgets/content/cloud_files.dart'; | import 'package:island/widgets/content/cloud_files.dart'; | ||||||
| import 'package:island/widgets/content/embed/link.dart'; | import 'package:island/widgets/content/embed/link.dart'; | ||||||
| import 'package:island/widgets/content/markdown.dart'; | import 'package:island/widgets/content/markdown.dart'; | ||||||
|  | import 'package:island/widgets/poll/poll_submit.dart'; | ||||||
| import 'package:island/widgets/post/post_replies_sheet.dart'; | import 'package:island/widgets/post/post_replies_sheet.dart'; | ||||||
| import 'package:island/widgets/safety/abuse_report_helper.dart'; | import 'package:island/widgets/safety/abuse_report_helper.dart'; | ||||||
| import 'package:island/widgets/share/share_sheet.dart'; | import 'package:island/widgets/share/share_sheet.dart'; | ||||||
| @@ -542,23 +544,35 @@ class PostItem extends HookConsumerWidget { | |||||||
|             ), |             ), | ||||||
|           ), |           ), | ||||||
|         if (item.meta?['embeds'] != null) |         if (item.meta?['embeds'] != null) | ||||||
|           ...((item.meta!['embeds'] as List<dynamic>) |           ...((item.meta!['embeds'] as List<dynamic>).map( | ||||||
|               .where((embed) => embed['Type'] == 'link') |             (embedData) => switch (embedData['type']) { | ||||||
|               .map( |               'link' => EmbedLinkWidget( | ||||||
|                 (embedData) => EmbedLinkWidget( |                 link: SnEmbedLink.fromJson(embedData as Map<String, dynamic>), | ||||||
|                   link: SnEmbedLink.fromJson(embedData as Map<String, dynamic>), |                 maxWidth: math.min( | ||||||
|                   maxWidth: math.min( |                   MediaQuery.of(context).size.width, | ||||||
|                     MediaQuery.of(context).size.width, |                   kWideScreenWidth, | ||||||
|                     kWideScreenWidth, |  | ||||||
|                   ), |  | ||||||
|                   margin: EdgeInsets.only( |  | ||||||
|                     top: 4, |  | ||||||
|                     bottom: 4, |  | ||||||
|                     left: renderingPadding.horizontal, |  | ||||||
|                     right: renderingPadding.horizontal, |  | ||||||
|                   ), |  | ||||||
|                 ), |                 ), | ||||||
|               )), |                 margin: EdgeInsets.only( | ||||||
|  |                   top: 4, | ||||||
|  |                   bottom: 4, | ||||||
|  |                   left: renderingPadding.horizontal, | ||||||
|  |                   right: renderingPadding.horizontal, | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |               'poll' => Card( | ||||||
|  |                 margin: EdgeInsets.symmetric( | ||||||
|  |                   horizontal: renderingPadding.horizontal, | ||||||
|  |                   vertical: 8, | ||||||
|  |                 ), | ||||||
|  |                 child: PollSubmit( | ||||||
|  |                   initialAnswers: embedData['poll']?['user_answer']?['answer'], | ||||||
|  |                   poll: SnPollWithStats.fromJson(embedData['poll']), | ||||||
|  |                   onSubmit: (_) {}, | ||||||
|  |                 ).padding(horizontal: 12, vertical: 8), | ||||||
|  |               ), | ||||||
|  |               _ => const Placeholder(), | ||||||
|  |             }, | ||||||
|  |           )), | ||||||
|         if (isShowReference) |         if (isShowReference) | ||||||
|           _buildReferencePost(context, item, renderingPadding), |           _buildReferencePost(context, item, renderingPadding), | ||||||
|         if (item.repliesCount > 0 && isEmbedReply) |         if (item.repliesCount > 0 && isEmbedReply) | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user