✨ Poll participate
This commit is contained in:
		| @@ -633,5 +633,10 @@ | ||||
|   "pollEditorUnlink": "Unlink Poll", | ||||
|   "pollOptionAdd": "Add Option", | ||||
|   "pollOptionName": "Option Name", | ||||
|   "pollLinkExisting": "Link existing poll" | ||||
|   "pollLinkExisting": "Link existing poll", | ||||
|   "pollAnswered": "Answered the poll.", | ||||
|   "pollVotes": { | ||||
|     "one": "{} vote", | ||||
|     "other": "{} votes" | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -632,5 +632,10 @@ | ||||
|   "pollEditorUnlink": "解除链接", | ||||
|   "pollOptionAdd": "添加选项", | ||||
|   "pollOptionName": "选项名称", | ||||
|   "pollLinkExisting": "链接现有投票" | ||||
|   "pollLinkExisting": "链接现有投票", | ||||
|   "pollAnswered": "答案已经反馈。", | ||||
|   "pollVotes": { | ||||
|     "one": "{} 票", | ||||
|     "other": "{} 票" | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -18,7 +18,6 @@ import 'package:surface/providers/config.dart'; | ||||
| import 'package:surface/providers/sn_attachment.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/types/attachment.dart'; | ||||
| import 'package:surface/types/poll.dart'; | ||||
| import 'package:surface/types/post.dart'; | ||||
| import 'package:surface/widgets/account/account_image.dart'; | ||||
| import 'package:surface/widgets/attachment/attachment_input.dart'; | ||||
| @@ -32,7 +31,7 @@ import 'package:surface/widgets/post/post_media_pending_list.dart'; | ||||
| import 'package:surface/widgets/post/post_meta_editor.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:surface/widgets/post/post_poll.dart'; | ||||
| import 'package:surface/widgets/post/post_poll_editor.dart'; | ||||
| import 'package:uuid/uuid.dart'; | ||||
|  | ||||
| class PostEditorExtra { | ||||
|   | ||||
| @@ -24,7 +24,8 @@ class SnPoll with _$SnPoll { | ||||
| class SnPollMetric with _$SnPollMetric { | ||||
|   const factory SnPollMetric({ | ||||
|     required int totalAnswer, | ||||
|     required dynamic byOptions, | ||||
|     @Default({}) Map<String, int> byOptions, | ||||
|     @Default({}) Map<String, int> byOptionsPercentage, | ||||
|   }) = _SnPollMetric; | ||||
|  | ||||
|   factory SnPollMetric.fromJson(Map<String, Object?> json) | ||||
|   | ||||
| @@ -344,7 +344,9 @@ SnPollMetric _$SnPollMetricFromJson(Map<String, dynamic> json) { | ||||
| /// @nodoc | ||||
| mixin _$SnPollMetric { | ||||
|   int get totalAnswer => throw _privateConstructorUsedError; | ||||
|   dynamic get byOptions => throw _privateConstructorUsedError; | ||||
|   Map<String, int> get byOptions => throw _privateConstructorUsedError; | ||||
|   Map<String, int> get byOptionsPercentage => | ||||
|       throw _privateConstructorUsedError; | ||||
|  | ||||
|   /// Serializes this SnPollMetric to a JSON map. | ||||
|   Map<String, dynamic> toJson() => throw _privateConstructorUsedError; | ||||
| @@ -362,7 +364,10 @@ abstract class $SnPollMetricCopyWith<$Res> { | ||||
|           SnPollMetric value, $Res Function(SnPollMetric) then) = | ||||
|       _$SnPollMetricCopyWithImpl<$Res, SnPollMetric>; | ||||
|   @useResult | ||||
|   $Res call({int totalAnswer, dynamic byOptions}); | ||||
|   $Res call( | ||||
|       {int totalAnswer, | ||||
|       Map<String, int> byOptions, | ||||
|       Map<String, int> byOptionsPercentage}); | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| @@ -381,17 +386,22 @@ class _$SnPollMetricCopyWithImpl<$Res, $Val extends SnPollMetric> | ||||
|   @override | ||||
|   $Res call({ | ||||
|     Object? totalAnswer = null, | ||||
|     Object? byOptions = freezed, | ||||
|     Object? byOptions = null, | ||||
|     Object? byOptionsPercentage = null, | ||||
|   }) { | ||||
|     return _then(_value.copyWith( | ||||
|       totalAnswer: null == totalAnswer | ||||
|           ? _value.totalAnswer | ||||
|           : totalAnswer // ignore: cast_nullable_to_non_nullable | ||||
|               as int, | ||||
|       byOptions: freezed == byOptions | ||||
|       byOptions: null == byOptions | ||||
|           ? _value.byOptions | ||||
|           : byOptions // ignore: cast_nullable_to_non_nullable | ||||
|               as dynamic, | ||||
|               as Map<String, int>, | ||||
|       byOptionsPercentage: null == byOptionsPercentage | ||||
|           ? _value.byOptionsPercentage | ||||
|           : byOptionsPercentage // ignore: cast_nullable_to_non_nullable | ||||
|               as Map<String, int>, | ||||
|     ) as $Val); | ||||
|   } | ||||
| } | ||||
| @@ -404,7 +414,10 @@ abstract class _$$SnPollMetricImplCopyWith<$Res> | ||||
|       __$$SnPollMetricImplCopyWithImpl<$Res>; | ||||
|   @override | ||||
|   @useResult | ||||
|   $Res call({int totalAnswer, dynamic byOptions}); | ||||
|   $Res call( | ||||
|       {int totalAnswer, | ||||
|       Map<String, int> byOptions, | ||||
|       Map<String, int> byOptionsPercentage}); | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| @@ -421,17 +434,22 @@ class __$$SnPollMetricImplCopyWithImpl<$Res> | ||||
|   @override | ||||
|   $Res call({ | ||||
|     Object? totalAnswer = null, | ||||
|     Object? byOptions = freezed, | ||||
|     Object? byOptions = null, | ||||
|     Object? byOptionsPercentage = null, | ||||
|   }) { | ||||
|     return _then(_$SnPollMetricImpl( | ||||
|       totalAnswer: null == totalAnswer | ||||
|           ? _value.totalAnswer | ||||
|           : totalAnswer // ignore: cast_nullable_to_non_nullable | ||||
|               as int, | ||||
|       byOptions: freezed == byOptions | ||||
|           ? _value.byOptions | ||||
|       byOptions: null == byOptions | ||||
|           ? _value._byOptions | ||||
|           : byOptions // ignore: cast_nullable_to_non_nullable | ||||
|               as dynamic, | ||||
|               as Map<String, int>, | ||||
|       byOptionsPercentage: null == byOptionsPercentage | ||||
|           ? _value._byOptionsPercentage | ||||
|           : byOptionsPercentage // ignore: cast_nullable_to_non_nullable | ||||
|               as Map<String, int>, | ||||
|     )); | ||||
|   } | ||||
| } | ||||
| @@ -440,19 +458,39 @@ class __$$SnPollMetricImplCopyWithImpl<$Res> | ||||
| @JsonSerializable() | ||||
| class _$SnPollMetricImpl implements _SnPollMetric { | ||||
|   const _$SnPollMetricImpl( | ||||
|       {required this.totalAnswer, required this.byOptions}); | ||||
|       {required this.totalAnswer, | ||||
|       final Map<String, int> byOptions = const {}, | ||||
|       final Map<String, int> byOptionsPercentage = const {}}) | ||||
|       : _byOptions = byOptions, | ||||
|         _byOptionsPercentage = byOptionsPercentage; | ||||
|  | ||||
|   factory _$SnPollMetricImpl.fromJson(Map<String, dynamic> json) => | ||||
|       _$$SnPollMetricImplFromJson(json); | ||||
|  | ||||
|   @override | ||||
|   final int totalAnswer; | ||||
|   final Map<String, int> _byOptions; | ||||
|   @override | ||||
|   final dynamic byOptions; | ||||
|   @JsonKey() | ||||
|   Map<String, int> get byOptions { | ||||
|     if (_byOptions is EqualUnmodifiableMapView) return _byOptions; | ||||
|     // ignore: implicit_dynamic_type | ||||
|     return EqualUnmodifiableMapView(_byOptions); | ||||
|   } | ||||
|  | ||||
|   final Map<String, int> _byOptionsPercentage; | ||||
|   @override | ||||
|   @JsonKey() | ||||
|   Map<String, int> get byOptionsPercentage { | ||||
|     if (_byOptionsPercentage is EqualUnmodifiableMapView) | ||||
|       return _byOptionsPercentage; | ||||
|     // ignore: implicit_dynamic_type | ||||
|     return EqualUnmodifiableMapView(_byOptionsPercentage); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'SnPollMetric(totalAnswer: $totalAnswer, byOptions: $byOptions)'; | ||||
|     return 'SnPollMetric(totalAnswer: $totalAnswer, byOptions: $byOptions, byOptionsPercentage: $byOptionsPercentage)'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
| @@ -462,13 +500,19 @@ class _$SnPollMetricImpl implements _SnPollMetric { | ||||
|             other is _$SnPollMetricImpl && | ||||
|             (identical(other.totalAnswer, totalAnswer) || | ||||
|                 other.totalAnswer == totalAnswer) && | ||||
|             const DeepCollectionEquality().equals(other.byOptions, byOptions)); | ||||
|             const DeepCollectionEquality() | ||||
|                 .equals(other._byOptions, _byOptions) && | ||||
|             const DeepCollectionEquality() | ||||
|                 .equals(other._byOptionsPercentage, _byOptionsPercentage)); | ||||
|   } | ||||
|  | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
|   @override | ||||
|   int get hashCode => Object.hash( | ||||
|       runtimeType, totalAnswer, const DeepCollectionEquality().hash(byOptions)); | ||||
|       runtimeType, | ||||
|       totalAnswer, | ||||
|       const DeepCollectionEquality().hash(_byOptions), | ||||
|       const DeepCollectionEquality().hash(_byOptionsPercentage)); | ||||
|  | ||||
|   /// Create a copy of SnPollMetric | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
| @@ -489,7 +533,8 @@ class _$SnPollMetricImpl implements _SnPollMetric { | ||||
| abstract class _SnPollMetric implements SnPollMetric { | ||||
|   const factory _SnPollMetric( | ||||
|       {required final int totalAnswer, | ||||
|       required final dynamic byOptions}) = _$SnPollMetricImpl; | ||||
|       final Map<String, int> byOptions, | ||||
|       final Map<String, int> byOptionsPercentage}) = _$SnPollMetricImpl; | ||||
|  | ||||
|   factory _SnPollMetric.fromJson(Map<String, dynamic> json) = | ||||
|       _$SnPollMetricImpl.fromJson; | ||||
| @@ -497,7 +542,9 @@ abstract class _SnPollMetric implements SnPollMetric { | ||||
|   @override | ||||
|   int get totalAnswer; | ||||
|   @override | ||||
|   dynamic get byOptions; | ||||
|   Map<String, int> get byOptions; | ||||
|   @override | ||||
|   Map<String, int> get byOptionsPercentage; | ||||
|  | ||||
|   /// Create a copy of SnPollMetric | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   | ||||
| @@ -34,13 +34,22 @@ Map<String, dynamic> _$$SnPollImplToJson(_$SnPollImpl instance) => | ||||
| _$SnPollMetricImpl _$$SnPollMetricImplFromJson(Map<String, dynamic> json) => | ||||
|     _$SnPollMetricImpl( | ||||
|       totalAnswer: (json['total_answer'] as num).toInt(), | ||||
|       byOptions: json['by_options'], | ||||
|       byOptions: (json['by_options'] as Map<String, dynamic>?)?.map( | ||||
|             (k, e) => MapEntry(k, (e as num).toInt()), | ||||
|           ) ?? | ||||
|           const {}, | ||||
|       byOptionsPercentage: | ||||
|           (json['by_options_percentage'] as Map<String, dynamic>?)?.map( | ||||
|                 (k, e) => MapEntry(k, (e as num).toInt()), | ||||
|               ) ?? | ||||
|               const {}, | ||||
|     ); | ||||
|  | ||||
| Map<String, dynamic> _$$SnPollMetricImplToJson(_$SnPollMetricImpl instance) => | ||||
|     <String, dynamic>{ | ||||
|       'total_answer': instance.totalAnswer, | ||||
|       'by_options': instance.byOptions, | ||||
|       'by_options_percentage': instance.byOptionsPercentage, | ||||
|     }; | ||||
|  | ||||
| _$SnPollOptionImpl _$$SnPollOptionImplFromJson(Map<String, dynamic> json) => | ||||
|   | ||||
| @@ -36,6 +36,7 @@ import 'package:surface/widgets/markdown_content.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:surface/widgets/post/post_comment_list.dart'; | ||||
| import 'package:surface/widgets/post/post_meta_editor.dart'; | ||||
| import 'package:surface/widgets/post/post_poll.dart'; | ||||
| import 'package:surface/widgets/post/post_reaction.dart'; | ||||
| import 'package:surface/widgets/post/publisher_popover.dart'; | ||||
| import 'package:surface/widgets/universal_image.dart'; | ||||
| @@ -218,7 +219,7 @@ class PostItem extends StatelessWidget { | ||||
|                 ).padding(bottom: 8), | ||||
|                 if (data.preload?.video != null) _PostVideoPlayer(data: data).padding(bottom: 8), | ||||
|                 _PostHeadline(data: data).padding(horizontal: 4, bottom: 8), | ||||
|                 _PostFeaturedComment(data: data).padding(), | ||||
|                 _PostFeaturedComment(data: data), | ||||
|                 _PostBottomAction( | ||||
|                   data: data, | ||||
|                   showComments: true, | ||||
| @@ -389,6 +390,7 @@ class PostItem extends StatelessWidget { | ||||
|             fit: showFullPost ? BoxFit.cover : BoxFit.contain, | ||||
|             padding: const EdgeInsets.symmetric(horizontal: 12), | ||||
|           ), | ||||
|         if (data.preload?.poll != null) PostPoll(poll: data.preload!.poll!).padding(horizontal: 12, vertical: 4), | ||||
|         if (data.body['content'] != null && (cfg.prefs.getBool(kAppExpandPostLink) ?? true)) | ||||
|           LinkPreviewWidget( | ||||
|             text: data.body['content'], | ||||
|   | ||||
| @@ -1,201 +1,116 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/providers/userinfo.dart'; | ||||
| import 'package:surface/types/poll.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
| import 'package:uuid/uuid.dart'; | ||||
|  | ||||
| class PollEditorDialog extends StatefulWidget { | ||||
|   final SnPoll? poll; | ||||
| class PostPoll extends StatefulWidget { | ||||
|   final SnPoll poll; | ||||
|  | ||||
|   const PollEditorDialog({super.key, this.poll}); | ||||
|   const PostPoll({super.key, required this.poll}); | ||||
|  | ||||
|   @override | ||||
|   State<PollEditorDialog> createState() => _PollEditorDialogState(); | ||||
|   State<PostPoll> createState() => _PostPollState(); | ||||
| } | ||||
|  | ||||
| class _PollEditorDialogState extends State<PollEditorDialog> { | ||||
|   final TextEditingController _linkController = TextEditingController(); | ||||
|   final List<SnPollOption> _pollOptions = List.empty(growable: true); | ||||
|  | ||||
| class _PostPollState extends State<PostPoll> { | ||||
|   bool _isBusy = false; | ||||
|  | ||||
|   Future<void> _fetchPoll() async { | ||||
|     if (_linkController.text.isEmpty) return; | ||||
|     try { | ||||
|       setState(() => _isBusy = true); | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       final resp = await sn.client.get('/cgi/co/polls/${_linkController.text}'); | ||||
|       final out = SnPoll.fromJson(resp.data); | ||||
|       if (!mounted) return; | ||||
|       Navigator.pop(context, out); | ||||
|     } catch (err) { | ||||
|       if (!mounted) return; | ||||
|       context.showErrorDialog(err); | ||||
|     } finally { | ||||
|       setState(() => _isBusy = false); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> _applyPost() async { | ||||
|     try { | ||||
|       setState(() => _isBusy = true); | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       final resp = widget.poll == null | ||||
|           ? await sn.client.post('/cgi/co/polls', data: { | ||||
|               'options': _pollOptions.where((ele) => ele.name.isNotEmpty).toList(), | ||||
|             }) | ||||
|           : await sn.client.put('/cgi/co/polls/${widget.poll!.id}', data: { | ||||
|               'options': _pollOptions.where((ele) => ele.name.isNotEmpty).toList(), | ||||
|             }); | ||||
|       final out = SnPoll.fromJson(resp.data); | ||||
|       if (!mounted) return; | ||||
|       Navigator.pop(context, out); | ||||
|     } catch (err) { | ||||
|       if (!mounted) return; | ||||
|       context.showErrorDialog(err); | ||||
|     } finally { | ||||
|       setState(() => _isBusy = false); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> _deletePoll() async { | ||||
|     final confirm = await context.showConfirmDialog( | ||||
|       'pollEditorDelete'.tr(), | ||||
|       'pollEditorDeleteDescription'.tr(), | ||||
|     ); | ||||
|     if (!confirm) return; | ||||
|     if (!mounted) return; | ||||
|  | ||||
|     try { | ||||
|       setState(() => _isBusy = true); | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       await sn.client.delete('/cgi/co/polls/${widget.poll!.id}'); | ||||
|       if (!mounted) return; | ||||
|       Navigator.pop(context, false); | ||||
|     } catch (err) { | ||||
|       if (!mounted) return; | ||||
|       context.showErrorDialog(err); | ||||
|     } finally { | ||||
|       setState(() => _isBusy = false); | ||||
|     } | ||||
|   } | ||||
|   late SnPoll _poll; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     _poll = widget.poll; | ||||
|     _fetchAnswer(); | ||||
|     super.initState(); | ||||
|     _pollOptions.addAll(widget.poll?.options ?? []); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _linkController.dispose(); | ||||
|     super.dispose(); | ||||
|   String? _answeredChoice; | ||||
|  | ||||
|   Future<void> _fetchAnswer() async { | ||||
|     final ua = context.read<UserProvider>(); | ||||
|     if (!ua.isAuthorized) return; | ||||
|     try { | ||||
|       setState(() => _isBusy = true); | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       final resp = await sn.client.get('/cgi/co/polls/${widget.poll.id}/answer'); | ||||
|       _answeredChoice = resp.data?['answer']; | ||||
|       if (!mounted) return; | ||||
|       setState(() {}); | ||||
|     } catch (err) { | ||||
|       if (!mounted) return; | ||||
|       context.showErrorDialog(err); | ||||
|     } finally { | ||||
|       setState(() => _isBusy = false); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> _voteForOption(SnPollOption option) async { | ||||
|     final ua = context.read<UserProvider>(); | ||||
|     if (!ua.isAuthorized) return; | ||||
|     try { | ||||
|       setState(() => _isBusy = true); | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       await sn.client.post('/cgi/co/polls/${widget.poll.id}/answer', data: { | ||||
|         'answer': option.id, | ||||
|       }); | ||||
|       if (!mounted) return; | ||||
|       context.showSnackbar('pollAnswered'.tr()); | ||||
|       HapticFeedback.heavyImpact(); | ||||
|     } catch (err) { | ||||
|       if (!mounted) return; | ||||
|       context.showErrorDialog(err); | ||||
|     } finally { | ||||
|       setState(() => _isBusy = false); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return AlertDialog( | ||||
|       title: Text(widget.poll == null ? 'pollEditorNew' : 'pollEditorEdit').tr(), | ||||
|       content: Column( | ||||
|         mainAxisSize: MainAxisSize.min, | ||||
|         spacing: 16, | ||||
|     return Card( | ||||
|       margin: EdgeInsets.zero, | ||||
|       child: Column( | ||||
|         children: [ | ||||
|           if (widget.poll == null) | ||||
|             TextField( | ||||
|               controller: _linkController, | ||||
|               decoration: InputDecoration( | ||||
|                 isDense: true, | ||||
|                 labelText: 'pollLinkExisting'.tr(), | ||||
|                 prefixText: '#', | ||||
|                 suffixIcon: IconButton( | ||||
|                   visualDensity: const VisualDensity(horizontal: -4, vertical: -4), | ||||
|                   constraints: const BoxConstraints(), | ||||
|                   padding: EdgeInsets.zero, | ||||
|                   onPressed: _isBusy ? null : () => _fetchPoll(), | ||||
|                   icon: const Icon(Icons.keyboard_arrow_right), | ||||
|                 ), | ||||
|                 border: const OutlineInputBorder(), | ||||
|               ), | ||||
|               onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|             ), | ||||
|           Card( | ||||
|             margin: EdgeInsets.zero, | ||||
|             child: Column( | ||||
|           for (final option in _poll.options) | ||||
|             Stack( | ||||
|               children: [ | ||||
|                 for (int i = 0; i < _pollOptions.length; i++) | ||||
|                   ListTile( | ||||
|                     leading: const Icon(Symbols.circle), | ||||
|                     title: TextFormField( | ||||
|                       decoration: InputDecoration.collapsed( | ||||
|                         hintText: 'pollOptionName'.tr(), | ||||
|                       ), | ||||
|                       onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                       initialValue: _pollOptions[i].name, | ||||
|                       onChanged: (value) { | ||||
|                         // Looks like we don't need set state here cuz it got internal updated. | ||||
|                         _pollOptions[i] = _pollOptions[i].copyWith(name: value); | ||||
|                       }, | ||||
|                     ), | ||||
|                     trailing: IconButton( | ||||
|                       visualDensity: const VisualDensity(horizontal: -4, vertical: -4), | ||||
|                       padding: EdgeInsets.zero, | ||||
|                       constraints: const BoxConstraints(), | ||||
|                       onPressed: () { | ||||
|                         setState(() => _pollOptions.removeAt(i)); | ||||
|                       }, | ||||
|                       icon: const Icon(Icons.close), | ||||
|                     ), | ||||
|                   ), | ||||
|                 Container( | ||||
|                   height: 60, | ||||
|                   width: MediaQuery.of(context).size.width * (_poll.metric.byOptionsPercentage[option.id] ?? 0).toDouble(), | ||||
|                   color: Theme.of(context).colorScheme.surfaceContainerHigh, | ||||
|                 ), | ||||
|                 ListTile( | ||||
|                   leading: const Icon(Symbols.add), | ||||
|                   title: Text('pollOptionAdd').tr(), | ||||
|                   onTap: () { | ||||
|                     setState( | ||||
|                       () => _pollOptions.add( | ||||
|                         SnPollOption(id: const Uuid().v4(), icon: '', name: '', description: ''), | ||||
|                   shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), | ||||
|                   minTileHeight: 60, | ||||
|                   leading: _answeredChoice == option.id ? const Icon(Symbols.circle, fill: 1) : const Icon(Symbols.circle), | ||||
|                   title: Text(option.name), | ||||
|                   subtitle: Column( | ||||
|                     crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                     mainAxisSize: MainAxisSize.min, | ||||
|                     children: [ | ||||
|                       Row( | ||||
|                         mainAxisSize: MainAxisSize.min, | ||||
|                         children: [ | ||||
|                           Text('pollVotes'.plural(_poll.metric.byOptions[option.id] ?? 0)), | ||||
|                           Text(' · ').padding(horizontal: 4), | ||||
|                           Text( | ||||
|                             '${((_poll.metric.byOptionsPercentage[option.id] ?? 0).toDouble() * 100).toStringAsFixed(2)}%', | ||||
|                           ), | ||||
|                         ], | ||||
|                       ), | ||||
|                     ); | ||||
|                   }, | ||||
|                       if (option.description.isNotEmpty) Text(option.description), | ||||
|                     ], | ||||
|                   ), | ||||
|                   onTap: _isBusy ? null : () => _voteForOption(option), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|           if (widget.poll != null) | ||||
|             Card( | ||||
|               margin: EdgeInsets.zero, | ||||
|               child: Column( | ||||
|                 mainAxisSize: MainAxisSize.min, | ||||
|                 children: [ | ||||
|                   ListTile( | ||||
|                     leading: const Icon(Symbols.delete), | ||||
|                     trailing: const Icon(Symbols.chevron_right), | ||||
|                     title: Text('pollEditorDelete').tr(), | ||||
|                     onTap: _isBusy ? null : () => _deletePoll(), | ||||
|                   ), | ||||
|                   ListTile( | ||||
|                     leading: const Icon(Symbols.link_off), | ||||
|                     trailing: const Icon(Symbols.chevron_right), | ||||
|                     title: Text('pollEditorUnlink').tr(), | ||||
|                     onTap: _isBusy ? null : () => Navigator.pop(context, false), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|             ) | ||||
|         ], | ||||
|       ), | ||||
|       actions: [ | ||||
|         TextButton( | ||||
|           onPressed: _isBusy ? null : () => Navigator.pop(context), | ||||
|           child: Text('cancel'.tr()), | ||||
|         ), | ||||
|         TextButton( | ||||
|           onPressed: _isBusy ? null : () => _applyPost(), | ||||
|           child: Text('dialogConfirm'.tr()), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										201
									
								
								lib/widgets/post/post_poll_editor.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										201
									
								
								lib/widgets/post/post_poll_editor.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,201 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/types/poll.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
| import 'package:uuid/uuid.dart'; | ||||
|  | ||||
| class PollEditorDialog extends StatefulWidget { | ||||
|   final SnPoll? poll; | ||||
|  | ||||
|   const PollEditorDialog({super.key, this.poll}); | ||||
|  | ||||
|   @override | ||||
|   State<PollEditorDialog> createState() => _PollEditorDialogState(); | ||||
| } | ||||
|  | ||||
| class _PollEditorDialogState extends State<PollEditorDialog> { | ||||
|   final TextEditingController _linkController = TextEditingController(); | ||||
|   final List<SnPollOption> _pollOptions = List.empty(growable: true); | ||||
|  | ||||
|   bool _isBusy = false; | ||||
|  | ||||
|   Future<void> _fetchPoll() async { | ||||
|     if (_linkController.text.isEmpty) return; | ||||
|     try { | ||||
|       setState(() => _isBusy = true); | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       final resp = await sn.client.get('/cgi/co/polls/${_linkController.text}'); | ||||
|       final out = SnPoll.fromJson(resp.data); | ||||
|       if (!mounted) return; | ||||
|       Navigator.pop(context, out); | ||||
|     } catch (err) { | ||||
|       if (!mounted) return; | ||||
|       context.showErrorDialog(err); | ||||
|     } finally { | ||||
|       setState(() => _isBusy = false); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> _applyPost() async { | ||||
|     try { | ||||
|       setState(() => _isBusy = true); | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       final resp = widget.poll == null | ||||
|           ? await sn.client.post('/cgi/co/polls', data: { | ||||
|               'options': _pollOptions.where((ele) => ele.name.isNotEmpty).toList(), | ||||
|             }) | ||||
|           : await sn.client.put('/cgi/co/polls/${widget.poll!.id}', data: { | ||||
|               'options': _pollOptions.where((ele) => ele.name.isNotEmpty).toList(), | ||||
|             }); | ||||
|       final out = SnPoll.fromJson(resp.data); | ||||
|       if (!mounted) return; | ||||
|       Navigator.pop(context, out); | ||||
|     } catch (err) { | ||||
|       if (!mounted) return; | ||||
|       context.showErrorDialog(err); | ||||
|     } finally { | ||||
|       setState(() => _isBusy = false); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> _deletePoll() async { | ||||
|     final confirm = await context.showConfirmDialog( | ||||
|       'pollEditorDelete'.tr(), | ||||
|       'pollEditorDeleteDescription'.tr(), | ||||
|     ); | ||||
|     if (!confirm) return; | ||||
|     if (!mounted) return; | ||||
|  | ||||
|     try { | ||||
|       setState(() => _isBusy = true); | ||||
|       final sn = context.read<SnNetworkProvider>(); | ||||
|       await sn.client.delete('/cgi/co/polls/${widget.poll!.id}'); | ||||
|       if (!mounted) return; | ||||
|       Navigator.pop(context, false); | ||||
|     } catch (err) { | ||||
|       if (!mounted) return; | ||||
|       context.showErrorDialog(err); | ||||
|     } finally { | ||||
|       setState(() => _isBusy = false); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _pollOptions.addAll(widget.poll?.options ?? []); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _linkController.dispose(); | ||||
|     super.dispose(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return AlertDialog( | ||||
|       title: Text(widget.poll == null ? 'pollEditorNew' : 'pollEditorEdit').tr(), | ||||
|       content: Column( | ||||
|         mainAxisSize: MainAxisSize.min, | ||||
|         spacing: 16, | ||||
|         children: [ | ||||
|           if (widget.poll == null) | ||||
|             TextField( | ||||
|               controller: _linkController, | ||||
|               decoration: InputDecoration( | ||||
|                 isDense: true, | ||||
|                 labelText: 'pollLinkExisting'.tr(), | ||||
|                 prefixText: '#', | ||||
|                 suffixIcon: IconButton( | ||||
|                   visualDensity: const VisualDensity(horizontal: -4, vertical: -4), | ||||
|                   constraints: const BoxConstraints(), | ||||
|                   padding: EdgeInsets.zero, | ||||
|                   onPressed: _isBusy ? null : () => _fetchPoll(), | ||||
|                   icon: const Icon(Icons.keyboard_arrow_right), | ||||
|                 ), | ||||
|                 border: const OutlineInputBorder(), | ||||
|               ), | ||||
|               onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|             ), | ||||
|           Card( | ||||
|             margin: EdgeInsets.zero, | ||||
|             child: Column( | ||||
|               children: [ | ||||
|                 for (int i = 0; i < _pollOptions.length; i++) | ||||
|                   ListTile( | ||||
|                     leading: const Icon(Symbols.circle), | ||||
|                     title: TextFormField( | ||||
|                       decoration: InputDecoration.collapsed( | ||||
|                         hintText: 'pollOptionName'.tr(), | ||||
|                       ), | ||||
|                       onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                       initialValue: _pollOptions[i].name, | ||||
|                       onChanged: (value) { | ||||
|                         // Looks like we don't need set state here cuz it got internal updated. | ||||
|                         _pollOptions[i] = _pollOptions[i].copyWith(name: value); | ||||
|                       }, | ||||
|                     ), | ||||
|                     trailing: IconButton( | ||||
|                       visualDensity: const VisualDensity(horizontal: -4, vertical: -4), | ||||
|                       padding: EdgeInsets.zero, | ||||
|                       constraints: const BoxConstraints(), | ||||
|                       onPressed: () { | ||||
|                         setState(() => _pollOptions.removeAt(i)); | ||||
|                       }, | ||||
|                       icon: const Icon(Icons.close), | ||||
|                     ), | ||||
|                   ), | ||||
|                 ListTile( | ||||
|                   leading: const Icon(Symbols.add), | ||||
|                   title: Text('pollOptionAdd').tr(), | ||||
|                   onTap: () { | ||||
|                     setState( | ||||
|                       () => _pollOptions.add( | ||||
|                         SnPollOption(id: const Uuid().v4(), icon: '', name: '', description: ''), | ||||
|                       ), | ||||
|                     ); | ||||
|                   }, | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|           if (widget.poll != null) | ||||
|             Card( | ||||
|               margin: EdgeInsets.zero, | ||||
|               child: Column( | ||||
|                 mainAxisSize: MainAxisSize.min, | ||||
|                 children: [ | ||||
|                   ListTile( | ||||
|                     leading: const Icon(Symbols.delete), | ||||
|                     trailing: const Icon(Symbols.chevron_right), | ||||
|                     title: Text('pollEditorDelete').tr(), | ||||
|                     onTap: _isBusy ? null : () => _deletePoll(), | ||||
|                   ), | ||||
|                   ListTile( | ||||
|                     leading: const Icon(Symbols.link_off), | ||||
|                     trailing: const Icon(Symbols.chevron_right), | ||||
|                     title: Text('pollEditorUnlink').tr(), | ||||
|                     onTap: _isBusy ? null : () => Navigator.pop(context, false), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|         ], | ||||
|       ), | ||||
|       actions: [ | ||||
|         TextButton( | ||||
|           onPressed: _isBusy ? null : () => Navigator.pop(context), | ||||
|           child: Text('cancel'.tr()), | ||||
|         ), | ||||
|         TextButton( | ||||
|           onPressed: _isBusy ? null : () => _applyPost(), | ||||
|           child: Text('dialogConfirm'.tr()), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user