feat: live search

Signed-off-by: bggRGjQaUbCoE <githubaccount56556@proton.me>
This commit is contained in:
bggRGjQaUbCoE
2025-05-06 20:31:09 +08:00
parent 867efecc54
commit 661e7bfa78
23 changed files with 835 additions and 68 deletions

View File

@@ -45,33 +45,21 @@ class BangumiCardVMemberHome extends StatelessWidget {
);
}),
),
bangumiContent(bangumiItem)
Padding(
padding: const EdgeInsets.fromLTRB(4, 5, 0, 3),
child: Text(
bangumiItem.title,
textAlign: TextAlign.start,
style: const TextStyle(
letterSpacing: 0.3,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
}
Widget bangumiContent(SpaceArchiveItem bangumiItem) {
return Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 5, 0, 3),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
bangumiItem.title,
textAlign: TextAlign.start,
style: const TextStyle(
letterSpacing: 0.3,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 1),
],
),
),
);
}

View File

@@ -52,29 +52,18 @@ class BangumiCardVSearch extends StatelessWidget {
);
}),
),
bagumiContent(context)
],
),
),
);
}
Widget bagumiContent(context) {
return Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 5, 0, 3),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title!.map((e) => e['text']).join(),
textAlign: TextAlign.start,
style: const TextStyle(
letterSpacing: 0.3,
Padding(
padding: const EdgeInsets.fromLTRB(4, 5, 0, 3),
child: Text(
item.title!.map((e) => e['text']).join(),
textAlign: TextAlign.start,
style: const TextStyle(
letterSpacing: 0.3,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
)
],
),
),

View File

