From cb24bd953d3f6610430fcbac3049b7ffe41fbdeb Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Thu, 13 Feb 2025 22:35:53 +0800 Subject: [PATCH] :sparkles: Poll participate --- assets/translations/en-US.json | 7 +- assets/translations/zh-CN.json | 7 +- lib/screens/post/post_editor.dart | 3 +- lib/types/poll.dart | 3 +- lib/types/poll.freezed.dart | 81 ++++++-- lib/types/poll.g.dart | 11 +- lib/widgets/post/post_item.dart | 4 +- lib/widgets/post/post_poll.dart | 247 ++++++++----------------- lib/widgets/post/post_poll_editor.dart | 201 ++++++++++++++++++++ 9 files changed, 374 insertions(+), 190 deletions(-) create mode 100644 lib/widgets/post/post_poll_editor.dart diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index c1c71de..d6a4b29 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -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" + } } diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index 6c53f7c..c0a6619 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -632,5 +632,10 @@ "pollEditorUnlink": "解除链接", "pollOptionAdd": "添加选项", "pollOptionName": "选项名称", - "pollLinkExisting": "链接现有投票" + "pollLinkExisting": "链接现有投票", + "pollAnswered": "答案已经反馈。", + "pollVotes": { + "one": "{} 票", + "other": "{} 票" + } } diff --git a/lib/screens/post/post_editor.dart b/lib/screens/post/post_editor.dart index f9cdae7..ca32033 100644 --- a/lib/screens/post/post_editor.dart +++ b/lib/screens/post/post_editor.dart @@ -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 { diff --git a/lib/types/poll.dart b/lib/types/poll.dart index 7d0a355..28786c2 100644 --- a/lib/types/poll.dart +++ b/lib/types/poll.dart @@ -24,7 +24,8 @@ class SnPoll with _$SnPoll { class SnPollMetric with _$SnPollMetric { const factory SnPollMetric({ required int totalAnswer, - required dynamic byOptions, + @Default({}) Map byOptions, + @Default({}) Map byOptionsPercentage, }) = _SnPollMetric; factory SnPollMetric.fromJson(Map json) diff --git a/lib/types/poll.freezed.dart b/lib/types/poll.freezed.dart index 2d2f4ad..eaeb28e 100644 --- a/lib/types/poll.freezed.dart +++ b/lib/types/poll.freezed.dart @@ -344,7 +344,9 @@ SnPollMetric _$SnPollMetricFromJson(Map json) { /// @nodoc mixin _$SnPollMetric { int get totalAnswer => throw _privateConstructorUsedError; - dynamic get byOptions => throw _privateConstructorUsedError; + Map get byOptions => throw _privateConstructorUsedError; + Map get byOptionsPercentage => + throw _privateConstructorUsedError; /// Serializes this SnPollMetric to a JSON map. Map 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 byOptions, + Map 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, + byOptionsPercentage: null == byOptionsPercentage + ? _value.byOptionsPercentage + : byOptionsPercentage // ignore: cast_nullable_to_non_nullable + as Map, ) 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 byOptions, + Map 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, + byOptionsPercentage: null == byOptionsPercentage + ? _value._byOptionsPercentage + : byOptionsPercentage // ignore: cast_nullable_to_non_nullable + as Map, )); } } @@ -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 byOptions = const {}, + final Map byOptionsPercentage = const {}}) + : _byOptions = byOptions, + _byOptionsPercentage = byOptionsPercentage; factory _$SnPollMetricImpl.fromJson(Map json) => _$$SnPollMetricImplFromJson(json); @override final int totalAnswer; + final Map _byOptions; @override - final dynamic byOptions; + @JsonKey() + Map get byOptions { + if (_byOptions is EqualUnmodifiableMapView) return _byOptions; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_byOptions); + } + + final Map _byOptionsPercentage; + @override + @JsonKey() + Map 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 byOptions, + final Map byOptionsPercentage}) = _$SnPollMetricImpl; factory _SnPollMetric.fromJson(Map json) = _$SnPollMetricImpl.fromJson; @@ -497,7 +542,9 @@ abstract class _SnPollMetric implements SnPollMetric { @override int get totalAnswer; @override - dynamic get byOptions; + Map get byOptions; + @override + Map get byOptionsPercentage; /// Create a copy of SnPollMetric /// with the given fields replaced by the non-null parameter values. diff --git a/lib/types/poll.g.dart b/lib/types/poll.g.dart index 9c6b093..445a263 100644 --- a/lib/types/poll.g.dart +++ b/lib/types/poll.g.dart @@ -34,13 +34,22 @@ Map _$$SnPollImplToJson(_$SnPollImpl instance) => _$SnPollMetricImpl _$$SnPollMetricImplFromJson(Map json) => _$SnPollMetricImpl( totalAnswer: (json['total_answer'] as num).toInt(), - byOptions: json['by_options'], + byOptions: (json['by_options'] as Map?)?.map( + (k, e) => MapEntry(k, (e as num).toInt()), + ) ?? + const {}, + byOptionsPercentage: + (json['by_options_percentage'] as Map?)?.map( + (k, e) => MapEntry(k, (e as num).toInt()), + ) ?? + const {}, ); Map _$$SnPollMetricImplToJson(_$SnPollMetricImpl instance) => { 'total_answer': instance.totalAnswer, 'by_options': instance.byOptions, + 'by_options_percentage': instance.byOptionsPercentage, }; _$SnPollOptionImpl _$$SnPollOptionImplFromJson(Map json) => diff --git a/lib/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart index 2c86316..48a4700 100644 --- a/lib/widgets/post/post_item.dart +++ b/lib/widgets/post/post_item.dart @@ -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'], diff --git a/lib/widgets/post/post_poll.dart b/lib/widgets/post/post_poll.dart index 7c17bfd..22d4a4a 100644 --- a/lib/widgets/post/post_poll.dart +++ b/lib/widgets/post/post_poll.dart @@ -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 createState() => _PollEditorDialogState(); + State createState() => _PostPollState(); } -class _PollEditorDialogState extends State { - final TextEditingController _linkController = TextEditingController(); - final List _pollOptions = List.empty(growable: true); - +class _PostPollState extends State { bool _isBusy = false; - - Future _fetchPoll() async { - if (_linkController.text.isEmpty) return; - try { - setState(() => _isBusy = true); - final sn = context.read(); - 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 _applyPost() async { - try { - setState(() => _isBusy = true); - final sn = context.read(); - 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 _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(); - 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 _fetchAnswer() async { + final ua = context.read(); + if (!ua.isAuthorized) return; + try { + setState(() => _isBusy = true); + final sn = context.read(); + 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 _voteForOption(SnPollOption option) async { + final ua = context.read(); + if (!ua.isAuthorized) return; + try { + setState(() => _isBusy = true); + final sn = context.read(); + 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()), - ), - ], ); } } diff --git a/lib/widgets/post/post_poll_editor.dart b/lib/widgets/post/post_poll_editor.dart new file mode 100644 index 0000000..7c17bfd --- /dev/null +++ b/lib/widgets/post/post_poll_editor.dart @@ -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 createState() => _PollEditorDialogState(); +} + +class _PollEditorDialogState extends State { + final TextEditingController _linkController = TextEditingController(); + final List _pollOptions = List.empty(growable: true); + + bool _isBusy = false; + + Future _fetchPoll() async { + if (_linkController.text.isEmpty) return; + try { + setState(() => _isBusy = true); + final sn = context.read(); + 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 _applyPost() async { + try { + setState(() => _isBusy = true); + final sn = context.read(); + 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 _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(); + 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()), + ), + ], + ); + } +}