feat(apps/home): 新增 HomeScreen 录音交互与导航组件

This commit is contained in:
zl-q
2026-03-19 00:51:52 +08:00
parent 14ccf2cb28
commit 039e8b73d6
13 changed files with 1840 additions and 847 deletions
@@ -0,0 +1,197 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/features/home/ui/controllers/home_message_viewport_controller.dart';
void main() {
ViewportEvent buildEvent({
required ViewportEventType type,
required String conversationId,
required int eventSeq,
required double distanceToBottomPx,
bool isFirstEnter = false,
bool hasSavedViewport = false,
bool hasAnchor = false,
int deltaCount = 0,
ViewportTriggerSource source = ViewportTriggerSource.system,
}) {
return ViewportEvent(
type: type,
conversationId: conversationId,
eventSeq: eventSeq,
triggerSource: source,
deltaCount: deltaCount,
anchor: const ViewportAnchor(messageId: null, offsetPx: null),
timestamp: 1000 + eventSeq,
viewportContext: ViewportContext(
distanceToBottomPx: distanceToBottomPx,
isFirstEnter: isFirstEnter,
hasSavedViewport: hasSavedViewport,
hasAnchor: hasAnchor,
),
);
}
test('distance<=96 and new message => animateBottom', () {
final controller = HomeMessageViewportController();
final decision = controller.apply(
buildEvent(
type: ViewportEventType.newMessageAppended,
conversationId: 'c1',
eventSeq: 1,
distanceToBottomPx: 80,
deltaCount: 1,
),
);
expect(decision.action, ViewportAction.animateBottom);
});
test('distance>96 and new message => showUnreadBadge', () {
final controller = HomeMessageViewportController();
controller.apply(
buildEvent(
type: ViewportEventType.userScrollStateChanged,
conversationId: 'c1',
eventSeq: 1,
distanceToBottomPx: 200,
source: ViewportTriggerSource.user,
),
);
final decision = controller.apply(
buildEvent(
type: ViewportEventType.newMessageAppended,
conversationId: 'c1',
eventSeq: 2,
distanceToBottomPx: 200,
deltaCount: 1,
),
);
expect(decision.action, ViewportAction.showUnreadBadge);
expect(controller.unreadCount, 1);
});
test('stale event is dropped by sequence', () {
final controller = HomeMessageViewportController();
controller.apply(
buildEvent(
type: ViewportEventType.historyInitialLoaded,
conversationId: 'c1',
eventSeq: 10,
distanceToBottomPx: 0,
isFirstEnter: true,
),
);
final decision = controller.apply(
buildEvent(
type: ViewportEventType.newMessageAppended,
conversationId: 'c1',
eventSeq: 9,
distanceToBottomPx: 0,
),
);
expect(decision.action, ViewportAction.none);
expect(decision.reason, 'stale-event');
});
test('different conversations keep independent sequence', () {
final controller = HomeMessageViewportController();
controller.apply(
buildEvent(
type: ViewportEventType.historyInitialLoaded,
conversationId: 'A',
eventSeq: 10,
distanceToBottomPx: 0,
isFirstEnter: true,
),
);
final decision = controller.apply(
buildEvent(
type: ViewportEventType.historyInitialLoaded,
conversationId: 'B',
eventSeq: 1,
distanceToBottomPx: 0,
isFirstEnter: true,
),
);
expect(decision.action, ViewportAction.jumpBottom);
});
test('refresh keeps reading history position when far from bottom', () {
final controller = HomeMessageViewportController();
controller.apply(
buildEvent(
type: ViewportEventType.userScrollStateChanged,
conversationId: 'c1',
eventSeq: 1,
distanceToBottomPx: 180,
source: ViewportTriggerSource.user,
),
);
final decision = controller.apply(
buildEvent(
type: ViewportEventType.sessionRefreshCompleted,
conversationId: 'c1',
eventSeq: 2,
distanceToBottomPx: 180,
),
);
expect(decision.action, ViewportAction.none);
});
test('resume with saved viewport restores anchor', () {
final controller = HomeMessageViewportController();
final decision = controller.apply(
buildEvent(
type: ViewportEventType.screenResumedFromSubRoute,
conversationId: 'c1',
eventSeq: 1,
distanceToBottomPx: 180,
hasSavedViewport: true,
hasAnchor: true,
),
);
expect(decision.action, ViewportAction.restoreAnchor);
expect(decision.reason, 'resume-restore-saved-viewport');
});
test('prepend finish without anchor exits restoring state', () {
final controller = HomeMessageViewportController();
controller.apply(
buildEvent(
type: ViewportEventType.historyPagePrependStarted,
conversationId: 'c1',
eventSeq: 1,
distanceToBottomPx: 200,
hasAnchor: true,
),
);
controller.apply(
buildEvent(
type: ViewportEventType.historyPagePrependFinished,
conversationId: 'c1',
eventSeq: 2,
distanceToBottomPx: 200,
hasAnchor: false,
),
);
final decision = controller.apply(
buildEvent(
type: ViewportEventType.newMessageAppended,
conversationId: 'c1',
eventSeq: 3,
distanceToBottomPx: 200,
deltaCount: 1,
),
);
expect(decision.action, ViewportAction.showUnreadBadge);
});
}