feat: create vote (#871)

Signed-off-by: bggRGjQaUbCoE <githubaccount56556@proton.me>
This commit is contained in:
dom
2025-07-04 22:10:11 +08:00
committed by GitHub
parent 9ce84fb997
commit 83459df3b7
18 changed files with 800 additions and 67 deletions

View File

@@ -48,7 +48,9 @@ abstract class CommonPublishPageState<T extends CommonPublishPage>
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
if (Platform.isAndroid) {
WidgetsBinding.instance.addObserver(this);
}
initPubState();
@@ -68,7 +70,9 @@ abstract class CommonPublishPageState<T extends CommonPublishPage>
}
focusNode.dispose();
editController.dispose();
WidgetsBinding.instance.removeObserver(this);
if (Platform.isAndroid) {
WidgetsBinding.instance.removeObserver(this);
}
super.dispose();
}
@@ -83,6 +87,7 @@ abstract class CommonPublishPageState<T extends CommonPublishPage>
if (mounted &&
widget.autofocus &&
panelType.value == PanelType.keyboard) {
controller.restoreChatPanel();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (focusNode.hasFocus) {
focusNode.unfocus();

View File

@@ -66,9 +66,9 @@ abstract class CommonRichTextPubPageState<T extends CommonRichTextPubPage>
clipBehavior: Clip.none,
children: [
GestureDetector(
onTap: () {
onTap: () async {
controller.keepChatPanel();
context.imageView(
await context.imageView(
imgList: pathList
.map((path) => SourceModel(
url: path,
@@ -77,6 +77,7 @@ abstract class CommonRichTextPubPageState<T extends CommonRichTextPubPage>
.toList(),
initialPage: index,
);
controller.restoreChatPanel();
},
onLongPress: onClear,
child: ClipRRect(
@@ -193,31 +194,47 @@ abstract class CommonRichTextPubPageState<T extends CommonRichTextPubPage>
List<Map<String, dynamic>>? getRichContent() {
if (editController.items.isEmpty) return null;
return editController.items.map((e) {
return switch (e.type) {
RichTextType.text || RichTextType.composing => <String, dynamic>{
final list = <Map<String, dynamic>>[];
for (var e in editController.items) {
switch (e.type) {
case RichTextType.text || RichTextType.composing:
list.add({
"raw_text": e.text,
"type": 1,
"biz_id": "",
},
RichTextType.at => <String, dynamic>{
});
case RichTextType.at:
list.add({
"raw_text": '@${e.rawText}',
"type": 2,
"biz_id": e.uid,
},
RichTextType.emoji => <String, dynamic>{
"biz_id": e.id,
});
case RichTextType.emoji:
list.add({
"raw_text": e.rawText,
"type": 9,
"biz_id": "",
},
};
}).toList();
});
case RichTextType.vote:
list.add({
"raw_text": e.rawText,
"type": 4,
"biz_id": e.id,
});
list.add({
"raw_text": ' ',
"type": 1,
"biz_id": "",
});
}
}
return list;
}
double _mentionOffset = 0;
void onMention([bool fromClick = false]) {
Future<void> onMention([bool fromClick = false]) async {
controller.keepChatPanel();
DynMentionPanel.onDynMention(
await DynMentionPanel.onDynMention(
context,
offset: _mentionOffset,
callback: (offset) => _mentionOffset = offset,
@@ -227,11 +244,12 @@ abstract class CommonRichTextPubPageState<T extends CommonRichTextPubPage>
'@${res.name} ',
RichTextType.at,
rawText: res.name,
uid: res.uid,
id: res.uid,
fromClick: fromClick,
);
}
});
controller.restoreChatPanel();
}
void onInsertText(
@@ -239,7 +257,7 @@ abstract class CommonRichTextPubPageState<T extends CommonRichTextPubPage>
RichTextType type, {
String? rawText,
Emote? emote,
String? uid,
String? id,
bool? fromClick,
}) {
if (text.isEmpty) {
@@ -268,7 +286,7 @@ abstract class CommonRichTextPubPageState<T extends CommonRichTextPubPage>
rawText: rawText,
type: type,
emote: emote,
uid: uid,
id: id,
);
} else {
delta = RichTextEditingDeltaInsertion(
@@ -282,7 +300,7 @@ abstract class CommonRichTextPubPageState<T extends CommonRichTextPubPage>
rawText: rawText,
type: type,
emote: emote,
uid: uid,
id: id,
);
}
} else {
@@ -297,7 +315,7 @@ abstract class CommonRichTextPubPageState<T extends CommonRichTextPubPage>
rawText: rawText,
type: type,
emote: emote,
uid: uid,
id: id,
);
}
@@ -308,8 +326,8 @@ abstract class CommonRichTextPubPageState<T extends CommonRichTextPubPage>
}
editController
..value = newValue
..syncRichText(delta);
..syncRichText(delta)
..value = newValue.copyWith(selection: editController.newSelection);
} else {
editController.value = TextEditingValue(
text: text,
@@ -327,7 +345,7 @@ abstract class CommonRichTextPubPageState<T extends CommonRichTextPubPage>
end: text.length,
),
emote: emote,
uid: uid,
id: id,
),
);
}

View File

@@ -146,14 +146,14 @@ class _VotePanelState extends State<VotePanel> {
child: Builder(
builder: (context) {
final opt = _voteInfo.options[index];
final selected = groupValue.contains(opt.optidx);
final selected = groupValue.contains(opt.optIdx);
return PercentageChip(
label: opt.optdesc!,
label: opt.optDesc!,
percentage: _showPercentage ? _percentage[index] : null,
selected: selected,
onSelected: !_enabled || (groupValue.length >= _maxCnt && !selected)
? null
: (value) => _onSelected(context, value, opt.optidx!),
: (value) => _onSelected(context, value, opt.optIdx!),
);
},
),

View File

@@ -5,12 +5,15 @@ import 'package:PiliPlus/common/widgets/custom_icon.dart';
import 'package:PiliPlus/common/widgets/draggable_sheet/draggable_scrollable_sheet_dyn.dart'
as dyn_sheet;
import 'package:PiliPlus/common/widgets/pair.dart';
import 'package:PiliPlus/common/widgets/text_field/controller.dart';
import 'package:PiliPlus/common/widgets/text_field/text_field.dart';
import 'package:PiliPlus/http/dynamics.dart';
import 'package:PiliPlus/models/common/publish_panel_type.dart';
import 'package:PiliPlus/models/common/reply/reply_option_type.dart';
import 'package:PiliPlus/models/dynamics/vote_model.dart';
import 'package:PiliPlus/models_new/dynamic/dyn_topic_top/topic_item.dart';
import 'package:PiliPlus/pages/common/publish/common_rich_text_pub_page.dart';
import 'package:PiliPlus/pages/dynamics_create_vote/view.dart';
import 'package:PiliPlus/pages/dynamics_mention/controller.dart';
import 'package:PiliPlus/pages/dynamics_select_topic/controller.dart';
import 'package:PiliPlus/pages/dynamics_select_topic/view.dart';
@@ -341,7 +344,6 @@ class _CreateDynPanelState extends CommonRichTextPubPageState<CreateDynPanel> {
return PopupMenuButton<bool>(
requestFocus: false,
initialValue: _isPrivate.value,
onOpened: controller.keepChatPanel,
onSelected: (value) => _isPrivate.value = value,
itemBuilder: (context) => List.generate(
2,
@@ -398,7 +400,6 @@ class _CreateDynPanelState extends CommonRichTextPubPageState<CreateDynPanel> {
return PopupMenuButton<ReplyOptionType>(
requestFocus: false,
initialValue: _replyOption.value,
onOpened: controller.keepChatPanel,
onSelected: (item) => _replyOption.value = item,
itemBuilder: (context) => ReplyOptionType.values
.map(
@@ -549,6 +550,55 @@ class _CreateDynPanelState extends CommonRichTextPubPageState<CreateDynPanel> {
tooltip: '@',
selected: false,
),
ToolbarIconButton(
onPressed: () async {
controller.keepChatPanel();
RichTextItem? voteItem = editController.items
.firstWhereOrNull((e) => e.type == RichTextType.vote);
VoteInfo? voteInfo = await Navigator.of(context).push(
GetPageRoute(
page: () => CreateVotePage(
voteId: voteItem?.id == null
? null
: int.parse(voteItem!.id!))),
);
if (voteInfo != null) {
if (voteItem != null) {
final delta = RichTextEditingDeltaReplacement(
oldText: editController.text,
replacementText: ' ${voteInfo.title} ',
replacedRange: voteItem.range,
selection: editController.selection,
composing: TextRange.empty,
type: RichTextType.vote,
id: voteInfo.voteId.toString(),
rawText: voteInfo.title,
);
final newValue = delta.apply(editController.value);
editController
..syncRichText(delta)
..value = newValue.copyWith(
selection: editController.newSelection,
);
} else {
onInsertText(
'我发起了一个投票',
RichTextType.text,
);
onInsertText(
' ${voteInfo.title} ',
RichTextType.vote,
rawText: voteInfo.title,
id: voteInfo.voteId.toString(),
);
}
}
controller.restoreChatPanel();
},
icon: const Icon(Icons.bar_chart_rounded, size: 22),
tooltip: '投票',
selected: false,
),
// if (kDebugMode)
// ToolbarIconButton(
// onPressed: editController.clear,

View File

@@ -0,0 +1,126 @@
import 'package:PiliPlus/common/widgets/dialog/dialog.dart';
import 'package:PiliPlus/http/dynamics.dart';
import 'package:PiliPlus/http/msg.dart';
import 'package:PiliPlus/models/dynamics/vote_model.dart';
import 'package:PiliPlus/models_new/upload_bfs/data.dart';
import 'package:PiliPlus/utils/accounts.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
class CreateVoteController extends GetxController {
CreateVoteController(this.voteId);
final int? voteId;
String key = Utils.generateRandomString(6);
final RxString title = ''.obs;
final RxString desc = ''.obs;
final RxInt type = 0.obs;
final RxList<Option> options = <Option>[
Option(optDesc: '', imgUrl: ''),
Option(optDesc: '', imgUrl: ''),
].obs;
final RxInt choiceCnt = 1.obs;
final now = DateTime.now();
late final end = now.copyWith(day: now.day + 90);
late Rx<DateTime> endtime;
final RxBool canCreate = false.obs;
void updateCanCreate() {
if (type.value == 0) {
canCreate.value = title.value.isNotEmpty &&
options.every((e) => e.optDesc?.isNotEmpty == true);
} else {
canCreate.value = title.value.isNotEmpty &&
options.every(
(e) =>
e.optDesc?.isNotEmpty == true && e.imgUrl?.isNotEmpty == true,
);
}
}
@override
void onInit() {
super.onInit();
endtime = DateTime(
now.year,
now.month,
now.day + 1,
now.hour,
now.minute,
).obs;
if (voteId != null) {
queryData();
}
}
Future<void> queryData() async {
var res = await DynamicsHttp.voteInfo(voteId);
if (res.isSuccess) {
key = Utils.generateRandomString(6);
final VoteInfo data = res.data;
title.value = data.title!;
desc.value = data.desc ?? '';
type.value = data.options.first.imgUrl?.isNotEmpty == true ? 1 : 0;
options.value = data.options;
choiceCnt.value = data.choiceCnt!;
endtime.value = DateTime.fromMillisecondsSinceEpoch(data.endTime! * 1000);
canCreate.value = true;
} else {
showConfirmDialog(
context: Get.context!,
title: res.toString(),
onConfirm: Get.back,
);
}
}
void onDel(int i) {
options.removeAt(i);
updateCanCreate();
if (choiceCnt.value > options.length) {
choiceCnt.value = options.length;
}
}
Future<void> onCreate() async {
final voteInfo = VoteInfo(
title: title.value,
desc: desc.value,
type: type.value,
duration: endtime.value.difference(now).inSeconds,
options: options,
onlyFansLevel: 0,
choiceCnt: choiceCnt.value,
votePublisher: Accounts.main.mid,
voteId: voteId,
);
var res = voteId == null
? await DynamicsHttp.createVote(voteInfo)
: await DynamicsHttp.updateVote(voteInfo);
if (res.isSuccess) {
voteInfo.voteId = res.data;
Get.back(result: voteInfo);
} else {
res.toast();
}
}
Future<void> onUpload(int index, XFile pickedFile) async {
var res = await MsgHttp.uploadBfs(
path: pickedFile.path,
category: 'daily',
biz: 'vote',
);
if (res['status']) {
UploadBfsResData data = res['data'];
options
..[index].imgUrl = data.imageUrl
..refresh();
updateCanCreate();
} else {
SmartDialog.showToast(res['msg']);
}
}
}

View File

@@ -0,0 +1,412 @@
import 'package:PiliPlus/common/widgets/button/icon_button.dart';
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
import 'package:PiliPlus/models/dynamics/vote_model.dart';
import 'package:PiliPlus/pages/dynamics_create_vote/controller.dart';
import 'package:PiliPlus/utils/date_util.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:easy_debounce/easy_throttle.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
class CreateVotePage extends StatefulWidget {
const CreateVotePage({super.key, this.voteId});
final int? voteId;
@override
State<CreateVotePage> createState() => _CreateVotePageState();
}
class _CreateVotePageState extends State<CreateVotePage> {
late final _controller = Get.put(CreateVoteController(widget.voteId),
tag: Utils.generateRandomString(8));
late final imagePicker = ImagePicker();
late Divider _divider;
late TextStyle _leadingStyle;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
_divider = Divider(
height: 1,
color: theme.colorScheme.outline.withValues(alpha: 0.1),
);
_leadingStyle = TextStyle(
fontSize: 15,
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.9),
);
final padding = MediaQuery.paddingOf(context);
final divider = [
const SizedBox(height: 10),
_divider,
const SizedBox(height: 10),
];
return Scaffold(
appBar: AppBar(
title: Text('${_controller.voteId != null ? '' : '发起'}投票'),
),
body: ListView(
padding: EdgeInsets.only(
left: padding.left + 16,
right: padding.right + 16,
bottom: padding.bottom + 80,
),
children: [
const Text(
'投票类型',
style: TextStyle(fontSize: 14),
),
const SizedBox(height: 12),
_buildType(theme),
const SizedBox(height: 40),
Obx(
() => _buildInput(
theme,
key: ValueKey('${_controller.key}title'),
initialValue: _controller.title.value,
onChanged: (value) => _controller
..title.value = value
..updateCanCreate(),
desc: '投票标题',
hintText: '请填写标题',
inputFormatters: [LengthLimitingTextInputFormatter(32)],
),
),
...divider,
Obx(
() => _buildInput(
theme,
key: ValueKey('${_controller.key}desc'),
initialValue: _controller.desc.value,
onChanged: (value) => _controller.desc.value = value,
desc: '投票说明',
inputFormatters: [LengthLimitingTextInputFormatter(100)],
),
),
...divider,
const SizedBox(height: 40),
Obx(
() {
final showImg = _controller.type.value == 1;
final showDel = _controller.options.length > 2;
List<Widget> children = [];
for (int i = 0; i < _controller.options.length; i++) {
final e = _controller.options[i];
children
..add(_buildInput(
theme,
key: ValueKey(e.hashCode),
showDel: showDel,
onDel: () {
FocusManager.instance.primaryFocus?.unfocus();
_controller.onDel(i);
},
showImg: showImg,
imgUrl: e.imgUrl,
onPickImg: () => EasyThrottle.throttle(
'picImg',
const Duration(milliseconds: 500),
() => _onPickImg(i),
),
initialValue: e.optDesc,
onChanged: (value) => _controller
..options[i].optDesc = value
..updateCanCreate(),
desc: '选项${i + 1}',
hintText: '选项内容最多20字',
inputFormatters: [LengthLimitingTextInputFormatter(20)],
))
..addAll(divider);
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...children,
if (_controller.options.length < 20)
FilledButton(
onPressed: () => _controller
..options.add(Option(optDesc: '', imgUrl: ''))
..updateCanCreate(),
style: FilledButton.styleFrom(
minimumSize: Size.zero,
padding: const EdgeInsets.only(
left: 10, right: 14, top: 4, bottom: 4),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
foregroundColor: theme.colorScheme.onSurfaceVariant,
backgroundColor: theme.colorScheme.onInverseSurface,
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add, size: 16),
Text(
' 添加选项',
style: TextStyle(fontSize: 13),
),
],
),
),
],
);
},
),
const SizedBox(height: 40),
Row(
spacing: 12,
children: [
SizedBox(
width: 100,
child: Text('单选/多选', style: _leadingStyle),
),
Obx(() {
final choiceCnt = _controller.choiceCnt.value;
final choices =
List.generate(_controller.options.length, (i) => i + 1);
return Listener(
onPointerDown: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
child: PopupMenuButton<int>(
initialValue: choiceCnt,
requestFocus: false,
child:
Text(choiceCnt == 1 ? '单选 ' : '最多选$choiceCnt项'),
onSelected: (value) => _controller.choiceCnt.value = value,
itemBuilder: (context) {
return choices
.map((e) => PopupMenuItem(
value: e,
child: Text(e == 1 ? '单选' : '最多选$e项'),
))
.toList();
},
),
);
}),
],
),
const SizedBox(height: 4),
...divider,
Row(
spacing: 12,
children: [
SizedBox(
width: 100,
child: Text('投票截止时间', style: _leadingStyle),
),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () async {
FocusManager.instance.primaryFocus?.unfocus();
DateTime? newDate = await showDatePicker(
context: context,
initialDate: _controller.endtime.value,
firstDate: _controller.now,
lastDate: _controller.end,
);
if (newDate != null && context.mounted) {
TimeOfDay? newTime = await showTimePicker(
context: context,
initialTime:
TimeOfDay.fromDateTime(_controller.endtime.value),
);
if (newTime != null) {
final newEndtime = DateTime(
newDate.year,
newDate.month,
newDate.day,
newTime.hour,
newTime.minute,
);
if (newEndtime.difference(DateTime.now()) >=
const Duration(minutes: 5)) {
_controller.endtime.value = newEndtime;
} else {
SmartDialog.showToast('至少选择5分钟之后');
}
}
}
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Obx(() => Text(
DateUtil.longFormatD.format(_controller.endtime.value),
)),
),
),
],
),
...divider,
const SizedBox(height: 40),
Obx(() {
final canCreate = _controller.canCreate.value;
return FilledButton.tonal(
onPressed: canCreate ? _controller.onCreate : null,
child: const Text('发起投票'),
);
}),
],
),
);
}
Widget _buildInput(
ThemeData theme, {
Key? key,
String? initialValue,
required ValueChanged<String> onChanged,
required String desc,
String? hintText,
List<TextInputFormatter>? inputFormatters,
bool showDel = false,
bool showImg = false,
String? imgUrl,
VoidCallback? onDel,
VoidCallback? onPickImg,
}) {
return Row(
spacing: 12,
children: [
SizedBox(
width: 65,
child: Text(
desc,
style: _leadingStyle,
),
),
Expanded(
child: TextFormField(
key: key,
initialValue: initialValue,
onChanged: onChanged,
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
hintText: hintText ?? desc,
hintStyle: TextStyle(
fontSize: 15,
color: theme.colorScheme.outline.withValues(alpha: 0.7),
),
),
inputFormatters: inputFormatters,
),
),
if (showImg)
GestureDetector(
onTap: onPickImg,
child: NetworkImgLayer(
src: imgUrl,
width: 40,
height: 40,
radius: 6,
),
),
if (showDel)
iconButton(
size: 26,
iconSize: 18,
tooltip: '移除',
context: context,
icon: Icons.clear,
onPressed: onDel,
bgColor: Colors.transparent,
iconColor: theme.colorScheme.onSurfaceVariant,
),
],
);
}
Widget _buildType(ThemeData theme) => Obx(
() {
return Row(
spacing: 16,
children: List.generate(
2,
(index) {
final isEnable = index == _controller.type.value;
final style = TextButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 17),
shape: RoundedRectangleBorder(
side: BorderSide(
color: isEnable
? theme.colorScheme.secondary
: theme.colorScheme.outline,
),
borderRadius: const BorderRadius.all(Radius.circular(6)),
),
backgroundColor: isEnable
? theme.colorScheme.secondaryContainer
: Colors.transparent,
foregroundColor: isEnable
? theme.colorScheme.onSecondaryContainer
: theme.colorScheme.onSurfaceVariant,
);
Widget child = TextButton(
style: style,
onPressed: () => _controller
..type.value = index
..updateCanCreate(),
child: Text(
'${const ['文字', '图片'][index]}投票',
strutStyle: const StrutStyle(forceStrutHeight: true),
),
);
if (isEnable) {
child = Stack(
clipBehavior: Clip.none,
children: [
child,
Positioned(
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.all(1),
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(4),
bottomRight: Radius.circular(6),
),
color: theme.colorScheme.primary,
),
child: Icon(
size: 10,
Icons.check,
color: theme.colorScheme.onPrimary,
),
),
),
],
);
}
return child;
},
),
);
},
);
void _onPickImg(int index) {
EasyThrottle.throttle('imagePicker', const Duration(milliseconds: 500),
() async {
try {
XFile? pickedFile = await imagePicker.pickImage(
imageQuality: 100,
source: ImageSource.gallery,
);
if (pickedFile != null) {
_controller.onUpload(index, pickedFile);
}
} catch (e) {
SmartDialog.showToast(e.toString());
}
});
}
}