@@ -37,6 +37,7 @@ class _LivePageState extends CommonPageState<LivePage, LiveController>
@override
Widget build(BuildContext context) {
super.build(context);
final ThemeData theme = Theme.of(context);
return Container(
clipBehavior: Clip.hardEdge,
margin: const EdgeInsets.only(
@@ -58,8 +59,8 @@ class _LivePageState extends CommonPageState<LivePage, LiveController>
bottom: MediaQuery.paddingOf(context).bottom + 80,
),
sliver: SliverMainAxisGroup(slivers: [
Obx(() => _buildTop(controller.topState.value)),
Obx(() => _buildBody(controller.loadingState.value)),
Obx(() => _buildTop(theme, controller.topState.value)),
Obx(() => _buildBody(theme, controller.loadingState.value)),
]),
),
],
@@ -68,11 +69,11 @@ class _LivePageState extends CommonPageState<LivePage, LiveController>
);
}
Widget _buildTop(Pair<LiveCardList?, LiveCardList?> data) {
Widget _buildTop(ThemeData theme, Pair<LiveCardList?, LiveCardList?> data) {
return SliverMainAxisGroup(
slivers: [
if (data.first != null)
SliverToBoxAdapter(child: _buildFollowList(data.first!)),
SliverToBoxAdapter(child: _buildFollowList(theme, data.first!)),
if (data.second?.cardData?.areaEntranceV3?.list?.isNotEmpty == true)
SliverToBoxAdapter(
child: Row(
@@ -94,12 +95,10 @@ class _LivePageState extends CommonPageState<LivePage, LiveController>
),
text: index == 0 ? '推荐' : '${item.title}',
bgColor: index == controller.areaIndex.value
? Theme.of(context).colorScheme.secondaryContainer
? theme.colorScheme.secondaryContainer
: Colors.transparent,
textColor: index == controller.areaIndex.value
? Theme.of(context)
.colorScheme
.onSecondaryContainer
? theme.colorScheme.onSecondaryContainer
: null,
onTap: (value) {
controller.onSelectArea(
@@ -133,7 +132,7 @@ class _LivePageState extends CommonPageState<LivePage, LiveController>
);
}
Widget _buildBody(LoadingState<List?> loadingState) {
Widget _buildBody(ThemeData theme, LoadingState<List?> loadingState) {
return switch (loadingState) {
Loading() => SliverGrid(
gridDelegate: SliverGridDelegateWithExtentAndRatio(
@@ -169,14 +168,10 @@ class _LivePageState extends CommonPageState<LivePage, LiveController>
),
text: '${item.name}',
bgColor: index == controller.tagIndex.value
? Theme.of(context)
.colorScheme
.secondaryContainer
? theme.colorScheme.secondaryContainer
: Colors.transparent,
textColor: index == controller.tagIndex.value
? Theme.of(context)
.colorScheme
.onSecondaryContainer
? theme.colorScheme.onSecondaryContainer
: null,
onTap: (value) {
controller.onSelectTag(
@@ -224,8 +219,7 @@ class _LivePageState extends CommonPageState<LivePage, LiveController>
};
}
Widget _buildFollowList(LiveCardList item) {
final theme = Theme.of(context);
Widget _buildFollowList(ThemeData theme, LiveCardList item) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,

View File

@@ -316,8 +316,8 @@ class _LiveAreaPageState extends State<LiveAreaPage> {
border: Border.all(
color: theme.colorScheme.outline,
),
borderRadius: const BorderRadius.all(Radius.circular(4)),
color: theme.colorScheme.surface,
borderRadius: const BorderRadius.all(Radius.circular(4)),
),
child: SearchText(
text: item.name!,

View File

@@ -3,6 +3,7 @@ import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/models/live/live_area_list/area_item.dart';
import 'package:PiliPlus/pages/live_area_detail/child/view.dart';
import 'package:PiliPlus/pages/live_area_detail/controller.dart';
import 'package:PiliPlus/pages/live_search/view.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
@@ -35,7 +36,7 @@ class _LiveAreaDetailPageState extends State<LiveAreaDetailPage> {
actions: [
IconButton(
onPressed: () {
// TODO: search
Get.to(const LiveSearchPage());
},
icon: const Icon(Icons.search),
),

View File

@@ -0,0 +1,55 @@
import 'package:PiliPlus/http/live.dart';
import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/models/common/live_search_type.dart';
import 'package:PiliPlus/models/live/live_search/data.dart';
import 'package:PiliPlus/pages/common/common_list_controller.dart';
import 'package:PiliPlus/pages/live_search/controller.dart';
import 'package:PiliPlus/utils/storage.dart';
class LiveSearchChildController
extends CommonListController<LiveSearchData, dynamic> {
LiveSearchChildController(this.controller, this.searchType);
final isLogin = Accounts.main.isLogin;
final LiveSearchController controller;
final LiveSearchType searchType;
@override
void checkIsEnd(int length) {
switch (searchType) {
case LiveSearchType.room:
if (controller.counts.first != -1 &&
length >= controller.counts.first) {
isEnd = true;
}
break;
case LiveSearchType.user:
if (controller.counts[1] != -1 && length >= controller.counts[1]) {
isEnd = true;
}
break;
}
}
@override
List? getDataList(response) {
switch (searchType) {
case LiveSearchType.room:
controller.counts[searchType.index] = response.room?.totalRoom ?? 0;
return response.room?.list;
case LiveSearchType.user:
controller.counts[searchType.index] = response.user?.totalUser ?? 0;
return response.user?.list;
}
}
@override
Future<LoadingState<LiveSearchData>> customGetData() {
return LiveHttp.liveSearch(
isLogin: isLogin,
page: currentPage,
keyword: controller.editingController.text,
type: searchType,
);
}
}

View File

@@ -0,0 +1,151 @@
import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/skeleton/msg_feed_top.dart';
import 'package:PiliPlus/common/skeleton/video_card_v.dart';
import 'package:PiliPlus/common/widgets/loading_widget/http_error.dart';
import 'package:PiliPlus/common/widgets/refresh_indicator.dart';
import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/models/common/live_search_type.dart';
import 'package:PiliPlus/pages/live_search/child/controller.dart';
import 'package:PiliPlus/pages/live_search/widgets/live_search_room.dart';
import 'package:PiliPlus/pages/live_search/widgets/live_search_user.dart';
import 'package:PiliPlus/utils/grid.dart';
import 'package:PiliPlus/utils/storage.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class LiveSearchChildPage extends StatefulWidget {
const LiveSearchChildPage({
super.key,
required this.controller,
required this.searchType,
});
final LiveSearchChildController controller;
final LiveSearchType searchType;
@override
State<LiveSearchChildPage> createState() => _LiveSearchChildPageState();
}
class _LiveSearchChildPageState extends State<LiveSearchChildPage>
with AutomaticKeepAliveClientMixin {
late final bool dynamicsWaterfallFlow = GStorage.setting
.get(SettingBoxKey.dynamicsWaterfallFlow, defaultValue: true);
LiveSearchChildController get _controller => widget.controller;
@override
Widget build(BuildContext context) {
super.build(context);
double padding = widget.searchType == LiveSearchType.room ? 12 : 0;
return refreshIndicator(
onRefresh: _controller.onRefresh,
child: CustomScrollView(
controller: _controller.scrollController,
slivers: [
SliverPadding(
padding: EdgeInsets.only(
top: padding,
left: padding,
right: padding,
bottom: MediaQuery.paddingOf(context).bottom + 80,
),
sliver: Obx(() => _buildBody(_controller.loadingState.value)),
),
],
),
);
}
Widget get _buildLoading {
return switch (widget.searchType) {
LiveSearchType.room => SliverGrid(
gridDelegate: SliverGridDelegateWithExtentAndRatio(
mainAxisSpacing: StyleString.cardSpace,
crossAxisSpacing: StyleString.cardSpace,
maxCrossAxisExtent: Grid.smallCardWidth,
childAspectRatio: StyleString.aspectRatio,
mainAxisExtent: MediaQuery.textScalerOf(context).scale(90),
),
delegate: SliverChildBuilderDelegate(
(context, index) {
return const VideoCardVSkeleton();
},
childCount: 10,
),
),
LiveSearchType.user => SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: Grid.smallCardWidth * 2,
mainAxisExtent: 60,
),
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return const MsgFeedTopSkeleton();
},
childCount: 12,
),
),
};
}
Widget _buildBody(LoadingState<List?> loadingState) {
return switch (loadingState) {
Loading() => _buildLoading,
Success() => loadingState.response?.isNotEmpty == true
? Builder(
builder: (context) {
return switch (widget.searchType) {
LiveSearchType.room => SliverGrid(
gridDelegate: SliverGridDelegateWithExtentAndRatio(
mainAxisSpacing: StyleString.cardSpace,
crossAxisSpacing: StyleString.cardSpace,
maxCrossAxisExtent: Grid.smallCardWidth,
childAspectRatio: StyleString.aspectRatio,
mainAxisExtent:
MediaQuery.textScalerOf(context).scale(60),
),
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index == loadingState.response!.length - 1) {
_controller.onLoadMore();
}
return LiveCardVSearch(
item: loadingState.response![index],
);
},
childCount: loadingState.response!.length,
),
),
LiveSearchType.user => SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: Grid.smallCardWidth * 2,
mainAxisExtent: 60,
),
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
if (index == loadingState.response!.length - 1) {
_controller.onLoadMore();
}
return LiveSearchUserItem(
item: loadingState.response![index],
);
},
childCount: loadingState.response!.length,
),
),
};
},
)
: HttpError(
onReload: _controller.onReload,
),
Error() => HttpError(
errMsg: loadingState.errMsg,
onReload: _controller.onReload,
),
};
}
@override
bool get wantKeepAlive => true;
}

View File

@@ -0,0 +1,63 @@
import 'package:PiliPlus/models/common/live_search_type.dart';
import 'package:PiliPlus/pages/live_search/child/controller.dart';
import 'package:PiliPlus/utils/extension.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class LiveSearchController extends GetxController
with GetSingleTickerProviderStateMixin {
late final tabController = TabController(vsync: this, length: 2);
final editingController = TextEditingController();
final focusNode = FocusNode();
final mid = Get.parameters['mid'];
final uname = Get.parameters['uname'];
final RxBool hasData = false.obs;
final RxList<int> counts = <int>[-1, -1].obs;
late final roomCtr = Get.put(
LiveSearchChildController(this, LiveSearchType.room),
tag: Utils.generateRandomString(8));
late final userCtr = Get.put(
LiveSearchChildController(this, LiveSearchType.user),
tag: Utils.generateRandomString(8));
void onClear() {
if (editingController.value.text.isNotEmpty) {
editingController.clear();
counts.value = <int>[-1, -1];
hasData.value = false;
focusNode.requestFocus();
} else {
Get.back();
}
}
late final regex = RegExp(r'^\d+$');
void submit() {
if (editingController.text.isNotEmpty) {
if (regex.hasMatch(editingController.text)) {
Get.toNamed('/liveRoom?roomid=${editingController.text}');
} else {
hasData.value = true;
roomCtr
..scrollController.jumpToTop()
..onReload();
userCtr
..scrollController.jumpToTop()
..onReload();
}
}
}
@override
void onClose() {
editingController.dispose();
focusNode.dispose();
tabController.dispose();
super.onClose();
}
}

View File

@@ -0,0 +1,103 @@
import 'package:PiliPlus/common/widgets/scroll_physics.dart';
import 'package:PiliPlus/models/common/live_search_type.dart';
import 'package:PiliPlus/pages/live_search/child/view.dart';
import 'package:PiliPlus/pages/live_search/controller.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class LiveSearchPage extends StatefulWidget {
const LiveSearchPage({super.key});
@override
State<LiveSearchPage> createState() => _LiveSearchPageState();
}
class _LiveSearchPageState extends State<LiveSearchPage> {
final _controller =
Get.put(LiveSearchController(), tag: Utils.generateRandomString(8));
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
actions: [
IconButton(
tooltip: '搜索',
onPressed: _controller.submit,
icon: const Icon(Icons.search, size: 22),
),
const SizedBox(width: 10)
],
title: TextField(
autofocus: true,
focusNode: _controller.focusNode,
controller: _controller.editingController,
textInputAction: TextInputAction.search,
textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration(
hintText: '搜索房间或主播',
border: InputBorder.none,
suffixIcon: IconButton(
tooltip: '清空',
icon: const Icon(Icons.clear, size: 22),
onPressed: _controller.onClear,
),
),
onSubmitted: (value) => _controller.submit(),
onChanged: (value) {
if (value.isEmpty) {
_controller.hasData.value = false;
}
},
),
),
body: SafeArea(
top: false,
bottom: false,
child: Obx(() {
return Opacity(
opacity: _controller.hasData.value ? 1 : 0,
child: Column(
children: [
TabBar(
controller: _controller.tabController,
tabs: [
Obx(
() => Tab(
text:
'正在直播 ${_controller.counts[0] != -1 ? _controller.counts[0] : ''}',
),
),
Obx(
() => Tab(
text:
'主播 ${_controller.counts[1] != -1 ? _controller.counts[1] : ''}',
),
),
],
),
Expanded(
child: tabBarView(
controller: _controller.tabController,
children: [
LiveSearchChildPage(
controller: _controller.roomCtr,
searchType: LiveSearchType.room,
),
LiveSearchChildPage(
controller: _controller.userCtr,
searchType: LiveSearchType.user,
),
],
),
),
],
),
);
}),
),
);
}
}

