feat(apps/home): 新增 HomeScreen 录音交互与导航组件
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user