View File

@@ -200,5 +200,7 @@ class _ReplyPageState extends CommonRichTextPubPageState<LiveSendDmPanel> {
}
@override
void onMention([bool fromClick = false]) {}
Future<void> onMention([bool fromClick = false]) {
return Future.value();
}
}

View File

@@ -267,7 +267,7 @@ class _ReplyPageState extends CommonRichTextPubPageState<ReplyPage> {
Future<void> onCustomPublish({List? pictures}) async {
Map<String, int> atNameToMid = {
for (var e in editController.items)
if (e.type == RichTextType.at) e.rawText: int.parse(e.uid!),
if (e.type == RichTextType.at) e.rawText: int.parse(e.id!),
};
String message = editController.rawText;
var result = await VideoHttp.replyAdd(

View File

@@ -426,9 +426,9 @@ class _SendDanmakuPanelState extends CommonTextPubPageState<SendDanmakuPanel> {
);
}
void _showColorPicker() {
Future<void> _showColorPicker() async {
controller.keepChatPanel();
showDialog(
await showDialog(
context: context,
builder: (context) => AlertDialog(
clipBehavior: Clip.hardEdge,
@@ -445,6 +445,7 @@ class _SendDanmakuPanelState extends CommonTextPubPageState<SendDanmakuPanel> {
),
),
);
controller.restoreChatPanel();
}
@override

View File

@@ -335,7 +335,9 @@ class _WhisperDetailPageState
}
@override
void onMention([bool fromClick = false]) {}
Future<void> onMention([bool fromClick = false]) {
return Future.value();
}
@override
void onSave() {}