diff --git a/assets/i18n/en.json b/assets/i18n/en.json index df255d30..42ee0996 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -7,6 +7,7 @@ "settings": "Settings", "no-extension": "No installed extensions", "no-result": "No relevant results", + "no-more-data": "No more data", "cancel": "Cancel", "confirm": "Confirm", "close": "Close", diff --git a/assets/i18n/zh.json b/assets/i18n/zh.json index f9eed7ef..eb73ce2e 100644 --- a/assets/i18n/zh.json +++ b/assets/i18n/zh.json @@ -12,6 +12,7 @@ "settings": "设置", "no-extension": "未安装任何扩展", "no-result": "未找到相关结果", + "no-more-data": "没有更多数据了", "cancel": "取消", "confirm": "确定", "close": "关闭", diff --git a/lib/pages/detail/controller.dart b/lib/pages/detail/controller.dart index e9f5d4f0..863a77f2 100644 --- a/lib/pages/detail/controller.dart +++ b/lib/pages/detail/controller.dart @@ -108,7 +108,6 @@ class DetailPageController extends GetxController { ); showPlatformSnackbar( context: cuurentContext, - title: '', content: content, severity: fluent.InfoBarSeverity.error, ); @@ -186,7 +185,6 @@ class DetailPageController extends GetxController { } catch (e) { showPlatformSnackbar( context: cuurentContext, - title: '', content: e.toString().split('\n')[0], severity: fluent.InfoBarSeverity.error, ); @@ -205,7 +203,6 @@ class DetailPageController extends GetxController { if (runtime.value == null) { showPlatformSnackbar( context: cuurentContext, - title: '', content: FlutterI18n.translate( cuurentContext, 'common.extension-missing', diff --git a/lib/pages/detail/view.dart b/lib/pages/detail/view.dart index 045d1d12..95111cda 100644 --- a/lib/pages/detail/view.dart +++ b/lib/pages/detail/view.dart @@ -272,9 +272,13 @@ class _DetailPageState extends State { Obx( () { if (c.tmdbDetail == null || - c.tmdbDetail!.images.isEmpty) { + c.tmdbDetail!.backdrop == null) { return const SizedBox(); } + final images = [ + c.tmdbDetail!.backdrop!, + ...c.tmdbDetail!.images + ]; return fluent.Padding( padding: const EdgeInsets.only(bottom: 16), child: CardTile( @@ -284,7 +288,7 @@ class _DetailPageState extends State { child: ListView.builder( scrollDirection: Axis.horizontal, itemBuilder: (context, index) { - final image = c.tmdbDetail!.images[index]; + final image = images[index]; final url = TmdbApi.getImageUrl(image); if (url == null) { return const SizedBox(); @@ -301,7 +305,7 @@ class _DetailPageState extends State { ), ); }, - itemCount: c.tmdbDetail!.images.length, + itemCount: images.length, ), ), ), diff --git a/lib/pages/detail/widgets/detail_overview.dart b/lib/pages/detail/widgets/detail_overview.dart index 2abaf9fa..535b9e87 100644 --- a/lib/pages/detail/widgets/detail_overview.dart +++ b/lib/pages/detail/widgets/detail_overview.dart @@ -22,9 +22,10 @@ class DetailOverView extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Obx(() { - if (c.tmdbDetail == null || c.tmdbDetail!.images.isEmpty) { + if (c.tmdbDetail == null || c.tmdbDetail!.backdrop == null) { return const SizedBox(); } + final images = [c.tmdbDetail!.backdrop!, ...c.tmdbDetail!.images]; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -41,7 +42,7 @@ class DetailOverView extends StatelessWidget { child: ListView.builder( scrollDirection: Axis.horizontal, itemBuilder: (context, index) { - final image = c.tmdbDetail!.images[index]; + final image = images[index]; final url = TmdbApi.getImageUrl(image); if (url == null) { return const SizedBox(); @@ -58,7 +59,7 @@ class DetailOverView extends StatelessWidget { ), ); }, - itemCount: c.tmdbDetail!.images.length, + itemCount: images.length, ), ), const SizedBox(height: 20), @@ -68,6 +69,9 @@ class DetailOverView extends StatelessWidget { Obx( () => SelectableText( c.tmdbDetail?.overview ?? c.detail?.desc ?? '', + style: const TextStyle( + height: 2, + ), ), ), const SizedBox(height: 20), diff --git a/lib/pages/search/pages/search_extension.dart b/lib/pages/search/pages/search_extension.dart index 9ce3afed..b5eb5839 100644 --- a/lib/pages/search/pages/search_extension.dart +++ b/lib/pages/search/pages/search_extension.dart @@ -1,12 +1,14 @@ import 'package:fluent_ui/fluent_ui.dart' as fluent; import 'package:flutter/material.dart'; import 'package:flutter_i18n/flutter_i18n.dart'; +import 'package:miru_app/models/index.dart'; import 'package:miru_app/utils/extension.dart'; import 'package:miru_app/utils/extension_runtime.dart'; import 'package:miru_app/utils/i18n.dart'; import 'package:miru_app/widgets/extension_item_card.dart'; +import 'package:miru_app/widgets/infinite_scroller.dart'; +import 'package:miru_app/widgets/messenger.dart'; import 'package:miru_app/widgets/platform_widget.dart'; -import 'package:miru_app/widgets/progress_ring.dart'; class SearchExtensionPage extends fluent.StatefulWidget { const SearchExtensionPage({ @@ -26,6 +28,55 @@ class _SearchExtensionPageState extends fluent.State { late ExtensionRuntime _runtime; late String _keyWord = widget.keyWord ?? ''; bool _showSearh = false; + final List _data = []; + int _page = 1; + bool _isLoding = true; + + Future _onRefresh() async { + setState(() { + _page = 1; + _data.clear(); + }); + await _onLoad(); + } + + Future _onLoad() async { + try { + _isLoding = true; + setState(() {}); + late List data; + if (_keyWord.isEmpty) { + data = await _runtime.latest(_page); + } else { + data = await _runtime.search(_keyWord, _page); + } + if (data.isEmpty && mounted) { + showPlatformSnackbar( + context: context, + content: "common.no-more-data".i18n, + severity: fluent.InfoBarSeverity.warning, + ); + } + _data.addAll(data); + _page++; + } catch (e) { + showPlatformSnackbar( + context: context, + content: e.toString(), + severity: fluent.InfoBarSeverity.error, + ); + } finally { + _isLoding = false; + if (mounted) { + setState(() {}); + } + } + } + + _onSearch(String keyWord) { + _keyWord = keyWord; + _onRefresh(); + } Widget _buildAndroid(BuildContext context) { return Scaffold( @@ -41,16 +92,10 @@ class _SearchExtensionPageState extends fluent.State { ), onChanged: (value) { if (value.isEmpty) { - setState(() { - _keyWord = value; - }); + _onSearch(value); } }, - onSubmitted: (value) { - setState(() { - _keyWord = value; - }); - }, + onSubmitted: _onSearch, ) : Text( _runtime.extension.name, @@ -69,67 +114,50 @@ class _SearchExtensionPageState extends fluent.State { ), ], ), - body: FutureBuilder( - key: ValueKey(_keyWord), - future: _keyWord.isEmpty - ? _runtime.latest(1) - : _runtime.search(_keyWord, 1), - builder: ((context, snapshot) { - if (snapshot.hasError) { - return Center( - child: Text("${snapshot.error}"), - ); - } - - if (!snapshot.hasData) { - return const SizedBox( - height: 300, - child: Center( - child: ProgressRing(), - ), - ); - } - final data = snapshot.data; - - if (data != null && data.isEmpty) { - return Center( - child: Text("common.no-result".i18n), - ); - } - return LayoutBuilder( - builder: (context, constraints) => GridView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: constraints.maxWidth ~/ 120, - childAspectRatio: 0.7, - crossAxisSpacing: 16, - mainAxisSpacing: 16, - ), - itemCount: data!.length, - itemBuilder: (context, index) { - final item = data[index]; - return ExtensionItemCard( - title: item.title, - url: item.url, - package: widget.package, - cover: item.cover, - update: item.update, - ); - }, + body: InfiniteScroller( + onRefresh: _onRefresh, + onLoad: _onLoad, + child: LayoutBuilder( + builder: (context, constraints) => GridView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: constraints.maxWidth ~/ 120, + childAspectRatio: 0.7, + crossAxisSpacing: 16, + mainAxisSpacing: 16, ), - ); - }), + itemCount: _data.length, + itemBuilder: (context, index) { + final item = _data[index]; + return ExtensionItemCard( + title: item.title, + url: item.url, + package: widget.package, + cover: item.cover, + update: item.update, + ); + }, + ), + ), ), ); } Widget _buildDesktop(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(left: 16, right: 16, top: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_isLoding) + const SizedBox( + height: 4, + width: double.infinity, + child: fluent.ProgressBar(), + ) + else + const SizedBox(height: 4), + fluent.Padding( + padding: const EdgeInsets.only(left: 16, right: 16, top: 16), + child: Row( children: [ Expanded( flex: 2, @@ -147,78 +175,46 @@ class _SearchExtensionPageState extends fluent.State { ), onChanged: (value) { if (value.isEmpty) { - setState(() { - _keyWord = value; - }); + _onSearch(value); } }, - onSubmitted: (value) { - setState(() { - _keyWord = value; - }); - }, + onSubmitted: _onSearch, placeholder: 'search.hint-text'.i18n, ), ) ], ), - const SizedBox(height: 16), - Expanded( - child: FutureBuilder( - key: ValueKey(_keyWord), - future: _keyWord.isEmpty - ? _runtime.latest(1) - : _runtime.search(_keyWord, 1), - builder: ((context, snapshot) { - if (snapshot.hasError) { - return Center( - child: Text( - snapshot.error.toString(), + ), + const SizedBox(height: 16), + Expanded( + child: InfiniteScroller( + onRefresh: _onRefresh, + onLoad: _onLoad, + child: LayoutBuilder( + builder: ((context, constraints) => GridView.builder( + padding: const EdgeInsets.all(16), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: constraints.maxWidth ~/ 160, + childAspectRatio: 0.6, + crossAxisSpacing: 16, + mainAxisSpacing: 16, ), - ); - } - - if (!snapshot.hasData) { - return const Center( - child: CircularProgressIndicator(), - ); - } - - final data = snapshot.data; - - if (data == null) { - return const Center( - child: Text('No data'), - ); - } - - return LayoutBuilder( - builder: ((context, constraints) => GridView.builder( - padding: const EdgeInsets.all(8), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: constraints.maxWidth ~/ 160, - childAspectRatio: 0.6, - crossAxisSpacing: 16, - mainAxisSpacing: 16, - ), - itemCount: data.length, - itemBuilder: (context, index) { - final item = data[index]; - return ExtensionItemCard( - title: item.title, - url: item.url, - package: widget.package, - cover: item.cover, - update: item.update, - ); - }, - )), - ); - }), + itemCount: _data.length, + itemBuilder: (context, index) { + final item = _data[index]; + return ExtensionItemCard( + title: item.title, + url: item.url, + package: widget.package, + cover: item.cover, + update: item.update, + ); + }, + )), ), - ) - ], - ), + ), + ) + ], ); } diff --git a/lib/widgets/infinite_scroller.dart b/lib/widgets/infinite_scroller.dart new file mode 100644 index 00000000..41dea6fb --- /dev/null +++ b/lib/widgets/infinite_scroller.dart @@ -0,0 +1,96 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:miru_app/widgets/platform_widget.dart'; +import 'package:easy_refresh/easy_refresh.dart'; + +class InfiniteScroller extends StatefulWidget { + const InfiniteScroller({ + Key? key, + required this.child, + required this.onRefresh, + required this.onLoad, + this.refreshOnStart = true, + this.enableInfiniteScroll = true, + }) : super(key: key); + + final Widget child; + final Future Function() onRefresh; + final Future Function() onLoad; + final bool refreshOnStart; + final bool enableInfiniteScroll; + + @override + State createState() => _InfiniteScrollerState(); +} + +class _InfiniteScrollerState extends State { + bool _isLoding = false; + + @override + void initState() { + if (!Platform.isAndroid && widget.refreshOnStart) { + _onRefresh(); + } + super.initState(); + } + + _onRefresh() async { + await Future.delayed(const Duration(milliseconds: 100)); + widget.onRefresh(); + } + + void _onScroll(ScrollMetrics metrics) { + if (metrics.atEdge && metrics.pixels == metrics.maxScrollExtent) { + if (_isLoding || !widget.enableInfiniteScroll) { + return; + } + widget.onLoad().then((_) { + if (mounted) { + setState(() { + _isLoding = false; + }); + } + }); + } + } + + Widget _buildAndroid(BuildContext context) { + return EasyRefresh( + onRefresh: widget.onRefresh, + header: const ClassicHeader( + processedDuration: Duration.zero, + showMessage: false, + showText: false, + ), + footer: const ClassicFooter( + processedDuration: Duration.zero, + showMessage: false, + showText: false, + ), + refreshOnStart: widget.refreshOnStart, + onLoad: widget.onLoad, + child: widget.child, + ); + } + + Widget _buildDesktop(BuildContext context) { + return NotificationListener( + onNotification: (notification) { + if (notification is ScrollUpdateNotification) { + _onScroll(notification.metrics); + } + return false; + }, + child: widget.child, + ); + } + + @override + Widget build(BuildContext context) { + return PlatformBuildWidget( + androidBuilder: _buildAndroid, + desktopBuilder: _buildDesktop, + ); + } +} diff --git a/lib/widgets/messenger.dart b/lib/widgets/messenger.dart index 2b77f546..a7b42c0f 100644 --- a/lib/widgets/messenger.dart +++ b/lib/widgets/messenger.dart @@ -6,8 +6,8 @@ import 'package:flutter/widgets.dart'; showPlatformSnackbar({ required BuildContext context, - required String title, required String content, + String title = '', dynamic action, fluent.InfoBarSeverity severity = fluent.InfoBarSeverity.info, }) { diff --git a/pubspec.yaml b/pubspec.yaml index 54bcb8af..9790efeb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: miru_app description: A new Flutter project. publish_to: "none" -version: 1.6.3+21 +version: 1.6.4+22 environment: sdk: ">=3.0.3 <4.0.0"