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
+33 -2
View File
@@ -36,8 +36,13 @@ void main() {
when(() => tokenStorage.getAccessToken()).thenAnswer((_) async => null);
});
DioException _unauthorized(String path) {
final requestOptions = RequestOptions(path: path);
DioException _unauthorized(String path, {bool withAuthHeader = false}) {
final requestOptions = RequestOptions(
path: path,
headers: withAuthHeader
? <String, dynamic>{'Authorization': 'Bearer expired'}
: null,
);
return DioException(
requestOptions: requestOptions,
response: Response<dynamic>(
@@ -109,4 +114,30 @@ void main() {
expect(refreshCalls, 1);
});
test('并发401刷新失败仅触发一次auth failure回调', () async {
var refreshCalls = 0;
var authFailureCalls = 0;
interceptor.onTokenRefresh = () async {
refreshCalls += 1;
await Future<void>.delayed(const Duration(milliseconds: 20));
return false;
};
interceptor.onAuthFailure = () async {
authFailureCalls += 1;
};
interceptor.onError(
_unauthorized('/api/v1/agent/history', withAuthHeader: true),
handler,
);
interceptor.onError(
_unauthorized('/api/v1/agent/history', withAuthHeader: true),
handler,
);
await Future<void>.delayed(const Duration(milliseconds: 80));
expect(refreshCalls, 1);
expect(authFailureCalls, 1);
});
}
@@ -98,6 +98,18 @@ void main() {
},
);
test(
'clearSessionLocalOnly clears local tokens without api revoke',
() async {
when(() => mockStorage.clear()).thenAnswer((_) async {});
await repository.clearSessionLocalOnly();
verify(() => mockStorage.clear()).called(1);
verifyNever(() => mockApi.deleteSession(any()));
},
);
test('refreshSession saves new tokens', () async {
when(() => mockApi.refreshSession(any())).thenAnswer(
(_) async => AuthResponse(
@@ -32,7 +32,10 @@ void main() {
return authBloc;
},
act: (bloc) => bloc.add(AuthStarted()),
expect: () => [AuthLoading(), AuthUnauthenticated()],
expect: () => [
AuthLoading(),
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut),
],
);
blocTest<AuthBloc, AuthState>(
@@ -65,11 +68,38 @@ void main() {
when(
() => mockRepository.refreshSession('expired_refresh'),
).thenThrow(Exception('Invalid refresh token'));
when(() => mockRepository.deleteSession()).thenAnswer((_) async {});
when(
() => mockRepository.clearSessionLocalOnly(),
).thenAnswer((_) async {});
return authBloc;
},
act: (bloc) => bloc.add(AuthStarted()),
expect: () => [AuthLoading(), AuthUnauthenticated()],
expect: () => [
AuthLoading(),
const AuthUnauthenticated(
reason: AuthUnauthenticatedReason.startupRecoveryFailed,
),
],
);
blocTest<AuthBloc, AuthState>(
'emits startupRecoveryFailed when storage read throws',
build: () {
when(
() => mockRepository.getRefreshToken(),
).thenThrow(Exception('storage failed'));
when(
() => mockRepository.clearSessionLocalOnly(),
).thenAnswer((_) async {});
return authBloc;
},
act: (bloc) => bloc.add(AuthStarted()),
expect: () => [
AuthLoading(),
const AuthUnauthenticated(
reason: AuthUnauthenticatedReason.startupRecoveryFailed,
),
],
);
blocTest<AuthBloc, AuthState>(
@@ -93,7 +123,30 @@ void main() {
user: const AuthUser(id: '1', email: 'test@example.com'),
),
act: (bloc) => bloc.add(AuthLoggedOut()),
expect: () => [AuthUnauthenticated()],
expect: () => [
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut),
],
);
blocTest<AuthBloc, AuthState>(
'emits expired unauthenticated when session invalidated',
build: () {
when(
() => mockRepository.clearSessionLocalOnly(),
).thenAnswer((_) async {});
return authBloc;
},
seed: () => AuthAuthenticated(
user: const AuthUser(id: '1', email: 'test@example.com'),
),
act: (bloc) => bloc.add(
const AuthSessionInvalidated(
source: AuthInvalidationSource.unauthorized401,
),
),
expect: () => [
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.expired),
],
);
});
}
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/features/calendar/data/models/schedule_item_model.dart';
import 'package:social_app/features/calendar/ui/widgets/create_event_sheet.dart';
void main() {
testWidgets('编辑日程时支持非默认提醒值', (tester) async {
final event = ScheduleItemModel(
id: 'evt_1',
ownerId: 'user_1',
title: '测试日程',
startAt: DateTime(2026, 3, 18, 10, 0),
endAt: DateTime(2026, 3, 18, 11, 0),
metadata: ScheduleMetadata(reminderMinutes: 20),
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(body: CreateEventSheet(editingEvent: event)),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('进阶'));
await tester.pumpAndSettle();
expect(find.text('开始前20分钟'), findsOneWidget);
});
}
@@ -0,0 +1,104 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/features/calendar/data/models/schedule_item_model.dart';
import 'package:social_app/features/calendar/ui/dayweek/day_event_layout_engine.dart';
import 'package:social_app/features/calendar/ui/dayweek/day_timeline_metrics.dart';
import 'package:social_app/features/calendar/ui/dayweek/day_view_scale.dart';
void main() {
group('DayEventLayoutEngine', () {
const engine = DayEventLayoutEngine();
const scale = DayViewScale(hourHeight: 60);
test('maps event top and height by exact minutes', () {
final event = _event(
id: 'a',
start: DateTime(2026, 3, 18, 10, 15),
end: DateTime(2026, 3, 18, 11, 45),
);
final layouts = engine.layout(
events: [event],
scale: scale,
eventAreaLeft: DayTimelineMetrics.eventAreaLeft(),
eventAreaWidth: 200,
);
expect(layouts, hasLength(1));
expect(layouts.first.startMinutes, 615);
expect(layouts.first.endMinutes, 705);
expect(layouts.first.top, 615);
expect(layouts.first.geometryHeight, 90);
expect(layouts.first.visualHeight, 90);
});
test('splits overlapped events into columns', () {
final e1 = _event(
id: 'a',
start: DateTime(2026, 3, 18, 9, 0),
end: DateTime(2026, 3, 18, 10, 0),
);
final e2 = _event(
id: 'b',
start: DateTime(2026, 3, 18, 9, 30),
end: DateTime(2026, 3, 18, 10, 30),
);
final layouts = engine.layout(
events: [e1, e2],
scale: scale,
eventAreaLeft: DayTimelineMetrics.eventAreaLeft(),
eventAreaWidth: 200,
);
expect(layouts, hasLength(2));
expect(layouts[0].columnCount, 2);
expect(layouts[1].columnCount, 2);
expect(layouts[0].width, closeTo(98, 0.001));
expect(layouts[1].width, closeTo(98, 0.001));
expect(layouts[0].left, DayTimelineMetrics.eventAreaLeft());
expect(
layouts[1].left,
closeTo(DayTimelineMetrics.eventAreaLeft() + 102, 0.001),
);
});
test('uses 1 pixel minimum visual height but preserves geometry', () {
final event = _event(
id: 'a',
start: DateTime(2026, 3, 18, 9, 0),
end: DateTime(2026, 3, 18, 9, 1),
);
final tinyScale = const DayViewScale(
hourHeight: DayViewScale.minHourHeight,
);
final layouts = engine.layout(
events: [event],
scale: tinyScale,
eventAreaLeft: DayTimelineMetrics.eventAreaLeft(),
eventAreaWidth: 200,
);
expect(layouts, hasLength(1));
expect(
layouts.first.geometryHeight,
closeTo(tinyScale.pixelsForMinutes(1), 0.001),
);
expect(layouts.first.visualHeight, greaterThanOrEqualTo(1));
});
});
}
ScheduleItemModel _event({
required String id,
required DateTime start,
required DateTime end,
}) {
return ScheduleItemModel(
id: id,
ownerId: 'owner',
title: 'event-$id',
startAt: start,
endAt: end,
);
}
@@ -0,0 +1,31 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/features/calendar/ui/dayweek/day_view_scale.dart';
void main() {
group('DayViewScale', () {
test('maps minutes to pixels and back', () {
const scale = DayViewScale(hourHeight: 60);
expect(scale.pixelsForMinutes(30), 30);
expect(scale.pixelsForMinutes(75), 75);
expect(scale.minutesForPixels(90), 90);
});
test('clamps zoom height at boundaries', () {
const scale = DayViewScale(hourHeight: 34);
final zoomIn = scale.zoomByFactor(20);
final zoomOut = scale.zoomByFactor(0.01);
expect(zoomIn.hourHeight, DayViewScale.maxHourHeight);
expect(zoomOut.hourHeight, DayViewScale.minHourHeight);
});
test('ignores invalid zoom factor', () {
const scale = DayViewScale(hourHeight: 34);
expect(scale.zoomByFactor(0).hourHeight, 34);
expect(scale.zoomByFactor(-1).hourHeight, 34);
});
});
}
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:social_app/features/chat/ui/widgets/ui_schema_renderer.dart';
void main() {
@@ -128,5 +129,93 @@ void main() {
expect(find.textContaining('无效 UI Schema'), findsOneWidget);
});
testWidgets('handles navigation action and jumps by path', (tester) async {
final schema = {
'version': '2.0',
'root': {
'type': 'stack',
'direction': 'vertical',
'appearance': 'plain',
'children': [
{
'type': 'button',
'label': '查看待办',
'style': 'primary',
'action': {
'type': 'navigation',
'path': '/todo/123',
'params': {'from': 'assistant'},
},
},
],
},
};
final router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (context, state) =>
Scaffold(body: UiSchemaRenderer.renderSchema(schema)),
),
GoRoute(
path: '/todo/:id',
builder: (context, state) => Text(
'todo detail ${state.pathParameters['id']} from ${state.uri.queryParameters['from']}',
),
),
],
);
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
await tester.tap(find.text('查看待办'));
await tester.pumpAndSettle();
expect(find.text('todo detail 123 from assistant'), findsOneWidget);
});
testWidgets('does not navigate for placeholder path', (tester) async {
final schema = {
'version': '2.0',
'root': {
'type': 'stack',
'direction': 'vertical',
'appearance': 'plain',
'children': [
{
'type': 'button',
'label': '坏路径',
'style': 'primary',
'action': {'type': 'navigation', 'path': '/todo/:id'},
},
],
},
};
final router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (context, state) =>
Scaffold(body: UiSchemaRenderer.renderSchema(schema)),
),
GoRoute(
path: '/todo/:id',
builder: (context, state) => const Text('detail'),
),
],
);
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
await tester.tap(find.text('坏路径'));
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 3));
expect(find.text('坏路径'), findsOneWidget);
expect(find.text('detail'), findsNothing);
});
});
}