Poll participate

This commit is contained in:
2025-02-13 22:35:53 +08:00
parent 4937dee182
commit cb24bd953d
9 changed files with 374 additions and 190 deletions

View File

@ -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'],

View File

@ -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()),
),
],
);
}
}

View 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()),
),
],
);
}
}