feat(apps): 重构 UI 架构为 presentation 层并新增 l10n 国际化支持
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
|
||||
class HomeKeyboardInsetCalculator {
|
||||
static double compute({
|
||||
required double rawViewInsetBottom,
|
||||
required double bottomViewPadding,
|
||||
}) {
|
||||
if (rawViewInsetBottom <= AppSpacing.xs) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
final adjustedInset = rawViewInsetBottom - bottomViewPadding;
|
||||
if (adjustedInset <= AppSpacing.xs) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return adjustedInset;
|
||||
}
|
||||
}
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
enum ViewportStatus { atBottom, readingHistory, restoringAnchor }
|
||||
|
||||
enum ViewportAction {
|
||||
none,
|
||||
jumpBottom,
|
||||
animateBottom,
|
||||
restoreAnchor,
|
||||
showUnreadBadge,
|
||||
}
|
||||
|
||||
enum ViewportEventType {
|
||||
historyInitialLoaded,
|
||||
historyPagePrependStarted,
|
||||
historyPagePrependFinished,
|
||||
newMessageAppended,
|
||||
screenResumedFromSubRoute,
|
||||
userScrollStateChanged,
|
||||
sessionRefreshCompleted,
|
||||
}
|
||||
|
||||
enum ViewportTriggerSource { user, system, route }
|
||||
|
||||
class ViewportAnchor {
|
||||
final String? messageId;
|
||||
final double? offsetPx;
|
||||
|
||||
const ViewportAnchor({required this.messageId, required this.offsetPx});
|
||||
}
|
||||
|
||||
class ViewportContext {
|
||||
final double distanceToBottomPx;
|
||||
final bool isFirstEnter;
|
||||
final bool hasSavedViewport;
|
||||
final bool hasAnchor;
|
||||
|
||||
const ViewportContext({
|
||||
required this.distanceToBottomPx,
|
||||
required this.isFirstEnter,
|
||||
required this.hasSavedViewport,
|
||||
required this.hasAnchor,
|
||||
});
|
||||
}
|
||||
|
||||
class ViewportEvent {
|
||||
final ViewportEventType type;
|
||||
final String conversationId;
|
||||
final int eventSeq;
|
||||
final ViewportTriggerSource triggerSource;
|
||||
final int deltaCount;
|
||||
final ViewportAnchor anchor;
|
||||
final int timestamp;
|
||||
final ViewportContext viewportContext;
|
||||
|
||||
const ViewportEvent({
|
||||
required this.type,
|
||||
required this.conversationId,
|
||||
required this.eventSeq,
|
||||
required this.triggerSource,
|
||||
required this.deltaCount,
|
||||
required this.anchor,
|
||||
required this.timestamp,
|
||||
required this.viewportContext,
|
||||
});
|
||||
}
|
||||
|
||||
class ViewportDecision {
|
||||
final ViewportAction action;
|
||||
final String reason;
|
||||
final Map<String, Object> debugMeta;
|
||||
|
||||
const ViewportDecision(this.action, this.reason, {this.debugMeta = const {}});
|
||||
}
|
||||
|
||||
class HomeMessageViewportController {
|
||||
static const double bottomThresholdPx = 96;
|
||||
|
||||
final Map<String, int> _lastAppliedSeqByConversation = <String, int>{};
|
||||
final Map<String, ViewportStatus> _statusByConversation =
|
||||
<String, ViewportStatus>{};
|
||||
final Map<String, int> _unreadByConversation = <String, int>{};
|
||||
|
||||
int get unreadCount => _unreadByConversation.values.fold(0, (a, b) => a + b);
|
||||
|
||||
ViewportStatus _statusOf(String conversationId) {
|
||||
return _statusByConversation[conversationId] ?? ViewportStatus.atBottom;
|
||||
}
|
||||
|
||||
void _setStatus(String conversationId, ViewportStatus status) {
|
||||
_statusByConversation[conversationId] = status;
|
||||
}
|
||||
|
||||
int _unreadOf(String conversationId) {
|
||||
return _unreadByConversation[conversationId] ?? 0;
|
||||
}
|
||||
|
||||
void _setUnread(String conversationId, int value) {
|
||||
if (value <= 0) {
|
||||
_unreadByConversation.remove(conversationId);
|
||||
return;
|
||||
}
|
||||
_unreadByConversation[conversationId] = value;
|
||||
}
|
||||
|
||||
ViewportDecision apply(ViewportEvent event) {
|
||||
final lastSeq = _lastAppliedSeqByConversation[event.conversationId] ?? -1;
|
||||
if (event.eventSeq <= lastSeq) {
|
||||
return const ViewportDecision(ViewportAction.none, 'stale-event');
|
||||
}
|
||||
|
||||
final debugMeta = <String, Object>{};
|
||||
final seqGap = event.eventSeq - lastSeq;
|
||||
if (lastSeq >= 0 && seqGap > 1) {
|
||||
debugMeta['seqGap'] = seqGap;
|
||||
}
|
||||
_lastAppliedSeqByConversation[event.conversationId] = event.eventSeq;
|
||||
final currentStatus = _statusOf(event.conversationId);
|
||||
|
||||
switch (event.type) {
|
||||
case ViewportEventType.historyInitialLoaded:
|
||||
if (event.viewportContext.isFirstEnter) {
|
||||
_setStatus(event.conversationId, ViewportStatus.atBottom);
|
||||
_setUnread(event.conversationId, 0);
|
||||
return ViewportDecision(
|
||||
ViewportAction.jumpBottom,
|
||||
'initial-load-first-enter',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
}
|
||||
return ViewportDecision(
|
||||
ViewportAction.none,
|
||||
'initial-load-non-first-enter',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
case ViewportEventType.userScrollStateChanged:
|
||||
if (event.viewportContext.distanceToBottomPx <= bottomThresholdPx) {
|
||||
_setStatus(event.conversationId, ViewportStatus.atBottom);
|
||||
_setUnread(event.conversationId, 0);
|
||||
return ViewportDecision(
|
||||
ViewportAction.none,
|
||||
'entered-at-bottom',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
}
|
||||
_setStatus(event.conversationId, ViewportStatus.readingHistory);
|
||||
return ViewportDecision(
|
||||
ViewportAction.none,
|
||||
'entered-reading-history',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
case ViewportEventType.historyPagePrependStarted:
|
||||
if (event.viewportContext.hasAnchor) {
|
||||
_setStatus(event.conversationId, ViewportStatus.restoringAnchor);
|
||||
return ViewportDecision(
|
||||
ViewportAction.none,
|
||||
'prepend-start-capture-anchor',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
}
|
||||
return ViewportDecision(
|
||||
ViewportAction.none,
|
||||
'prepend-start-no-anchor',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
case ViewportEventType.historyPagePrependFinished:
|
||||
if (currentStatus == ViewportStatus.restoringAnchor &&
|
||||
event.viewportContext.hasAnchor) {
|
||||
_setStatus(event.conversationId, ViewportStatus.readingHistory);
|
||||
return ViewportDecision(
|
||||
ViewportAction.restoreAnchor,
|
||||
'prepend-finish-restore-anchor',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
}
|
||||
_setStatus(
|
||||
event.conversationId,
|
||||
event.viewportContext.distanceToBottomPx <= bottomThresholdPx
|
||||
? ViewportStatus.atBottom
|
||||
: ViewportStatus.readingHistory,
|
||||
);
|
||||
return ViewportDecision(
|
||||
ViewportAction.none,
|
||||
'prepend-finish-no-restore',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
case ViewportEventType.newMessageAppended:
|
||||
if (currentStatus == ViewportStatus.restoringAnchor) {
|
||||
_setUnread(
|
||||
event.conversationId,
|
||||
_unreadOf(event.conversationId) +
|
||||
(event.deltaCount > 0 ? event.deltaCount : 1),
|
||||
);
|
||||
return ViewportDecision(
|
||||
ViewportAction.none,
|
||||
'restoring-anchor-enqueue-unread',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
}
|
||||
if (event.viewportContext.distanceToBottomPx <= bottomThresholdPx) {
|
||||
_setStatus(event.conversationId, ViewportStatus.atBottom);
|
||||
return ViewportDecision(
|
||||
ViewportAction.animateBottom,
|
||||
'new-message-follow-bottom',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
}
|
||||
_setStatus(event.conversationId, ViewportStatus.readingHistory);
|
||||
_setUnread(
|
||||
event.conversationId,
|
||||
_unreadOf(event.conversationId) +
|
||||
(event.deltaCount > 0 ? event.deltaCount : 1),
|
||||
);
|
||||
return ViewportDecision(
|
||||
ViewportAction.showUnreadBadge,
|
||||
'new-message-keep-reading-history',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
case ViewportEventType.screenResumedFromSubRoute:
|
||||
if (event.viewportContext.hasSavedViewport) {
|
||||
return ViewportDecision(
|
||||
ViewportAction.restoreAnchor,
|
||||
'resume-restore-saved-viewport',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
}
|
||||
return ViewportDecision(
|
||||
ViewportAction.none,
|
||||
'resume-no-saved-viewport',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
case ViewportEventType.sessionRefreshCompleted:
|
||||
if (event.viewportContext.distanceToBottomPx <= bottomThresholdPx) {
|
||||
_setStatus(event.conversationId, ViewportStatus.atBottom);
|
||||
_setUnread(event.conversationId, 0);
|
||||
return ViewportDecision(
|
||||
ViewportAction.animateBottom,
|
||||
'refresh-follow-bottom',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
}
|
||||
return ViewportDecision(
|
||||
ViewportAction.none,
|
||||
'refresh-keep-position',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'home_message_viewport_controller.dart';
|
||||
|
||||
class HomeViewportCoordinator {
|
||||
HomeViewportCoordinator(this._controller);
|
||||
|
||||
final HomeMessageViewportController _controller;
|
||||
int _eventSeq = 0;
|
||||
|
||||
int get unreadCount => _controller.unreadCount;
|
||||
|
||||
ViewportDecision dispatch({
|
||||
required ViewportEventType type,
|
||||
required ViewportTriggerSource source,
|
||||
required int deltaCount,
|
||||
required double distanceToBottomPx,
|
||||
required bool hasSavedViewport,
|
||||
required bool hasAnchor,
|
||||
required double? anchorOffsetPx,
|
||||
bool isFirstEnter = false,
|
||||
}) {
|
||||
_eventSeq += 1;
|
||||
return _controller.apply(
|
||||
ViewportEvent(
|
||||
type: type,
|
||||
conversationId: 'home-main',
|
||||
eventSeq: _eventSeq,
|
||||
triggerSource: source,
|
||||
deltaCount: deltaCount,
|
||||
anchor: ViewportAnchor(messageId: null, offsetPx: anchorOffsetPx),
|
||||
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||
viewportContext: ViewportContext(
|
||||
distanceToBottomPx: distanceToBottomPx,
|
||||
isFirstEnter: isFirstEnter,
|
||||
hasSavedViewport: hasSavedViewport,
|
||||
hasAnchor: hasAnchor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user