View File

@@ -0,0 +1,114 @@
import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/widgets/image/image_save.dart';
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
import 'package:PiliPlus/models/live/live_search/room_item.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
// 视频卡片 - 垂直布局
class LiveCardVSearch extends StatelessWidget {
final LiveSearchRoomItemModel item;
const LiveCardVSearch({
super.key,
required this.item,
});
@override
Widget build(BuildContext context) {
String heroTag = Utils.makeHeroTag(item.roomid);
return Card(
clipBehavior: Clip.hardEdge,
margin: EdgeInsets.zero,
child: InkWell(
onTap: () {
Get.toNamed('/liveRoom?roomid=${item.roomid}');
},
onLongPress: () => imageSaveDialog(
title: item.title,
cover: item.cover,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: StyleString.aspectRatio,
child: LayoutBuilder(builder: (context, boxConstraints) {
double maxWidth = boxConstraints.maxWidth;
double maxHeight = boxConstraints.maxHeight;
return Stack(
clipBehavior: Clip.none,
children: [
Hero(
tag: heroTag,
child: NetworkImgLayer(
src: item.cover!,
width: maxWidth,
height: maxHeight,
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: AnimatedOpacity(
opacity: 1,
duration: const Duration(milliseconds: 200),
child: videoStat(context),
),
),
],
);
}),
),
Padding(
padding: const EdgeInsets.fromLTRB(5, 8, 5, 4),
child: Text(
'${item.title}',
textAlign: TextAlign.start,
style: const TextStyle(
letterSpacing: 0.3,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
Widget videoStat(context) {
return Container(
height: 50,
padding: const EdgeInsets.only(top: 26, left: 10, right: 10),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[
Colors.transparent,
Colors.black54,
],
tileMode: TileMode.mirror,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${item.name}',
style: const TextStyle(fontSize: 11, color: Colors.white),
),
if (item.watchedShow?.textSmall != null)
Text(
'${Utils.numFormat(item.watchedShow!.textSmall)}围观',
style: const TextStyle(fontSize: 11, color: Colors.white),
),
],
),
);
}
}

View File

@@ -0,0 +1,66 @@
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
import 'package:PiliPlus/models/live/live_search/user_item.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class LiveSearchUserItem extends StatelessWidget {
const LiveSearchUserItem({
super.key,
required this.item,
});
final LiveSearchUserItemModel item;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final style = TextStyle(
fontSize: 13,
color: theme.colorScheme.outline,
);
return InkWell(
onTap: () => Get.toNamed(
'/liveRoom?roomid=${item.roomid}',
),
child: Row(
children: [
const SizedBox(width: 15),
NetworkImgLayer(
src: item.face,
width: 42,
height: 42,
type: 'avatar',
),
const SizedBox(width: 10),
Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Text(
item.name!,
style: const TextStyle(
fontSize: 14,
),
),
if (item.liveStatus == 1) ...[
const SizedBox(width: 10),
Image.asset(height: 14, 'assets/images/live/live.gif'),
],
],
),
const SizedBox(height: 2),
Text(
'分区: ${item.areaName ?? ''} 关注数: ${Utils.numFormat(item.fansNum ?? 0)}',
style: style,
),
],
)
],
),
);
}
}

View File

@@ -50,7 +50,9 @@ class _MemberArticleState extends State<MemberArticle>
slivers: [
SliverPadding(
padding: EdgeInsets.only(
bottom: MediaQuery.paddingOf(context).bottom + 80),
top: 7,
bottom: MediaQuery.paddingOf(context).bottom + 80,
),
sliver: SliverGrid(
gridDelegate: Grid.videoCardHDelegate(context),
delegate: SliverChildBuilderDelegate(

View File

@@ -1,5 +1,6 @@
import 'package:PiliPlus/models/common/member/search_type.dart';
import 'package:PiliPlus/pages/member_search/child/controller.dart';
import 'package:PiliPlus/utils/extension.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
@@ -37,8 +38,12 @@ class MemberSearchController extends GetxController
void submit() {
if (editingController.text.isNotEmpty) {
hasData.value = true;
arcCtr.onReload();
dynCtr.onReload();
arcCtr
..scrollController.jumpToTop()
..onReload();
dynCtr
..scrollController.jumpToTop()
..onReload();
}
}