feat: 实现 Auth 全局状态机与 401 统一处理机制

- 新增 AuthSessionInvalidated 事件处理 token 失效场景
- ApiInterceptor 新增 authFailureCallback 单飞机制
- AuthBloc 区分 manual logout 与 auto expiry 语义
- 新增 startup recovery fallback 防止启动卡死

feat: 重构 Calendar DayWeek 视图事件布局引擎

- 新增 DayEventLayoutEngine 解耦事件计算与渲染
- 新增 DayTimelineMetrics 统一时间轴常量
- 新增 DayViewScale 支持捏合缩放

feat: 新增 Settings 页面共享 UI 组件

- 新增 BackTitlePageHeader 统一页面 header
- 新增 DetailHeaderActionMenu 统一操作菜单
- 新增 DestructiveActionSheet 统一删除确认
- 新增 AppToggleSwitch 统一开关组件

feat: Chat UI Schema 支持导航操作

- 支持 navigation 类型 action 触发内部路由跳转
- 新增路径验证与参数处理

chore: 更新相关测试覆盖 auth 失效路径
This commit is contained in:
qzl
2026-03-18 13:35:25 +08:00
parent 19981964fb
commit b34697660d
56 changed files with 2602 additions and 784 deletions
@@ -0,0 +1,184 @@
import '../../data/models/schedule_item_model.dart';
import 'day_timeline_metrics.dart';
import 'day_view_scale.dart';
class DayEventLayout {
final ScheduleItemModel event;
final int startMinutes;
final int endMinutes;
final int column;
final int columnCount;
final double top;
final double geometryHeight;
final double visualHeight;
final double left;
final double width;
const DayEventLayout({
required this.event,
required this.startMinutes,
required this.endMinutes,
required this.column,
required this.columnCount,
required this.top,
required this.geometryHeight,
required this.visualHeight,
required this.left,
required this.width,
});
}
class DayEventLayoutEngine {
const DayEventLayoutEngine();
List<DayEventLayout> layout({
required List<ScheduleItemModel> events,
required DayViewScale scale,
required double eventAreaLeft,
required double eventAreaWidth,
double columnGap = DayTimelineMetrics.eventColumnGap,
}) {
if (events.isEmpty || eventAreaWidth <= 0) {
return const [];
}
final sorted =
events
.map(_EventSpan.fromEvent)
.where((span) => span.endMinutes > span.startMinutes)
.toList()
..sort((a, b) {
final byStart = a.startMinutes.compareTo(b.startMinutes);
if (byStart != 0) {
return byStart;
}
final byEnd = a.endMinutes.compareTo(b.endMinutes);
if (byEnd != 0) {
return byEnd;
}
return a.event.id.compareTo(b.event.id);
});
if (sorted.isEmpty) {
return const [];
}
final active = <_PlacedSpan>[];
final clusters = <List<_PlacedSpan>>[];
List<_PlacedSpan> currentCluster = [];
var clusterEnd = sorted.first.endMinutes;
for (final span in sorted) {
active.removeWhere((item) => item.endMinutes <= span.startMinutes);
if (currentCluster.isNotEmpty && span.startMinutes >= clusterEnd) {
clusters.add(currentCluster);
currentCluster = [];
clusterEnd = span.endMinutes;
} else if (span.endMinutes > clusterEnd) {
clusterEnd = span.endMinutes;
}
final usedColumns = active.map((item) => item.column).toSet();
var column = 0;
while (usedColumns.contains(column)) {
column++;
}
final placed = _PlacedSpan(
event: span.event,
startMinutes: span.startMinutes,
endMinutes: span.endMinutes,
column: column,
);
active.add(placed);
currentCluster.add(placed);
}
if (currentCluster.isNotEmpty) {
clusters.add(currentCluster);
}
final layouts = <DayEventLayout>[];
for (final cluster in clusters) {
final clusterColumnCount =
cluster.map((item) => item.column).reduce((a, b) => a > b ? a : b) +
1;
final totalGap = (clusterColumnCount - 1) * columnGap;
final columnWidth = clusterColumnCount > 0
? ((eventAreaWidth - totalGap) / clusterColumnCount).toDouble()
: eventAreaWidth;
for (final item in cluster) {
final top = scale.pixelsForMinutes(item.startMinutes);
final geometryHeight = scale.pixelsForMinutes(
item.endMinutes - item.startMinutes,
);
final visualHeight = geometryHeight < 1 ? 1.0 : geometryHeight;
final left = eventAreaLeft + item.column * (columnWidth + columnGap);
layouts.add(
DayEventLayout(
event: item.event,
startMinutes: item.startMinutes,
endMinutes: item.endMinutes,
column: item.column,
columnCount: clusterColumnCount,
top: top,
geometryHeight: geometryHeight,
visualHeight: visualHeight,
left: left,
width: columnWidth,
),
);
}
}
return layouts;
}
}
class _EventSpan {
final ScheduleItemModel event;
final int startMinutes;
final int endMinutes;
const _EventSpan({
required this.event,
required this.startMinutes,
required this.endMinutes,
});
factory _EventSpan.fromEvent(ScheduleItemModel event) {
final start = _minutesOfDay(event.startAt);
final end = event.endAt != null ? _minutesOfDay(event.endAt!) : start + 60;
final clampedStart = DayTimelineMetrics.clampMinuteOfDay(start);
var clampedEnd = DayTimelineMetrics.clampMinuteOfDay(end);
if (clampedEnd <= clampedStart) {
clampedEnd = DayTimelineMetrics.clampMinuteOfDay(clampedStart + 1);
}
return _EventSpan(
event: event,
startMinutes: clampedStart,
endMinutes: clampedEnd,
);
}
}
class _PlacedSpan {
final ScheduleItemModel event;
final int startMinutes;
final int endMinutes;
final int column;
const _PlacedSpan({
required this.event,
required this.startMinutes,
required this.endMinutes,
required this.column,
});
}
int _minutesOfDay(DateTime dateTime) {
return dateTime.hour * 60 + dateTime.minute;
}
@@ -0,0 +1,29 @@
import 'day_view_scale.dart';
class DayTimelineMetrics {
static const int hoursInDay = 24;
static const int minutesInHour = 60;
static const int minutesInDay = hoursInDay * minutesInHour;
static const double timeLabelWidth = 44;
static const double timeLabelGap = 8;
static const double eventRightInset = 4;
static const double eventColumnGap = 4;
static double timelineHeight(DayViewScale scale) {
return scale.pixelsForMinutes(minutesInDay);
}
static double eventAreaLeft() {
return timeLabelWidth + timeLabelGap;
}
static double eventAreaWidth(double boardWidth) {
final width = boardWidth - eventAreaLeft() - eventRightInset;
return width > 0 ? width : 0;
}
static int clampMinuteOfDay(int minute) {
return minute.clamp(0, minutesInDay).toInt();
}
}
@@ -0,0 +1,38 @@
class DayViewScale {
static const double defaultHourHeight = 34.0;
static const double minHourHeight = 17.0;
static const double maxHourHeight = 68.0;
final double hourHeight;
const DayViewScale({required this.hourHeight});
factory DayViewScale.defaultScale() {
return const DayViewScale(hourHeight: defaultHourHeight);
}
DayViewScale copyWith({double? hourHeight}) {
return DayViewScale(
hourHeight: _clampHourHeight(hourHeight ?? this.hourHeight),
);
}
DayViewScale zoomByFactor(double factor) {
if (factor <= 0) {
return this;
}
return copyWith(hourHeight: hourHeight * factor);
}
double pixelsForMinutes(int minutes) {
return (minutes / 60) * hourHeight;
}
double minutesForPixels(double pixels) {
return (pixels / hourHeight) * 60;
}
static double _clampHourHeight(double value) {
return value.clamp(minHourHeight, maxHourHeight);
}
}