merge: 合并 K33-E 首页 feed 刷新与进度优化
This commit is contained in:
commit
564c0dbbef
8 files changed
+190
-17
No files matched your search
@@ -44,6 +44,19 @@ class CatalogController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 静默刷新首页聚合:成功替换数据,失败返回 false。
|
||||||
|
///
|
||||||
|
/// 不触碰 [loading]/[error],因此页面不会切到全屏 loading 或清空旧数据。
|
||||||
|
Future<bool> refresh() async {
|
||||||
|
try {
|
||||||
|
feed = await repository.fetchHomeFeed();
|
||||||
|
notifyListeners();
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 需要完整剧集时补一次详情;已加载过则直接返回,避免重复请求。
|
/// 需要完整剧集时补一次详情;已加载过则直接返回,避免重复请求。
|
||||||
Future<Drama> requireDetail(Drama drama) async {
|
Future<Drama> requireDetail(Drama drama) async {
|
||||||
if (drama.detailLoaded) return drama;
|
if (drama.detailLoaded) return drama;
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class HomePage extends StatelessWidget {
|
|||||||
return _HomeTabs(
|
return _HomeTabs(
|
||||||
key: ValueKey(catalog.dramas.first.id),
|
key: ValueKey(catalog.dramas.first.id),
|
||||||
dramas: catalog.dramas,
|
dramas: catalog.dramas,
|
||||||
onRefreshFeed: catalog.load,
|
onRefreshFeed: catalog.refresh,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,7 +103,7 @@ class _HomeTabs extends StatefulWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final List<Drama> dramas;
|
final List<Drama> dramas;
|
||||||
final VoidCallback onRefreshFeed;
|
final Future<bool> Function() onRefreshFeed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_HomeTabs> createState() => _HomeTabsState();
|
State<_HomeTabs> createState() => _HomeTabsState();
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class PlayerPage extends StatefulWidget {
|
|||||||
final ValueChanged<Drama>? onDramaChanged;
|
final ValueChanged<Drama>? onDramaChanged;
|
||||||
final ValueChanged<bool>? onImmersiveChanged;
|
final ValueChanged<bool>? onImmersiveChanged;
|
||||||
final Future<void> Function(Drama drama, Episode episode)? onOpenFullPlayer;
|
final Future<void> Function(Drama drama, Episode episode)? onOpenFullPlayer;
|
||||||
final VoidCallback? onRefreshFeed;
|
final Future<bool> Function()? onRefreshFeed;
|
||||||
final WalletController? wallet;
|
final WalletController? wallet;
|
||||||
final DownloadController? downloads;
|
final DownloadController? downloads;
|
||||||
|
|
||||||
@@ -420,7 +420,13 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
if (!mounted || !widget.active) return;
|
if (!mounted || !widget.active) return;
|
||||||
logic.session.setUserWantsPlay(true);
|
logic.session.setUserWantsPlay(true);
|
||||||
},
|
},
|
||||||
onRefreshFeed: () => widget.onRefreshFeed?.call(),
|
onRefreshFeed: () async {
|
||||||
|
final ok = await widget.onRefreshFeed?.call() ?? true;
|
||||||
|
if (!context.mounted || ok) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(l10n.catalogError)));
|
||||||
|
},
|
||||||
onDownload: () => requestEpisodeDownload(context, episode.id),
|
onDownload: () => requestEpisodeDownload(context, episode.id),
|
||||||
onClear: () => setState(() => _chromeVisible = false),
|
onClear: () => setState(() => _chromeVisible = false),
|
||||||
onSearch:
|
onSearch:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import 'interaction_rail.dart';
|
|||||||
part 'home_feed_overlay_parts.dart';
|
part 'home_feed_overlay_parts.dart';
|
||||||
|
|
||||||
const Color _overlayPill = Color(0x66000000);
|
const Color _overlayPill = Color(0x66000000);
|
||||||
|
const Color _fullBarBackground = Color(0x4D000000);
|
||||||
const double _topIconSize = 36;
|
const double _topIconSize = 36;
|
||||||
const double _topIconGlyph = 22;
|
const double _topIconGlyph = 22;
|
||||||
const double _tabGap = 22;
|
const double _tabGap = 22;
|
||||||
@@ -25,7 +26,7 @@ const double _metaFontSize = 12;
|
|||||||
const double _tagFontSize = 11;
|
const double _tagFontSize = 11;
|
||||||
const double _fullBarHeight = 44;
|
const double _fullBarHeight = 44;
|
||||||
const double _progressHeight = 2;
|
const double _progressHeight = 2;
|
||||||
const double _progressHitHeight = 20;
|
const double _progressHitHeight = 24;
|
||||||
const int _maxTags = 4;
|
const int _maxTags = 4;
|
||||||
final RegExp _tagSplitter = RegExp(r'[,/、·]');
|
final RegExp _tagSplitter = RegExp(r'[,/、·]');
|
||||||
|
|
||||||
@@ -100,7 +101,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
|||||||
_header(context),
|
_header(context),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: const Alignment(1.0, 0.45),
|
alignment: const Alignment(1.0, 0.68),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(0, 0, 10, 16),
|
padding: const EdgeInsets.fromLTRB(0, 0, 10, 16),
|
||||||
child: _rail(),
|
child: _rail(),
|
||||||
@@ -358,6 +359,12 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
|||||||
height: _fullBarHeight,
|
height: _fullBarHeight,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: Container(
|
||||||
|
key: const Key('home-feed-full-series-bg'),
|
||||||
|
color: _fullBarBackground,
|
||||||
|
),
|
||||||
|
),
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ part of 'home_feed_overlay.dart';
|
|||||||
|
|
||||||
const double _thumbSize = 6;
|
const double _thumbSize = 6;
|
||||||
const double _thumbActiveSize = 10;
|
const double _thumbActiveSize = 10;
|
||||||
|
const double _trackActiveHeight = 4;
|
||||||
|
const double _lineCenterFromBottom = 6;
|
||||||
|
|
||||||
/// 顶部频道 tab:漫剧点击提示即将上线。
|
/// 顶部频道 tab:漫剧点击提示即将上线。
|
||||||
enum _FeedTab {
|
enum _FeedTab {
|
||||||
@@ -199,10 +201,6 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
|||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
key: const Key('home-feed-progress'),
|
key: const Key('home-feed-progress'),
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTapDown: (details) {
|
|
||||||
widget.onScrubStart?.call();
|
|
||||||
_commit(details.localPosition.dx / width);
|
|
||||||
},
|
|
||||||
onHorizontalDragStart: (details) {
|
onHorizontalDragStart: (details) {
|
||||||
widget.onScrubStart?.call();
|
widget.onScrubStart?.call();
|
||||||
_update(details.localPosition.dx / width);
|
_update(details.localPosition.dx / width);
|
||||||
@@ -233,11 +231,14 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _track(double fraction, double width) {
|
Widget _track(double fraction, double width) {
|
||||||
final active = _preview != null;
|
final dragging = _preview != null;
|
||||||
final size = active ? _thumbActiveSize : _thumbSize;
|
final trackHeight = dragging ? _trackActiveHeight : widget.height;
|
||||||
|
final size = dragging ? _thumbActiveSize : _thumbSize;
|
||||||
final left = (width * fraction - size / 2)
|
final left = (width * fraction - size / 2)
|
||||||
.clamp(0.0, width - size)
|
.clamp(0.0, width - size)
|
||||||
.toDouble();
|
.toDouble();
|
||||||
|
final lineBottom = _lineCenterFromBottom - trackHeight / 2;
|
||||||
|
final thumbBottom = _lineCenterFromBottom - size / 2;
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: widget.hitHeight,
|
height: widget.hitHeight,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
@@ -246,21 +247,25 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
|||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: lineBottom,
|
||||||
child: Container(height: widget.height, color: Colors.white24),
|
child: Container(
|
||||||
|
key: const Key('home-feed-progress-track'),
|
||||||
|
height: trackHeight,
|
||||||
|
color: Colors.white24,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
bottom: 0,
|
bottom: lineBottom,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: width * fraction,
|
width: width * fraction,
|
||||||
height: widget.height,
|
height: trackHeight,
|
||||||
color: AppColors.blue,
|
color: AppColors.blue,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
left: left,
|
left: left,
|
||||||
bottom: 0,
|
bottom: thumbBottom,
|
||||||
child: Container(
|
child: Container(
|
||||||
key: const Key('home-feed-progress-thumb'),
|
key: const Key('home-feed-progress-thumb'),
|
||||||
width: size,
|
width: size,
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||||
|
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||||
|
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
|
||||||
|
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('should_keep_state_when_silent_refresh_fails', () async {
|
||||||
|
final repository = _FakeCatalog(fail: true);
|
||||||
|
final controller = CatalogController(repository);
|
||||||
|
const oldFeed = HomeFeed();
|
||||||
|
controller.feed = oldFeed;
|
||||||
|
|
||||||
|
final ok = await controller.refresh();
|
||||||
|
|
||||||
|
expect(ok, isFalse);
|
||||||
|
expect(controller.feed, same(oldFeed));
|
||||||
|
expect(controller.loading, isFalse);
|
||||||
|
expect(controller.error, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'should_replace_feed_without_touching_loading_when_refresh_succeeds',
|
||||||
|
() async {
|
||||||
|
final repository = _FakeCatalog();
|
||||||
|
final controller = CatalogController(repository);
|
||||||
|
controller.feed = const HomeFeed();
|
||||||
|
final next = HomeFeed(
|
||||||
|
sections: [
|
||||||
|
FeedSection(code: 'new', title: 'New', dramas: const [_drama]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
repository.next = next;
|
||||||
|
|
||||||
|
final ok = await controller.refresh();
|
||||||
|
|
||||||
|
expect(ok, isTrue);
|
||||||
|
expect(controller.feed, same(next));
|
||||||
|
expect(controller.loading, isFalse);
|
||||||
|
expect(controller.error, isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeCatalog implements CatalogRepository {
|
||||||
|
_FakeCatalog({this.fail = false});
|
||||||
|
|
||||||
|
bool fail;
|
||||||
|
HomeFeed next = const HomeFeed();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HomeFeed> fetchHomeFeed() async {
|
||||||
|
if (fail) throw StateError('offline');
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HomeFeed> fetchDiscoverFeed() async => const HomeFeed();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Drama> fetchDrama(String dramaId) async => _drama;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _drama = Drama(
|
||||||
|
id: 'refresh-drama',
|
||||||
|
title: 'Refresh Drama',
|
||||||
|
synopsis: 'synopsis',
|
||||||
|
posterUrl: '',
|
||||||
|
detailLoaded: true,
|
||||||
|
);
|
||||||
@@ -211,6 +211,12 @@ void main() {
|
|||||||
|
|
||||||
final bar = tester.getRect(find.byKey(const Key('home-feed-full-series')));
|
final bar = tester.getRect(find.byKey(const Key('home-feed-full-series')));
|
||||||
expect(bar.height, 44);
|
expect(bar.height, 44);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<Container>(find.byKey(const Key('home-feed-full-series-bg')))
|
||||||
|
.color,
|
||||||
|
const Color(0x4D000000),
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
tester.getRect(find.textContaining('Watch full series')).center.dy,
|
tester.getRect(find.textContaining('Watch full series')).center.dy,
|
||||||
closeTo(bar.center.dy, 2),
|
closeTo(bar.center.dy, 2),
|
||||||
@@ -219,6 +225,23 @@ void main() {
|
|||||||
tester.getSize(find.byKey(const Key('home-feed-progress-thumb'))),
|
tester.getSize(find.byKey(const Key('home-feed-progress-thumb'))),
|
||||||
const Size(6, 6),
|
const Size(6, 6),
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
tester.getSize(find.byKey(const Key('home-feed-progress'))).height,
|
||||||
|
greaterThanOrEqualTo(24),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.getRect(find.byKey(const Key('home-feed-progress-thumb')))
|
||||||
|
.center
|
||||||
|
.dy,
|
||||||
|
closeTo(
|
||||||
|
tester
|
||||||
|
.getRect(find.byKey(const Key('home-feed-progress-track')))
|
||||||
|
.center
|
||||||
|
.dy,
|
||||||
|
0.5,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('home-feed-refresh')));
|
await tester.tap(find.byKey(const Key('home-feed-refresh')));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
@@ -233,6 +256,23 @@ void main() {
|
|||||||
tester.getSize(find.byKey(const Key('home-feed-progress-thumb'))),
|
tester.getSize(find.byKey(const Key('home-feed-progress-thumb'))),
|
||||||
const Size(10, 10),
|
const Size(10, 10),
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
tester.getSize(find.byKey(const Key('home-feed-progress-track'))).height,
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.getRect(find.byKey(const Key('home-feed-progress-thumb')))
|
||||||
|
.center
|
||||||
|
.dy,
|
||||||
|
closeTo(
|
||||||
|
tester
|
||||||
|
.getRect(find.byKey(const Key('home-feed-progress-track')))
|
||||||
|
.center
|
||||||
|
.dy,
|
||||||
|
0.5,
|
||||||
|
),
|
||||||
|
);
|
||||||
await gesture.up();
|
await gesture.up();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(seeked, isNotNull);
|
expect(seeked, isNotNull);
|
||||||
@@ -241,6 +281,10 @@ void main() {
|
|||||||
tester.getSize(find.byKey(const Key('home-feed-progress-thumb'))),
|
tester.getSize(find.byKey(const Key('home-feed-progress-thumb'))),
|
||||||
const Size(6, 6),
|
const Size(6, 6),
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
tester.getSize(find.byKey(const Key('home-feed-progress-track'))).height,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('home-feed-full-series')));
|
await tester.tap(find.byKey(const Key('home-feed-full-series')));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|||||||
@@ -96,6 +96,32 @@ void main() {
|
|||||||
expect(find.text('Coming soon'), findsOneWidget);
|
expect(find.text('Coming soon'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('should_refresh_feed_without_blank_loading', (tester) async {
|
||||||
|
await _pumpApp(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('home-feed-refresh')));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||||
|
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_keep_feed_rail_above_bottom_panels', (tester) async {
|
||||||
|
await _pumpApp(tester);
|
||||||
|
final railTop = tester.getCenter(find.byKey(const Key('rail-save'))).dy;
|
||||||
|
final railBottom = tester
|
||||||
|
.getRect(find.byKey(const Key('rail-share')))
|
||||||
|
.bottom;
|
||||||
|
final infoTop = tester
|
||||||
|
.getRect(find.byKey(const Key('home-feed-series-meta')))
|
||||||
|
.top;
|
||||||
|
|
||||||
|
expect(railTop, greaterThan(844 * 0.35));
|
||||||
|
expect(railBottom, lessThanOrEqualTo(infoTop));
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('should_restore_light_theme_after_leaving_feed_tab', (
|
testWidgets('should_restore_light_theme_after_leaving_feed_tab', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
Reference in new issue
Block a user