diff --git a/lib/http/member.dart b/lib/http/member.dart index 012d73e2..c1f20b6f 100644 --- a/lib/http/member.dart +++ b/lib/http/member.dart @@ -370,7 +370,6 @@ class MemberHttp { }; return { 'status': false, - 'data': [], 'msg': errMap[res.data['code']] ?? res.data['message'], }; } @@ -407,9 +406,14 @@ class MemberHttp { } // 搜索用户动态 - static Future memberDynamicSearch({int? pn, int? ps, int? mid}) async { - var res = await Request().get(Api.memberDynamic, data: { - 'keyword': '海拔', + static Future memberDynamicSearch({ + int? pn, + int? ps, + int? mid, + required String keyword, + }) async { + var res = await Request().get(Api.memberDynamicSearch, data: { + 'keyword': keyword, 'mid': mid, 'pn': pn, 'ps': ps, @@ -418,12 +422,12 @@ class MemberHttp { if (res.data['code'] == 0) { return { 'status': true, - 'data': DynamicsDataModel.fromJson(res.data['data']), + 'data': res.data['data']['cards'], + 'count': res.data['data']['total'] }; } else { return { 'status': false, - 'data': [], 'msg': res.data['message'], }; } diff --git a/lib/models/dynamics/result.dart b/lib/models/dynamics/result.dart index 8793d626..c2a082d9 100644 --- a/lib/models/dynamics/result.dart +++ b/lib/models/dynamics/result.dart @@ -13,7 +13,7 @@ class DynamicsDataModel { DynamicsDataModel.fromJson(Map json) { hasMore = json['has_more']; items = json['items'] - .map((e) => DynamicItemModel.fromJson(e)) + ?.map((e) => DynamicItemModel.fromJson(e)) .toList(); offset = json['offset']; } diff --git a/lib/pages/member_search/controller.dart b/lib/pages/member_search/controller.dart index 69313a14..670c7427 100644 --- a/lib/pages/member_search/controller.dart +++ b/lib/pages/member_search/controller.dart @@ -1,27 +1,30 @@ +import 'package:PiliPalaX/http/loading_state.dart'; +import 'package:PiliPalaX/utils/extension.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:PiliPalaX/http/member.dart'; -import 'package:PiliPalaX/models/member/archive.dart'; -class MemberSearchController extends GetxController { - final ScrollController scrollController = ScrollController(); - Rx controller = TextEditingController().obs; - final FocusNode searchFocusNode = FocusNode(); - RxString searchKeyWord = ''.obs; - String hintText = '搜索'; - RxString loadingStatus = 'init'.obs; - RxString loadingText = '加载中...'.obs; - bool hasRequest = false; +class MemberSearchController extends GetxController + with GetSingleTickerProviderStateMixin { + final scrollController = ScrollController(); + late final tabController = TabController(vsync: this, length: 2); + final textEditingController = TextEditingController(); + final searchFocusNode = FocusNode(); + + RxBool hasData = false.obs; + late int mid; RxString uname = ''.obs; - int archivePn = 1; - int archiveCount = 0; - RxList archiveList = [].obs; - int dynamic_pn = 1; - RxList dynamicList = [].obs; - int ps = 30; + int archivePn = 1; + RxInt archiveCount = (-1).obs; + bool isEndArchive = false; + Rx archiveState = LoadingState.loading().obs; + + int dynamicPn = 1; + RxInt dynamicCount = (-1).obs; + bool isEndDynamic = false; + Rx dynamicState = LoadingState.loading().obs; @override void onInit() { @@ -32,69 +35,102 @@ class MemberSearchController extends GetxController { // 清空搜索 void onClear() { - if (searchKeyWord.value.isNotEmpty && controller.value.text != '') { - controller.value.clear(); - searchKeyWord.value = ''; + if (textEditingController.value.text.isNotEmpty) { + textEditingController.clear(); + hasData.value = false; + searchFocusNode.requestFocus(); } else { Get.back(); } } - void onChange(value) { - searchKeyWord.value = value; - } - // 提交搜索内容 void submit() { - loadingStatus.value = 'loading'; - if (hasRequest) { - archivePn = 1; - searchArchives(); + if (textEditingController.text.isNotEmpty) { + hasData.value = true; + + dynamicCount.value = -1; + refreshArchive(); + + archiveCount.value = -1; + refreshDynamic(); + } + } + + Future refreshDynamic() async { + dynamicPn = 1; + isEndDynamic = false; + dynamicState.value = LoadingState.loading(); + await searchDynamic(); + } + + Future refreshArchive() async { + archivePn = 1; + isEndArchive = false; + archiveState.value = LoadingState.loading(); + await searchArchives(); + } + + Future searchDynamic([bool isRefresh = true]) async { + if (isRefresh.not && isEndDynamic) return; + dynamic res = await MemberHttp.memberDynamicSearch( + mid: mid, + pn: dynamicPn, + ps: 30, + keyword: textEditingController.text, + ); + if (res['status']) { + if (isRefresh) { + dynamicCount.value = res['count']; + } + if (dynamicState.value is Success) { + res['data'].insertAll(0, (dynamicState.value as Success).response); + } + dynamicState.value = LoadingState.success(res['data']); + if (res['data'].length >= dynamicCount.value) { + isEndDynamic = true; + } + dynamicPn++; + } else { + dynamicState.value = LoadingState.error(res['msg']); } } // 搜索视频 - Future searchArchives({type = 'init'}) async { - if (type == 'onLoad' && loadingText.value == '没有更多了') { - return; - } - var res = await MemberHttp.memberArchive( + Future searchArchives([bool isRefresh = true]) async { + if (isRefresh.not && isEndArchive) return; + dynamic res = await MemberHttp.memberArchive( mid: mid, pn: archivePn, - keyword: controller.value.text, + keyword: textEditingController.text, order: 'pubdate', ); if (res['status']) { - if (type == 'init' || archivePn == 1) { - archiveList.value = res['data'].list.vlist; - } else { - archiveList.addAll(res['data'].list.vlist); + if (isRefresh) { + archiveCount.value = res['data'].page['count']; } - archiveCount = res['data'].page['count']; - if (archiveList.length == archiveCount) { - loadingText.value = '没有更多了'; + if (archiveState.value is Success) { + res['data'] + .list + .vlist + ?.insertAll(0, (archiveState.value as Success).response); } - archivePn += 1; - hasRequest = true; + archiveState.value = LoadingState.success(res['data'].list.vlist); + if (res['data'].list.vlist.length >= archiveCount.value) { + isEndArchive = true; + } + archivePn++; } else { - SmartDialog.showToast(res['msg']); + archiveState.value = LoadingState.error(res['msg']); } - // loadingStatus.value = 'finish'; - return res; - } - - // 搜索动态 - Future searchDynamic() async {} - - // - onLoad() { - searchArchives(type: 'onLoad'); } @override void onClose() { + textEditingController.dispose(); searchFocusNode.dispose(); scrollController.dispose(); + tabController.dispose(); super.onClose(); } } diff --git a/lib/pages/member_search/search_archive.dart b/lib/pages/member_search/search_archive.dart new file mode 100644 index 00000000..2c66f1bb --- /dev/null +++ b/lib/pages/member_search/search_archive.dart @@ -0,0 +1,76 @@ +import 'package:PiliPalaX/common/constants.dart'; +import 'package:PiliPalaX/common/widgets/loading_widget.dart'; +import 'package:PiliPalaX/common/widgets/refresh_indicator.dart'; +import 'package:PiliPalaX/common/widgets/video_card_h.dart'; +import 'package:PiliPalaX/http/loading_state.dart'; +import 'package:PiliPalaX/pages/member_search/controller.dart'; +import 'package:PiliPalaX/utils/grid.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get_state_manager/src/rx_flutter/rx_obx_widget.dart'; + +class SearchArchive extends StatelessWidget { + const SearchArchive({ + super.key, + required this.ctr, + }); + + final MemberSearchController ctr; + + @override + Widget build(BuildContext context) { + return Obx(() => _buildBody(context, ctr.archiveState.value)); + } + + Widget _buildBody(BuildContext context, LoadingState loadingState) { + return switch (loadingState) { + Loading() => loadingWidget, + Success() => (loadingState.response as List?)?.isNotEmpty == true + ? refreshIndicator( + onRefresh: () async { + await ctr.refreshArchive(); + }, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverPadding( + padding: EdgeInsets.only( + left: StyleString.safeSpace, + top: StyleString.safeSpace, + right: StyleString.safeSpace, + bottom: 92 + MediaQuery.paddingOf(context).bottom, + ), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithExtentAndRatio( + mainAxisSpacing: StyleString.safeSpace, + crossAxisSpacing: StyleString.safeSpace, + maxCrossAxisExtent: Grid.maxRowWidth * 2, + childAspectRatio: StyleString.aspectRatio * 2.4, + mainAxisExtent: 0, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index == loadingState.response.length - 1) { + ctr.searchArchives(false); + } + return VideoCardH( + videoItem: loadingState.response[index], + ); + }, + childCount: loadingState.response.length, + ), + ), + ), + ], + ), + ) + : errorWidget( + callback: ctr.refreshArchive, + ), + Error() => errorWidget( + errMsg: loadingState.errMsg, + callback: ctr.refreshArchive, + ), + LoadingState() => throw UnimplementedError(), + }; + } +} diff --git a/lib/pages/member_search/search_dynamic.dart b/lib/pages/member_search/search_dynamic.dart new file mode 100644 index 00000000..634ea4b5 --- /dev/null +++ b/lib/pages/member_search/search_dynamic.dart @@ -0,0 +1,130 @@ +import 'package:PiliPalaX/common/constants.dart'; +import 'package:PiliPalaX/common/widgets/loading_widget.dart'; +import 'package:PiliPalaX/common/widgets/refresh_indicator.dart'; +import 'package:PiliPalaX/http/loading_state.dart'; +import 'package:PiliPalaX/pages/member_search/controller.dart'; +import 'package:PiliPalaX/utils/grid.dart'; +import 'package:PiliPalaX/utils/storage.dart'; +import 'package:easy_debounce/easy_throttle.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:get/get_state_manager/src/rx_flutter/rx_obx_widget.dart'; +import 'package:waterfall_flow/waterfall_flow.dart'; + +class SearchDynamic extends StatelessWidget { + const SearchDynamic({ + super.key, + required this.ctr, + }); + + final MemberSearchController ctr; + + @override + Widget build(BuildContext context) { + return Obx(() => _buildBody(context, ctr.dynamicState.value)); + } + + Widget _buildBody(BuildContext context, LoadingState loadingState) { + bool dynamicsWaterfallFlow = GStorage.setting + .get(SettingBoxKey.dynamicsWaterfallFlow, defaultValue: true); + return switch (loadingState) { + Loading() => loadingWidget, + Success() => (loadingState.response as List?)?.isNotEmpty == true + ? refreshIndicator( + onRefresh: () async { + await ctr.refreshDynamic(); + }, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverPadding( + padding: EdgeInsets.only( + bottom: MediaQuery.paddingOf(context).bottom, + ), + sliver: dynamicsWaterfallFlow + ? SliverWaterfallFlow.extent( + maxCrossAxisExtent: Grid.maxRowWidth * 2, + crossAxisSpacing: StyleString.safeSpace, + mainAxisSpacing: StyleString.safeSpace, + lastChildLayoutTypeBuilder: (index) { + if (index == loadingState.response.length - 1) { + EasyThrottle.throttle('member_dynamics', + const Duration(milliseconds: 1000), () { + ctr.searchDynamic(false); + }); + } + return index == loadingState.response.length + ? LastChildLayoutType.foot + : LastChildLayoutType.none; + }, + children: (loadingState.response as List) + .map((item) => _buildItem(item)) + .toList(), + ) + : SliverCrossAxisGroup( + slivers: [ + const SliverFillRemaining(), + SliverConstrainedCrossAxis( + maxExtent: Grid.maxRowWidth * 2, + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index == + loadingState.response.length - 1) { + EasyThrottle.throttle('member_dynamics', + const Duration(milliseconds: 1000), + () { + ctr.searchDynamic(false); + }); + } + return _buildItem( + loadingState.response[index], + ); + }, + childCount: loadingState.response.length, + ), + ), + ), + const SliverFillRemaining(), + ], + ), + ), + ], + ), + ) + : errorWidget( + callback: ctr.refreshDynamic, + ), + Error() => errorWidget( + errMsg: loadingState.errMsg, + callback: ctr.refreshDynamic, + ), + LoadingState() => throw UnimplementedError(), + }; + } + + Widget _buildItem(item) { + return switch (item['desc']['type']) { + 2 => ListTile( + dense: true, + onTap: () {}, + title: const Text('动态'), + ), + 4 => ListTile( + dense: true, + onTap: () {}, + title: const Text('动态'), + ), + 8 => ListTile( + dense: true, + onTap: () {}, + title: const Text('视频'), + ), + _ => ListTile( + dense: true, + onTap: () {}, + title: Text('TODO: type: ${item['desc']['type']}'), + ), + }; + } +} diff --git a/lib/pages/member_search/view.dart b/lib/pages/member_search/view.dart index ff208d33..2bf2d3f1 100644 --- a/lib/pages/member_search/view.dart +++ b/lib/pages/member_search/view.dart @@ -1,12 +1,7 @@ -import 'package:easy_debounce/easy_throttle.dart'; +import 'package:PiliPalaX/pages/member_search/search_archive.dart'; +import 'package:PiliPalaX/pages/member_search/search_dynamic.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:PiliPalaX/common/skeleton/video_card_h.dart'; -import 'package:PiliPalaX/common/widgets/http_error.dart'; -import 'package:PiliPalaX/common/widgets/video_card_h.dart'; - -import '../../common/constants.dart'; -import '../../utils/grid.dart'; import 'controller.dart'; class MemberSearchPage extends StatefulWidget { @@ -16,166 +11,81 @@ class MemberSearchPage extends StatefulWidget { State createState() => _MemberSearchPageState(); } -class _MemberSearchPageState extends State - with SingleTickerProviderStateMixin { - final MemberSearchController _memberSearchCtr = - Get.put(MemberSearchController()); - - @override - void initState() { - super.initState(); - _memberSearchCtr.scrollController.addListener( - () { - if (_memberSearchCtr.scrollController.position.pixels >= - _memberSearchCtr.scrollController.position.maxScrollExtent - 300) { - EasyThrottle.throttle('history', const Duration(seconds: 1), () { - _memberSearchCtr.onLoad(); - }); - } - }, - ); - // _tabController = TabController(length: 2, vsync: this); - } - - @override - void dispose() { - // _tabController.dispose(); - _memberSearchCtr.scrollController.removeListener(() {}); - super.dispose(); - } +class _MemberSearchPageState extends State { + final _memberSearchCtr = Get.put(MemberSearchController()); @override Widget build(BuildContext context) { return Scaffold( - resizeToAvoidBottomInset: false, - appBar: AppBar( - actions: [ - IconButton( - tooltip: '搜索', - onPressed: () => _memberSearchCtr.submit(), - icon: const Icon(Icons.search, size: 22)), - const SizedBox(width: 10) - ], - title: Obx( - () => TextField( - autofocus: true, - focusNode: _memberSearchCtr.searchFocusNode, - controller: _memberSearchCtr.controller.value, - textInputAction: TextInputAction.search, - onChanged: (value) => _memberSearchCtr.onChange(value), - textAlignVertical: TextAlignVertical.center, - decoration: InputDecoration( - hintText: _memberSearchCtr.hintText, - border: InputBorder.none, - suffixIcon: IconButton( - tooltip: '清空', - icon: const Icon(Icons.clear, size: 22), - onPressed: () => _memberSearchCtr.onClear(), - ), - ), - onSubmitted: (String value) => _memberSearchCtr.submit(), + resizeToAvoidBottomInset: false, + appBar: AppBar( + actions: [ + IconButton( + tooltip: '搜索', + onPressed: _memberSearchCtr.submit, + icon: const Icon(Icons.search, size: 22), + ), + const SizedBox(width: 10) + ], + title: TextField( + autofocus: true, + focusNode: _memberSearchCtr.searchFocusNode, + controller: _memberSearchCtr.textEditingController, + textInputAction: TextInputAction.search, + textAlignVertical: TextAlignVertical.center, + decoration: InputDecoration( + hintText: '搜索', + border: InputBorder.none, + suffixIcon: IconButton( + tooltip: '清空', + icon: const Icon(Icons.clear, size: 22), + onPressed: _memberSearchCtr.onClear, ), ), + onSubmitted: (value) => _memberSearchCtr.submit(), + onChanged: (value) { + if (value.isEmpty) { + _memberSearchCtr.hasData.value = false; + } + }, ), - body: Obx( - () { - if (_memberSearchCtr.loadingStatus.value == 'init') { - return FractionallySizedBox( + ), + body: Obx( + () => _memberSearchCtr.hasData.value + ? Column( + children: [ + Obx( + () => TabBar( + controller: _memberSearchCtr.tabController, + tabs: [ + Tab( + text: + '视频 ${_memberSearchCtr.archiveCount.value != -1 ? '${_memberSearchCtr.archiveCount.value}' : ''}'), + Tab( + text: + '动态 ${_memberSearchCtr.dynamicCount.value != -1 ? '${_memberSearchCtr.dynamicCount.value}' : ''}'), + ], + ), + ), + Expanded( + child: TabBarView( + controller: _memberSearchCtr.tabController, + children: [ + SearchArchive(ctr: _memberSearchCtr), + SearchDynamic(ctr: _memberSearchCtr), + ], + ), + ), + ], + ) + : FractionallySizedBox( heightFactor: 0.5, widthFactor: 1.0, child: Center( child: Text('搜索「${_memberSearchCtr.uname.value}」的动态、视频'), ), - ); - } - return CustomScrollView( - physics: const AlwaysScrollableScrollPhysics(), - controller: _memberSearchCtr.scrollController, - slivers: [ - FutureBuilder( - future: _memberSearchCtr.searchArchives(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done && - snapshot.hasData) { - Map data = snapshot.data as Map; - if (data['status']) { - return SliverPadding( - padding: - const EdgeInsets.all(StyleString.safeSpace), - sliver: Obx( - () => _memberSearchCtr.archiveList.isNotEmpty - ? SliverGrid( - gridDelegate: - SliverGridDelegateWithExtentAndRatio( - mainAxisSpacing: StyleString - .safeSpace, - crossAxisSpacing: StyleString - .safeSpace, - maxCrossAxisExtent: Grid.maxRowWidth * - 2, - childAspectRatio: - StyleString.aspectRatio * 2.4, - mainAxisExtent: 0), - delegate: SliverChildBuilderDelegate( - (context, index) { - return VideoCardH( - videoItem: _memberSearchCtr - .archiveList[index]); - }, - childCount: _memberSearchCtr.archiveList - .length)) - : _memberSearchCtr.loadingStatus.value == - 'loading' - ? SliverGrid( - gridDelegate: - SliverGridDelegateWithExtentAndRatio( - mainAxisSpacing: - StyleString.cardSpace, - crossAxisSpacing: - StyleString.safeSpace, - maxCrossAxisExtent: - Grid.maxRowWidth * 2, - childAspectRatio: - StyleString.aspectRatio * - 2.1, - mainAxisExtent: 0), - delegate: SliverChildBuilderDelegate( - (context, index) { - return const VideoCardHSkeleton(); - }, childCount: 10)) - : HttpError( - callback: () => setState(() {}), - ), - )); - } else { - return HttpError( - errMsg: data['msg'], - callback: () => setState(() {}), - ); - } - } else { - // 骨架屏 - return SliverPadding( - padding: const EdgeInsets.all(StyleString.safeSpace), - sliver: SliverGrid( - gridDelegate: - SliverGridDelegateWithExtentAndRatio( - mainAxisSpacing: StyleString.cardSpace, - crossAxisSpacing: StyleString.safeSpace, - maxCrossAxisExtent: Grid.maxRowWidth * 2, - childAspectRatio: - StyleString.aspectRatio * 2.4, - mainAxisExtent: 0), - delegate: - SliverChildBuilderDelegate((context, index) { - return const VideoCardHSkeleton(); - }, childCount: 10))); - } - }, - ), - ], - ); - }, - )); + ), + ), + ); } }