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);
});
}
@@ -0,0 +1,21 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/features/home/ui/navigation/home_return_policy.dart';
void main() {
group('resolveHomeReturnAction', () {
test('business route with back stack prefers pop', () {
final action = resolveHomeReturnAction(canPop: true, isAuthEntry: false);
expect(action, HomeReturnAction.pop);
});
test('business route without back stack falls back to go home', () {
final action = resolveHomeReturnAction(canPop: false, isAuthEntry: false);
expect(action, HomeReturnAction.goHome);
});
test('auth entry always goes home directly', () {
final action = resolveHomeReturnAction(canPop: true, isAuthEntry: true);
expect(action, HomeReturnAction.goHome);
});
});
}
@@ -7,6 +7,7 @@ import 'package:image_picker/image_picker.dart';
import 'package:social_app/core/api/i_api_client.dart';
import 'package:social_app/core/di/injection.dart';
import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart';
import 'package:social_app/features/chat/data/models/chat_list_item.dart';
import 'package:social_app/features/home/data/voice_recorder.dart';
import 'package:social_app/features/home/ui/screens/home_screen.dart';
import 'package:social_app/features/home/ui/widgets/home_attachment_strip.dart';
@@ -127,6 +128,18 @@ void main() {
await tester.pump();
}
List<ChatListItem> buildMessages(int count) {
final base = DateTime(2026, 1, 1, 9, 0);
return List<ChatListItem>.generate(count, (index) {
return TextMessageItem(
id: 'msg_$index',
content: 'message $index',
timestamp: base.add(Duration(minutes: index)),
sender: index.isEven ? MessageSender.user : MessageSender.ai,
);
});
}
testWidgets(
'home screen shows floating header, conversation stage, and bottom input stack',
(tester) async {
@@ -292,4 +305,68 @@ void main() {
expect(recorder.stopCalls, 1);
expect(transcribeCalls, 0);
});
testWidgets(
'shows unread badge when new message arrives during history reading',
(tester) async {
await pumpHomeScreen(tester);
final initialItems = buildMessages(30);
chatBloc.emit(const ChatState().copyWith(items: initialItems));
await tester.pump(const Duration(milliseconds: 700));
final position = tester
.state<ScrollableState>(find.byType(Scrollable))
.position;
position.jumpTo(0);
await tester.pump(const Duration(milliseconds: 220));
final nextItems = [
...initialItems,
...buildMessages(1).map(
(e) => (e as TextMessageItem).copyWith(
id: 'new_1',
content: 'new message',
),
),
];
chatBloc.emit(const ChatState().copyWith(items: nextItems));
await tester.pump(const Duration(milliseconds: 700));
expect(find.textContaining('有1条新消息'), findsOneWidget);
},
);
testWidgets('tap unread badge scrolls bottom and clears badge', (
tester,
) async {
await pumpHomeScreen(tester);
final initialItems = buildMessages(30);
chatBloc.emit(const ChatState().copyWith(items: initialItems));
await tester.pump(const Duration(milliseconds: 700));
final position = tester
.state<ScrollableState>(find.byType(Scrollable))
.position;
position.jumpTo(0);
await tester.pump(const Duration(milliseconds: 220));
final nextItems = [
...initialItems,
...buildMessages(1).map(
(e) => (e as TextMessageItem).copyWith(
id: 'new_2',
content: 'new message 2',
),
),
];
chatBloc.emit(const ChatState().copyWith(items: nextItems));
await tester.pump(const Duration(milliseconds: 700));
await tester.tap(find.textContaining('有1条新消息'));
await tester.pump(const Duration(milliseconds: 700));
expect(find.textContaining('有1条新消息'), findsNothing);
});
}