feat: 评论增加表情
This commit is contained in:
@ -489,4 +489,7 @@ class Api {
|
||||
|
||||
/// 我的订阅详情
|
||||
static const userSubFolderDetail = '/x/space/fav/season/list';
|
||||
|
||||
/// 表情
|
||||
static const emojiList = '/x/emote/user/panel/web';
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import '../models/video/reply/data.dart';
|
||||
import '../models/video/reply/emote.dart';
|
||||
import 'api.dart';
|
||||
import 'init.dart';
|
||||
|
||||
@ -100,4 +101,23 @@ class ReplyHttp {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static Future getEmoteList({String? business}) async {
|
||||
var res = await Request().get(Api.emojiList, data: {
|
||||
'business': business ?? 'reply',
|
||||
'web_location': '333.1245',
|
||||
});
|
||||
if (res.data['code'] == 0) {
|
||||
return {
|
||||
'status': true,
|
||||
'data': EmoteModelData.fromJson(res.data['data']),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
'status': false,
|
||||
'date': [],
|
||||
'msg': res.data['message'],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
120
lib/models/video/reply/emote.dart
Normal file
120
lib/models/video/reply/emote.dart
Normal file
@ -0,0 +1,120 @@
|
||||
class EmoteModelData {
|
||||
final List<PackageItem>? packages;
|
||||
|
||||
EmoteModelData({
|
||||
required this.packages,
|
||||
});
|
||||
|
||||
factory EmoteModelData.fromJson(Map<String, dynamic> jsonRes) {
|
||||
final List<PackageItem>? packages =
|
||||
jsonRes['packages'] is List ? <PackageItem>[] : null;
|
||||
if (packages != null) {
|
||||
for (final dynamic item in jsonRes['packages']!) {
|
||||
if (item != null) {
|
||||
try {
|
||||
packages.add(PackageItem.fromJson(item));
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
return EmoteModelData(
|
||||
packages: packages,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PackageItem {
|
||||
final int? id;
|
||||
final String? text;
|
||||
final String? url;
|
||||
final int? mtime;
|
||||
final int? type;
|
||||
final int? attr;
|
||||
final Meta? meta;
|
||||
final List<Emote>? emote;
|
||||
|
||||
PackageItem({
|
||||
required this.id,
|
||||
required this.text,
|
||||
required this.url,
|
||||
required this.mtime,
|
||||
required this.type,
|
||||
required this.attr,
|
||||
required this.meta,
|
||||
required this.emote,
|
||||
});
|
||||
|
||||
factory PackageItem.fromJson(Map<String, dynamic> jsonRes) {
|
||||
final List<Emote>? emote = jsonRes['emote'] is List ? <Emote>[] : null;
|
||||
if (emote != null) {
|
||||
for (final dynamic item in jsonRes['emote']!) {
|
||||
if (item != null) {
|
||||
try {
|
||||
emote.add(Emote.fromJson(item));
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
return PackageItem(
|
||||
id: jsonRes['id'],
|
||||
text: jsonRes['text'],
|
||||
url: jsonRes['url'],
|
||||
mtime: jsonRes['mtime'],
|
||||
type: jsonRes['type'],
|
||||
attr: jsonRes['attr'],
|
||||
meta: Meta.fromJson(jsonRes['meta']),
|
||||
emote: emote,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Meta {
|
||||
final int? size;
|
||||
final List<String>? suggest;
|
||||
|
||||
Meta({
|
||||
required this.size,
|
||||
required this.suggest,
|
||||
});
|
||||
|
||||
factory Meta.fromJson(Map<String, dynamic> jsonRes) => Meta(
|
||||
size: jsonRes['size'],
|
||||
suggest: jsonRes['suggest'] is List ? <String>[] : null,
|
||||
);
|
||||
}
|
||||
|
||||
class Emote {
|
||||
final int? id;
|
||||
final int? packageId;
|
||||
final String? text;
|
||||
final String? url;
|
||||
final int? mtime;
|
||||
final int? type;
|
||||
final int? attr;
|
||||
final Meta? meta;
|
||||
final dynamic activity;
|
||||
|
||||
Emote({
|
||||
required this.id,
|
||||
required this.packageId,
|
||||
required this.text,
|
||||
required this.url,
|
||||
required this.mtime,
|
||||
required this.type,
|
||||
required this.attr,
|
||||
required this.meta,
|
||||
required this.activity,
|
||||
});
|
||||
|
||||
factory Emote.fromJson(Map<String, dynamic> jsonRes) => Emote(
|
||||
id: jsonRes['id'],
|
||||
packageId: jsonRes['package_id'],
|
||||
text: jsonRes['text'],
|
||||
url: jsonRes['url'],
|
||||
mtime: jsonRes['mtime'],
|
||||
type: jsonRes['type'],
|
||||
attr: jsonRes['attr'],
|
||||
meta: Meta.fromJson(jsonRes['meta']),
|
||||
activity: jsonRes['activity'],
|
||||
);
|
||||
}
|
20
lib/pages/emote/controller.dart
Normal file
20
lib/pages/emote/controller.dart
Normal file
@ -0,0 +1,20 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../../http/reply.dart';
|
||||
import '../../models/video/reply/emote.dart';
|
||||
|
||||
class EmotePanelController extends GetxController
|
||||
with GetTickerProviderStateMixin {
|
||||
late List<PackageItem> emotePackage;
|
||||
late TabController tabController;
|
||||
|
||||
Future getEmote() async {
|
||||
var res = await ReplyHttp.getEmoteList(business: 'reply');
|
||||
if (res['status']) {
|
||||
emotePackage = res['data'].packages;
|
||||
tabController = TabController(length: emotePackage.length, vsync: this);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
4
lib/pages/emote/index.dart
Normal file
4
lib/pages/emote/index.dart
Normal file
@ -0,0 +1,4 @@
|
||||
library emote;
|
||||
|
||||
export './controller.dart';
|
||||
export './view.dart';
|
116
lib/pages/emote/view.dart
Normal file
116
lib/pages/emote/view.dart
Normal file
@ -0,0 +1,116 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import '../../models/video/reply/emote.dart';
|
||||
import 'controller.dart';
|
||||
|
||||
class EmotePanel extends StatefulWidget {
|
||||
final Function onChoose;
|
||||
const EmotePanel({super.key, required this.onChoose});
|
||||
|
||||
@override
|
||||
State<EmotePanel> createState() => _EmotePanelState();
|
||||
}
|
||||
|
||||
class _EmotePanelState extends State<EmotePanel>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
final EmotePanelController _emotePanelController =
|
||||
Get.put(EmotePanelController());
|
||||
late Future _futureBuilderFuture;
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_futureBuilderFuture = _emotePanelController.getEmote();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return FutureBuilder(
|
||||
future: _futureBuilderFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.done) {
|
||||
Map data = snapshot.data as Map;
|
||||
if (data['status']) {
|
||||
List<PackageItem> emotePackage =
|
||||
_emotePanelController.emotePackage;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _emotePanelController.tabController,
|
||||
children: emotePackage.map(
|
||||
(e) {
|
||||
int size = e.emote!.first.meta!.size!;
|
||||
int type = e.type!;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 6, 12, 0),
|
||||
child: GridView.builder(
|
||||
gridDelegate:
|
||||
SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: size == 1 ? 40 : 60,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
),
|
||||
itemCount: e.emote!.length,
|
||||
itemBuilder: (context, index) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
widget.onChoose(e, e.emote![index]);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(3),
|
||||
child: type == 4
|
||||
? Text(
|
||||
e.emote![index].text!,
|
||||
overflow: TextOverflow.clip,
|
||||
maxLines: 1,
|
||||
)
|
||||
: Image.network(
|
||||
e.emote![index].url!,
|
||||
width: size * 38,
|
||||
height: size * 38,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
).toList(),
|
||||
)),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: Theme.of(context).dividerColor.withOpacity(0.1),
|
||||
),
|
||||
TabBar(
|
||||
controller: _emotePanelController.tabController,
|
||||
dividerColor: Colors.transparent,
|
||||
isScrollable: true,
|
||||
tabs: _emotePanelController.emotePackage
|
||||
.map((e) => Tab(text: e.text))
|
||||
.toList(),
|
||||
),
|
||||
SizedBox(height: MediaQuery.of(context).padding.bottom + 20),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Center(child: Text(data['msg']));
|
||||
}
|
||||
} else {
|
||||
return const Center(child: Text('加载中...'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
40
lib/pages/video/detail/reply_new/toolbar_icon_button.dart
Normal file
40
lib/pages/video/detail/reply_new/toolbar_icon_button.dart
Normal file
@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ToolbarIconButton extends StatelessWidget {
|
||||
final VoidCallback onPressed;
|
||||
final Icon icon;
|
||||
final String toolbarType;
|
||||
final bool selected;
|
||||
|
||||
const ToolbarIconButton({
|
||||
super.key,
|
||||
required this.onPressed,
|
||||
required this.icon,
|
||||
required this.toolbarType,
|
||||
required this.selected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: IconButton(
|
||||
onPressed: onPressed,
|
||||
icon: icon,
|
||||
highlightColor: Theme.of(context).colorScheme.secondaryContainer,
|
||||
color: selected
|
||||
? Theme.of(context).colorScheme.onSecondaryContainer
|
||||
: Theme.of(context).colorScheme.outline,
|
||||
style: ButtonStyle(
|
||||
padding: MaterialStateProperty.all(EdgeInsets.zero),
|
||||
backgroundColor: MaterialStateProperty.resolveWith((states) {
|
||||
return selected
|
||||
? Theme.of(context).colorScheme.secondaryContainer
|
||||
: null;
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -4,9 +4,13 @@ import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:pilipala/http/video.dart';
|
||||
import 'package:pilipala/models/common/reply_type.dart';
|
||||
import 'package:pilipala/models/video/reply/emote.dart';
|
||||
import 'package:pilipala/models/video/reply/item.dart';
|
||||
import 'package:pilipala/pages/emote/index.dart';
|
||||
import 'package:pilipala/utils/feed_back.dart';
|
||||
|
||||
import 'toolbar_icon_button.dart';
|
||||
|
||||
class VideoReplyNewDialog extends StatefulWidget {
|
||||
final int? oid;
|
||||
final int? root;
|
||||
@ -32,6 +36,10 @@ class _VideoReplyNewDialogState extends State<VideoReplyNewDialog>
|
||||
final TextEditingController _replyContentController = TextEditingController();
|
||||
final FocusNode replyContentFocusNode = FocusNode();
|
||||
final GlobalKey _formKey = GlobalKey<FormState>();
|
||||
late double emoteHeight = 0.0;
|
||||
double keyboardHeight = 0.0; // 键盘高度
|
||||
final _debouncer = Debouncer(milliseconds: 200); // 设置延迟时间
|
||||
String toolbarType = 'input';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -42,6 +50,8 @@ class _VideoReplyNewDialogState extends State<VideoReplyNewDialog>
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
// 自动聚焦
|
||||
_autoFocus();
|
||||
// 监听聚焦状态
|
||||
_focuslistener();
|
||||
}
|
||||
|
||||
_autoFocus() async {
|
||||
@ -51,6 +61,16 @@ class _VideoReplyNewDialogState extends State<VideoReplyNewDialog>
|
||||
}
|
||||
}
|
||||
|
||||
_focuslistener() {
|
||||
replyContentFocusNode.addListener(() {
|
||||
if (replyContentFocusNode.hasFocus) {
|
||||
setState(() {
|
||||
toolbarType = 'input';
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future submitReplyAdd() async {
|
||||
feedBack();
|
||||
String message = _replyContentController.text;
|
||||
@ -73,18 +93,49 @@ class _VideoReplyNewDialogState extends State<VideoReplyNewDialog>
|
||||
}
|
||||
}
|
||||
|
||||
void onChooseEmote(PackageItem package, Emote emote) {
|
||||
final int cursorPosition = _replyContentController.selection.baseOffset;
|
||||
final String currentText = _replyContentController.text;
|
||||
final String newText = currentText.substring(0, cursorPosition) +
|
||||
emote.text! +
|
||||
currentText.substring(cursorPosition);
|
||||
_replyContentController.value = TextEditingValue(
|
||||
text: newText,
|
||||
selection:
|
||||
TextSelection.collapsed(offset: cursorPosition + emote.text!.length),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
super.didChangeMetrics();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// 键盘高度
|
||||
final viewInsets = EdgeInsets.fromViewPadding(
|
||||
View.of(context).viewInsets, View.of(context).devicePixelRatio);
|
||||
_debouncer.run(() {
|
||||
if (mounted) {
|
||||
if (keyboardHeight == 0 && emoteHeight == 0) {
|
||||
setState(() {
|
||||
emoteHeight = keyboardHeight =
|
||||
keyboardHeight == 0.0 ? viewInsets.bottom : keyboardHeight;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_replyContentController.dispose();
|
||||
replyContentFocusNode.removeListener(() {});
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
double keyboardHeight = EdgeInsets.fromViewPadding(
|
||||
View.of(context).viewInsets, View.of(context).devicePixelRatio)
|
||||
.bottom;
|
||||
return Container(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
decoration: BoxDecoration(
|
||||
@ -137,27 +188,32 @@ class _VideoReplyNewDialogState extends State<VideoReplyNewDialog>
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
FocusScope.of(context)
|
||||
.requestFocus(replyContentFocusNode);
|
||||
},
|
||||
icon: Icon(Icons.keyboard,
|
||||
size: 22,
|
||||
color: Theme.of(context).colorScheme.onBackground),
|
||||
highlightColor:
|
||||
Theme.of(context).colorScheme.onInverseSurface,
|
||||
style: ButtonStyle(
|
||||
padding: MaterialStateProperty.all(EdgeInsets.zero),
|
||||
backgroundColor:
|
||||
MaterialStateProperty.resolveWith((states) {
|
||||
return Theme.of(context).highlightColor;
|
||||
}),
|
||||
),
|
||||
),
|
||||
ToolbarIconButton(
|
||||
onPressed: () {
|
||||
if (toolbarType == 'emote') {
|
||||
setState(() {
|
||||
toolbarType = 'input';
|
||||
});
|
||||
}
|
||||
FocusScope.of(context).requestFocus(replyContentFocusNode);
|
||||
},
|
||||
icon: const Icon(Icons.keyboard, size: 22),
|
||||
toolbarType: toolbarType,
|
||||
selected: toolbarType == 'input',
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
ToolbarIconButton(
|
||||
onPressed: () {
|
||||
if (toolbarType == 'input') {
|
||||
setState(() {
|
||||
toolbarType = 'emote';
|
||||
});
|
||||
}
|
||||
FocusScope.of(context).unfocus();
|
||||
},
|
||||
icon: const Icon(Icons.emoji_emotions, size: 22),
|
||||
toolbarType: toolbarType,
|
||||
selected: toolbarType == 'emote',
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
@ -170,7 +226,10 @@ class _VideoReplyNewDialogState extends State<VideoReplyNewDialog>
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: keyboardHeight,
|
||||
height: toolbarType == 'input' ? keyboardHeight : emoteHeight,
|
||||
child: EmotePanel(
|
||||
onChoose: (package, emote) => onChooseEmote(package, emote),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -178,3 +237,22 @@ class _VideoReplyNewDialogState extends State<VideoReplyNewDialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
typedef DebounceCallback = void Function();
|
||||
|
||||
class Debouncer {
|
||||
DebounceCallback? callback;
|
||||
final int? milliseconds;
|
||||
Timer? _timer;
|
||||
|
||||
Debouncer({this.milliseconds});
|
||||
|
||||
run(DebounceCallback callback) {
|
||||
if (_timer != null) {
|
||||
_timer!.cancel();
|
||||
}
|
||||
_timer = Timer(Duration(milliseconds: milliseconds!), () {
|
||||
callback();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user