✨ Poll participate
This commit is contained in:
@ -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