feat(apps): 重构 UI 架构为 presentation 层并新增 l10n 国际化支持

This commit is contained in:
qzl
2026-03-27 14:05:03 +08:00
parent b1f0eb8921
commit c592cc7854
178 changed files with 10748 additions and 5764 deletions
@@ -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;
}
}
@@ -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,
),
),
);
}
}