merge: 修正双击点赞与心形联动
This commit is contained in:
commit
76c4b528d3
8 files changed
+200
-64
No files matched your search
@@ -1297,6 +1297,7 @@ $ flutter test
|
|||||||
|
|
||||||
## 2026-10-22 播放器双击点赞与飘心
|
## 2026-10-22 播放器双击点赞与飘心
|
||||||
|
|
||||||
- 沉浸式播放手势增加原生双击识别,双击优先于单击暂停,长按左右倍速与中间更多保持不变。
|
- 沉浸式播放器改用 onTapUp + 280ms Timer 判定双击,双击取消单击,长按左右倍速与中间更多保持不变。
|
||||||
- 双击仅对未点赞剧集调用现有点赞接口,已点赞或请求中只播动画;触发轻震动并在失败时回滚提示。
|
- 双击和右侧心形共用点赞方法,成功后控制器通知并点亮红色实心心形,失败由 controller 回滚并使用 Toast。
|
||||||
- 飘心层使用独立 IgnorePointer Stack,动画完成后自动移除;`flutter analyze`:No issues found。
|
- 飘心层使用独立 IgnorePointer Stack,动画完成后自动移除;`flutter analyze`:No issues found。
|
||||||
|
- `flutter test test/player/double_tap_like_test.dart`:2 项测试全部通过。
|
||||||
@@ -516,16 +516,8 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
controller: videoPort?.controller,
|
controller: videoPort?.controller,
|
||||||
videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl,
|
videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl,
|
||||||
theater: theater,
|
theater: theater,
|
||||||
onTap: () => _handleFeedSurfaceTap(),
|
onTap: widget.homeMode ? () {} : _handleFeedSurfaceTap,
|
||||||
),
|
),
|
||||||
if (widget.homeMode && _chromeVisible && !logic.chromeHidden)
|
|
||||||
Positioned.fill(
|
|
||||||
child: GestureDetector(
|
|
||||||
key: const Key('home-feed-video-toggle'),
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
onTap: _handleFeedSurfaceTap,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 票据三态之一:起播前正在向后端要签名地址
|
// 票据三态之一:起播前正在向后端要签名地址
|
||||||
if (selected && logic.ticketLoading)
|
if (selected && logic.ticketLoading)
|
||||||
const Center(
|
const Center(
|
||||||
@@ -640,24 +632,11 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
commentCount: _commentCounts[episode.id] ?? episode.commentCount,
|
commentCount: _commentCounts[episode.id] ?? episode.commentCount,
|
||||||
saved: context.watch<FavoriteController>().isSaved(drama.id),
|
saved: context.watch<FavoriteController>().isSaved(drama.id),
|
||||||
onLike: () async {
|
onLike: () async {
|
||||||
if (await ensureSignedIn(context)) {
|
await _toggleLike(context, interactions, episode);
|
||||||
if (!context.mounted) return;
|
|
||||||
unawaited(context.read<Haptics>().lightImpact());
|
|
||||||
final result = await interactions.toggleEpisodeLike(episode.id);
|
|
||||||
if (!result.isSuccess && context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
result.errorMessage ??
|
|
||||||
AppLocalizations.of(context)!.likeFailed,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onDoubleTapLike: (_) =>
|
onDoubleTapLike: (_) => unawaited(
|
||||||
unawaited(_likeFromDoubleTap(context, interactions, episode)),
|
_toggleLike(context, interactions, episode, likeOnly: true),
|
||||||
|
),
|
||||||
onSave: () => unawaited(_toggleSave(context, drama)),
|
onSave: () => unawaited(_toggleSave(context, drama)),
|
||||||
onComments: () => unawaited(_openComments(context, drama, episode)),
|
onComments: () => unawaited(_openComments(context, drama, episode)),
|
||||||
onShare: () => logic.shareDrama(context, drama),
|
onShare: () => logic.shareDrama(context, drama),
|
||||||
|
|||||||
@@ -81,34 +81,17 @@ extension _PlayerViewActions on _PlayerPageState {
|
|||||||
Future<void> _toggleLike(
|
Future<void> _toggleLike(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
InteractionController interactions,
|
InteractionController interactions,
|
||||||
Episode episode,
|
Episode episode, {
|
||||||
) async {
|
bool likeOnly = false,
|
||||||
if (!await ensureSignedIn(context) || !context.mounted) return;
|
}) async {
|
||||||
unawaited(context.read<Haptics>().lightImpact());
|
unawaited(context.read<Haptics>().lightImpact());
|
||||||
final result = await interactions.toggleEpisodeLike(episode.id);
|
if (interactions.isEpisodeBusy(episode.id) ||
|
||||||
if (result.isSuccess || !context.mounted) return;
|
(likeOnly && interactions.isEpisodeLiked(episode.id))) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
result.errorMessage ?? AppLocalizations.of(context)!.likeFailed,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _likeFromDoubleTap(
|
|
||||||
BuildContext context,
|
|
||||||
InteractionController interactions,
|
|
||||||
Episode episode,
|
|
||||||
) async {
|
|
||||||
unawaited(context.read<Haptics>().lightImpact());
|
|
||||||
if (interactions.isEpisodeLiked(episode.id) ||
|
|
||||||
interactions.isEpisodeBusy(episode.id)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!await ensureSignedIn(context) || !context.mounted) return;
|
if (!await ensureSignedIn(context) || !context.mounted) return;
|
||||||
if (interactions.isEpisodeLiked(episode.id) ||
|
if (interactions.isEpisodeBusy(episode.id) ||
|
||||||
interactions.isEpisodeBusy(episode.id)) {
|
(likeOnly && interactions.isEpisodeLiked(episode.id))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final result = await interactions.toggleEpisodeLike(episode.id);
|
final result = await interactions.toggleEpisodeLike(episode.id);
|
||||||
|
|||||||
@@ -107,9 +107,12 @@ class HomeFeedOverlay extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||||
|
static const Duration _singleTapDelay = Duration(milliseconds: 280);
|
||||||
|
|
||||||
bool _scrubbing = false;
|
bool _scrubbing = false;
|
||||||
bool _wasVisibleBeforeScrub = true;
|
bool _wasVisibleBeforeScrub = true;
|
||||||
Offset? _doubleTapPosition;
|
Timer? _singleTapTimer;
|
||||||
|
DateTime? _lastTapAt;
|
||||||
int _nextHeartBurstId = 0;
|
int _nextHeartBurstId = 0;
|
||||||
final List<({int id, Offset position})> _heartBursts = [];
|
final List<({int id, Offset position})> _heartBursts = [];
|
||||||
|
|
||||||
@@ -121,6 +124,12 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_singleTapTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
@@ -135,10 +144,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
key: const Key('home-feed-gesture'),
|
key: const Key('home-feed-gesture'),
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: _handleSurfaceTap,
|
onTapUp: _handleTapUp,
|
||||||
onDoubleTapDown: (details) =>
|
|
||||||
_doubleTapPosition = details.localPosition,
|
|
||||||
onDoubleTap: _handleDoubleTap,
|
|
||||||
onLongPressStart: (details) =>
|
onLongPressStart: (details) =>
|
||||||
_handleLongPressStart(details, constraints.maxWidth),
|
_handleLongPressStart(details, constraints.maxWidth),
|
||||||
onLongPressEnd: (_) => widget.onBoostEnd(),
|
onLongPressEnd: (_) => widget.onBoostEnd(),
|
||||||
@@ -242,13 +248,28 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
|||||||
widget.onScrubEnd?.call(wasVisible);
|
widget.onScrubEnd?.call(wasVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleSurfaceTap() {
|
void _handleTapUp(TapUpDetails details) {
|
||||||
widget.onTogglePlay();
|
final now = DateTime.now();
|
||||||
|
final lastTap = _lastTapAt;
|
||||||
|
if (_singleTapTimer != null &&
|
||||||
|
lastTap != null &&
|
||||||
|
now.difference(lastTap) <= _singleTapDelay) {
|
||||||
|
_singleTapTimer?.cancel();
|
||||||
|
_singleTapTimer = null;
|
||||||
|
_lastTapAt = null;
|
||||||
|
_showHearts(details.localPosition);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_lastTapAt = now;
|
||||||
|
_singleTapTimer?.cancel();
|
||||||
|
_singleTapTimer = Timer(_singleTapDelay, () {
|
||||||
|
_singleTapTimer = null;
|
||||||
|
_lastTapAt = null;
|
||||||
|
if (mounted) widget.onTogglePlay();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleDoubleTap() {
|
void _showHearts(Offset position) {
|
||||||
final position = _doubleTapPosition;
|
|
||||||
if (position == null) return;
|
|
||||||
final id = _nextHeartBurstId++;
|
final id = _nextHeartBurstId++;
|
||||||
setState(() => _heartBursts.add((id: id, position: position)));
|
setState(() => _heartBursts.add((id: id, position: position)));
|
||||||
widget.onDoubleTapLike?.call(position);
|
widget.onDoubleTapLike?.call(position);
|
||||||
@@ -260,6 +281,9 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleLongPressStart(LongPressStartDetails details, double width) {
|
void _handleLongPressStart(LongPressStartDetails details, double width) {
|
||||||
|
_singleTapTimer?.cancel();
|
||||||
|
_singleTapTimer = null;
|
||||||
|
_lastTapAt = null;
|
||||||
final third = width / 3;
|
final third = width / 3;
|
||||||
final dx = details.localPosition.dx;
|
final dx = details.localPosition.dx;
|
||||||
if (dx < third || dx > width - third) {
|
if (dx < third || dx > width - third) {
|
||||||
|
|||||||
@@ -371,6 +371,9 @@ class HomeFeedActionRail extends StatelessWidget {
|
|||||||
icon: liked
|
icon: liked
|
||||||
? Icons.favorite_rounded
|
? Icons.favorite_rounded
|
||||||
: Icons.favorite_border_rounded,
|
: Icons.favorite_border_rounded,
|
||||||
|
iconColor: liked
|
||||||
|
? AppColors.doubleTapHeartRed
|
||||||
|
: AppColors.textPrimary,
|
||||||
label: likeCount == null
|
label: likeCount == null
|
||||||
? l10n.like
|
? l10n.like
|
||||||
: formatInteractionCount(likeCount!),
|
: formatInteractionCount(likeCount!),
|
||||||
@@ -397,6 +400,7 @@ class HomeFeedActionRail extends StatelessWidget {
|
|||||||
required String label,
|
required String label,
|
||||||
required Key labelKey,
|
required Key labelKey,
|
||||||
required VoidCallback onTap,
|
required VoidCallback onTap,
|
||||||
|
Color iconColor = AppColors.textPrimary,
|
||||||
double labelFontSize = 12,
|
double labelFontSize = 12,
|
||||||
}) {
|
}) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
@@ -409,7 +413,7 @@ class HomeFeedActionRail extends StatelessWidget {
|
|||||||
Icon(
|
Icon(
|
||||||
icon,
|
icon,
|
||||||
size: _actionIcon,
|
size: _actionIcon,
|
||||||
color: AppColors.textPrimary,
|
color: iconColor,
|
||||||
shadows: const [
|
shadows: const [
|
||||||
Shadow(
|
Shadow(
|
||||||
color: AppColors.shadowSoft,
|
color: AppColors.shadowSoft,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class InteractionRailItem {
|
|||||||
this.count,
|
this.count,
|
||||||
this.countKey,
|
this.countKey,
|
||||||
this.selected = false,
|
this.selected = false,
|
||||||
|
this.selectedColor = AppColors.blue,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Key key;
|
final Key key;
|
||||||
@@ -28,6 +29,7 @@ class InteractionRailItem {
|
|||||||
final int? count;
|
final int? count;
|
||||||
final Key? countKey;
|
final Key? countKey;
|
||||||
final bool selected;
|
final bool selected;
|
||||||
|
final Color selectedColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 播放页与首页 feed 共用的右侧互动栏:收藏/评论/喜欢/分享。
|
/// 播放页与首页 feed 共用的右侧互动栏:收藏/评论/喜欢/分享。
|
||||||
@@ -71,7 +73,7 @@ class _RailButton extends StatelessWidget {
|
|||||||
width: _iconSize,
|
width: _iconSize,
|
||||||
height: _iconSize,
|
height: _iconSize,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
color: item.selected ? AppColors.blue : AppColors.onPrimary,
|
color: item.selected ? item.selectedColor : AppColors.onPrimary,
|
||||||
colorBlendMode: BlendMode.srcIn,
|
colorBlendMode: BlendMode.srcIn,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
|||||||
count: data.likeCount,
|
count: data.likeCount,
|
||||||
countKey: const Key('player-rail-like-count'),
|
countKey: const Key('player-rail-like-count'),
|
||||||
selected: data.liked,
|
selected: data.liked,
|
||||||
|
selectedColor: AppColors.doubleTapHeartRed,
|
||||||
onTap: actions.onLike,
|
onTap: actions.onLike,
|
||||||
),
|
),
|
||||||
InteractionRailItem(
|
InteractionRailItem(
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fruitshorts/core/data/services/interaction_controller.dart';
|
||||||
|
import 'package:fruitshorts/core/theme/app_theme.dart';
|
||||||
|
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||||
|
import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart';
|
||||||
|
|
||||||
|
import '../support/fake_catalog.dart';
|
||||||
|
import '../support/fake_haptics.dart';
|
||||||
|
import '../support/fake_interaction_repositories.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('quick double tap likes without pausing', (tester) async {
|
||||||
|
final repository = FakeInteractionRepository();
|
||||||
|
final haptics = FakeHaptics();
|
||||||
|
final key = GlobalKey<_HarnessState>();
|
||||||
|
await _pumpHarness(tester, repository, haptics, key);
|
||||||
|
|
||||||
|
final gesture = find.byKey(const Key('home-feed-gesture'));
|
||||||
|
final center = tester.getCenter(gesture);
|
||||||
|
|
||||||
|
await tester.tapAt(center);
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
await tester.tapAt(center);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(key.currentState!.pauseToggles, 0);
|
||||||
|
expect(repository.likeTargets, hasLength(1));
|
||||||
|
expect(haptics.lightImpacts, 1);
|
||||||
|
expect(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const Key('rail-like')),
|
||||||
|
matching: find.byIcon(Icons.favorite_rounded),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('single tap toggles playback after 280ms', (tester) async {
|
||||||
|
final key = GlobalKey<_HarnessState>();
|
||||||
|
await _pumpHarness(tester, FakeInteractionRepository(), FakeHaptics(), key);
|
||||||
|
|
||||||
|
await tester.tapAt(
|
||||||
|
tester.getCenter(find.byKey(const Key('home-feed-gesture'))),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 279));
|
||||||
|
expect(key.currentState!.pauseToggles, 0);
|
||||||
|
|
||||||
|
await tester.pump(const Duration(milliseconds: 2));
|
||||||
|
expect(key.currentState!.pauseToggles, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpHarness(
|
||||||
|
WidgetTester tester,
|
||||||
|
FakeInteractionRepository repository,
|
||||||
|
FakeHaptics haptics,
|
||||||
|
GlobalKey<_HarnessState> key,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: AppTheme.dark(),
|
||||||
|
locale: const Locale('en'),
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
home: _Harness(key: key, repository: repository, haptics: haptics),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Harness extends StatefulWidget {
|
||||||
|
const _Harness({super.key, required this.repository, required this.haptics});
|
||||||
|
|
||||||
|
final FakeInteractionRepository repository;
|
||||||
|
final FakeHaptics haptics;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_Harness> createState() => _HarnessState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HarnessState extends State<_Harness> {
|
||||||
|
late final InteractionController interactions = InteractionController(
|
||||||
|
widget.repository,
|
||||||
|
);
|
||||||
|
int pauseToggles = 0;
|
||||||
|
|
||||||
|
Future<void> _like({bool likeOnly = false}) async {
|
||||||
|
await widget.haptics.lightImpact();
|
||||||
|
final episodeId = demoDramas.first.episodes.first.id;
|
||||||
|
if (interactions.isEpisodeBusy(episodeId) ||
|
||||||
|
(likeOnly && interactions.isEpisodeLiked(episodeId))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await interactions.toggleEpisodeLike(episodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
interactions.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: ListenableBuilder(
|
||||||
|
listenable: interactions,
|
||||||
|
builder: (context, _) {
|
||||||
|
final episode = demoDramas.first.episodes.first;
|
||||||
|
return HomeFeedOverlay(
|
||||||
|
l10n: l10n,
|
||||||
|
drama: demoDramas.first,
|
||||||
|
episode: episode,
|
||||||
|
controller: null,
|
||||||
|
locked: false,
|
||||||
|
liked: interactions.isEpisodeLiked(episode.id),
|
||||||
|
likeCount: interactions.episodeLikeCount(episode.id),
|
||||||
|
saved: false,
|
||||||
|
visible: true,
|
||||||
|
boosting: false,
|
||||||
|
onLike: () => _like(),
|
||||||
|
onDoubleTapLike: (_) => _like(likeOnly: true),
|
||||||
|
onSave: () {},
|
||||||
|
onComments: () {},
|
||||||
|
onShare: () {},
|
||||||
|
onDetail: () {},
|
||||||
|
onOpenFullPlayer: () {},
|
||||||
|
onTogglePlay: () => setState(() => pauseToggles++),
|
||||||
|
onBoostStart: () {},
|
||||||
|
onBoostEnd: () {},
|
||||||
|
onMore: () {},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user