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

@@ -47,6 +47,7 @@
## feat
- [x] 发起投票
- [x] 发布动态/评论支持`富文本编辑`/`表情显示`/`@用户`
- [x] 修改消息设置
- [x] 修改聊天设置

View File

@@ -26,7 +26,7 @@ import 'package:flutter/services.dart';
/// created by bggRGjQaUbCoE on 2025/6/27
///
enum RichTextType { text, composing, at, emoji }
enum RichTextType { text, composing, at, emoji, vote }
class Emote {
late String url;
@@ -43,20 +43,20 @@ class Emote {
mixin RichTextTypeMixin {
RichTextType get type;
Emote? get emote;
String? get uid;
String? get id;
String? get rawText;
}
extension TextEditingDeltaExt on TextEditingDelta {
({RichTextType type, String? rawText, Emote? emote, String? uid}) get config {
({RichTextType type, String? rawText, Emote? emote, String? id}) get config {
if (this case RichTextTypeMixin e) {
return (type: e.type, rawText: e.rawText, emote: e.emote, uid: e.uid);
return (type: e.type, rawText: e.rawText, emote: e.emote, id: e.id);
}
return (
type: composing.isValid ? RichTextType.composing : RichTextType.text,
rawText: null,
emote: null,
uid: null
id: null
);
}
@@ -82,7 +82,7 @@ class RichTextEditingDeltaInsertion extends TextEditingDeltaInsertion
required super.composing,
RichTextType? type,
this.emote,
this.uid,
this.id,
this.rawText,
}) {
this.type = type ??
@@ -96,7 +96,7 @@ class RichTextEditingDeltaInsertion extends TextEditingDeltaInsertion
final Emote? emote;
@override
final String? uid;
final String? id;
@override
final String? rawText;
@@ -112,7 +112,7 @@ class RichTextEditingDeltaReplacement extends TextEditingDeltaReplacement
required super.composing,
RichTextType? type,
this.emote,
this.uid,
this.id,
this.rawText,
}) {
this.type = type ??
@@ -126,7 +126,7 @@ class RichTextEditingDeltaReplacement extends TextEditingDeltaReplacement
final Emote? emote;
@override
final String? uid;
final String? id;
@override
final String? rawText;
@@ -138,7 +138,7 @@ class RichTextItem {
String? _rawText;
late TextRange range;
Emote? emote;
String? uid;
String? id;
String get rawText => _rawText ?? text;
@@ -146,7 +146,7 @@ class RichTextItem {
bool get isComposing => type == RichTextType.composing;
bool get isRich => type == RichTextType.at || type == RichTextType.emoji;
bool get isRich => !isText && !isComposing;
RichTextItem({
this.type = RichTextType.text,
@@ -154,7 +154,7 @@ class RichTextItem {
String? rawText,
required this.range,
this.emote,
this.uid,
this.id,
}) {
_rawText = rawText;
}
@@ -164,7 +164,7 @@ class RichTextItem {
String? rawText,
this.type = RichTextType.text,
this.emote,
this.uid,
this.id,
}) {
range = TextRange(start: 0, end: text.length);
_rawText = rawText;
@@ -198,7 +198,7 @@ class RichTextItem {
rawText: config.rawText,
type: config.type,
emote: config.emote,
uid: config.uid,
id: config.id,
);
return [insertedItem];
}
@@ -224,7 +224,7 @@ class RichTextItem {
final insertedItem = RichTextItem(
type: config.type,
emote: config.emote,
uid: config.uid,
id: config.id,
text: delta.textInserted,
rawText: config.rawText,
range: TextRange(start: insertionOffset, end: end),
@@ -251,7 +251,7 @@ class RichTextItem {
final insertedItem = RichTextItem(
type: config.type,
emote: config.emote,
uid: config.uid,
id: config.id,
text: delta.textInserted,
rawText: config.rawText,
range: TextRange(start: insertionOffset, end: insertEnd),
@@ -397,7 +397,7 @@ class RichTextItem {
final insertedItem = RichTextItem(
type: config.type,
emote: config.emote,
uid: config.uid,
id: config.id,
text: delta.replacementText,
rawText: config.rawText,
range: TextRange(
@@ -427,7 +427,7 @@ class RichTextItem {
text = delta.replacementText;
type = config.type;
emote = config.emote;
uid = config.uid;
id = config.id;
final end = range.start + text.length;
range = TextRange(start: range.start, end: end);
controller.newSelection = TextSelection.collapsed(offset: end);
@@ -441,7 +441,7 @@ class RichTextItem {
_rawText = config.rawText;
type = config.type;
emote = config.emote;
uid = config.uid;
id = config.id;
final end = range.start + text.length;
range = TextRange(start: range.start, end: end);
controller.newSelection = TextSelection.collapsed(offset: end);
@@ -476,7 +476,7 @@ class RichTextItem {
rawText: config.rawText,
type: config.type,
emote: config.emote,
uid: config.uid,
id: config.id,
range: TextRange(start: replacedRange.start, end: end),
);
controller.newSelection = TextSelection.collapsed(offset: end);
@@ -487,7 +487,7 @@ class RichTextItem {
final config = delta.config;
type = config.type;
emote = config.emote;
uid = config.uid;
id = config.id;
final end = range.start + text.length;
range = TextRange(start: range.start, end: end);
controller.newSelection = TextSelection.collapsed(offset: end);
@@ -523,7 +523,7 @@ class RichTextItem {
rawText: config.rawText,
type: config.type,
emote: config.emote,
uid: config.uid,
id: config.id,
range: TextRange(start: range.start, end: end),
);
controller.newSelection = TextSelection.collapsed(offset: end);
@@ -536,7 +536,7 @@ class RichTextItem {
final config = delta.config;
type = config.type;
emote = config.emote;
uid = config.uid;
id = config.id;
final end = range.start + text.length;
range = TextRange(start: range.start, end: end);
controller.newSelection = TextSelection.collapsed(offset: end);
@@ -622,7 +622,7 @@ class RichTextEditingController extends TextEditingController {
rawText: config.rawText,
type: config.type,
emote: config.emote,
uid: config.uid,
id: config.id,
),
);
newSelection =
@@ -732,7 +732,7 @@ class RichTextEditingController extends TextEditingController {
// break;
// }
// }
// debugPrint('isValid: $isValid');
// debugPrint('isValid: $isValid,,${text.length},,${plainText.length}');
// debugPrint('$items\n$selection');
return TextSpan(
@@ -777,6 +777,25 @@ class RichTextEditingController extends TextEditingController {
);
}
return TextSpan(text: e.text);
case RichTextType.vote:
richStyle ??= (style ?? const TextStyle())
.copyWith(color: Theme.of(context).colorScheme.primary);
return TextSpan(
children: [
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Icon(
Icons.bar_chart_rounded,
size: 22,
color: richStyle!.color,
),
),
TextSpan(
text: '${e.rawText} ',
style: richStyle,
),
],
);
}
}).toList(),
);

View File

@@ -917,4 +917,8 @@ class Api {
static const String spaceAudio = '/audio/music-service/web/song/upper';
static const String dynMention = '/x/polymer/web-dynamic/v1/mention/search';
static const String createVote = '/x/vote/create';
static const String updateVote = '/x/vote/update';
}

View File

@@ -510,4 +510,30 @@ class DynamicsHttp {
return Error(res.data['message']);
}
}
static Future<LoadingState<int?>> createVote(VoteInfo voteInfo) async {
final res = await Request().post(
Api.createVote,
queryParameters: {'csrf': Accounts.main.csrf},
data: {'vote_info': voteInfo.toJson()},
);
if (res.data['code'] == 0) {
return Success(res.data['data']?['vote_id']);
} else {
return Error(res.data['message']);
}
}
static Future<LoadingState<int?>> updateVote(VoteInfo voteInfo) async {
final res = await Request().post(
Api.updateVote,
queryParameters: {'csrf': Accounts.main.csrf},
data: {'vote_info': voteInfo.toJson()},
);
if (res.data['code'] == 0) {
return Success(res.data['data']?['vote_id']);
} else {
return Error(res.data['message']);
}
}
}

View File

@@ -168,7 +168,7 @@ class MsgHttp {
}
static Future uploadBfs({
dynamic path,
required String path,
String? category,
String? biz,
CancelToken? cancelToken,

View File

@@ -8,6 +8,17 @@ class SimpleVoteInfo {
int? voteId;
late int joinNum;
SimpleVoteInfo({
this.choiceCnt,
this.defaultShare,
this.desc,
this.endTime,
this.status,
this.uid,
this.voteId,
this.joinNum = 0,
});
SimpleVoteInfo.fromJson(Map<String, dynamic> json) {
choiceCnt = json['choice_cnt'];
defaultShare = json['default_share'];
@@ -29,6 +40,34 @@ class VoteInfo extends SimpleVoteInfo {
int? voterLevel;
String? face;
String? name;
// 0 文字, 1 图片
int? type;
int? votePublisher;
int? duration;
int? onlyFansLevel;
VoteInfo({
super.choiceCnt,
super.defaultShare,
super.desc,
super.endTime,
super.status,
super.uid,
super.voteId,
super.joinNum = 0,
this.title,
this.ctime,
this.myVotes,
List<Option>? options,
this.optionsCnt,
this.voterLevel,
this.face,
this.name,
this.type,
this.votePublisher,
this.duration,
this.onlyFansLevel,
}) : options = options ?? <Option>[];
VoteInfo.fromJson(Map<String, dynamic> json) : super.fromJson(json) {
title = json['title'];
@@ -42,22 +81,48 @@ class VoteInfo extends SimpleVoteInfo {
voterLevel = json['voter_level'];
face = json['face'];
name = json['name'];
type = json['type'];
votePublisher = json['vote_publisher'];
}
factory VoteInfo.fromSeparatedJson(Map<String, dynamic> json) {
return VoteInfo.fromJson(json['vote_info'])
..myVotes = (json['my_votes'] as List?)?.cast(); // voteInfo
}
Map<String, dynamic> toJson() => {
'title': title,
'desc': desc,
'type': type,
'choice_cnt': choiceCnt,
'duration': duration,
'options': options.map((e) => e.toJson()).toList(),
'only_fans_level': onlyFansLevel,
'vote_publisher': votePublisher,
if (voteId != null) 'vote_id': voteId,
};
}
class Option {
int? optidx;
String? optdesc;
int? optIdx;
String? optDesc;
late int cnt;
String? imgUrl;
Option({
this.optDesc,
this.imgUrl,
});
Option.fromJson(Map<String, dynamic> json) {
optidx = json['opt_idx'];
optdesc = json['opt_desc'];
optIdx = json['opt_idx'];
optDesc = json['opt_desc'];
cnt = json['cnt'] ?? 0;
imgUrl = json['img_url'];
}
Map<String, dynamic> toJson() => {
'opt_desc': optDesc,
'img_url': imgUrl,
};
}

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() {}

View File

@@ -4,6 +4,7 @@ import 'package:PiliPlus/pages/article_list/view.dart';
import 'package:PiliPlus/pages/blacklist/view.dart';
import 'package:PiliPlus/pages/danmaku_block/view.dart';
import 'package:PiliPlus/pages/dynamics/view.dart';
import 'package:PiliPlus/pages/dynamics_create_vote/view.dart';
import 'package:PiliPlus/pages/dynamics_detail/view.dart';
import 'package:PiliPlus/pages/dynamics_topic/view.dart';
import 'package:PiliPlus/pages/dynamics_topic_rcmd/view.dart';
@@ -186,6 +187,7 @@ class Routes {
CustomGetPage(name: '/msgLikeDetail', page: () => const LikeDetailPage()),
CustomGetPage(
name: '/liveDmBlockPage', page: () => const LiveDmBlockPage()),
CustomGetPage(name: '/createVote', page: () => const CreateVotePage()),
];
}

View File

@@ -85,14 +85,14 @@ extension BuildContextExt on BuildContext {
: const Color(0xFFD44E7D);
}
void imageView({
Future<void> imageView({
int initialPage = 0,
required List<SourceModel> imgList,
ValueChanged<int>? onDismissed,
int? quality,
}) {
bool isMemberPage = Get.currentRoute.startsWith('/member?');
Navigator.of(this).push(
return Navigator.of(this).push(
HeroDialogRoute(
builder: (context) => InteractiveviewerGallery(
sources: imgList,