feat: app 骨架 + 网络层 + 认证(登录/注册)

- flutter create 初始化 + 依赖(riverpod 3/dio/go_router/three_dart 0.0.16)
- ApiClient:JWT 拦截器 + 统一响应解包 + 401 登出回调 + multipart 上传
- TokenStorage(shared_preferences)+ AuthNotifier 登录/登出
- 登录页(含注册对话框)+ 4 Tab 主框架
- 6 个单元测试通过(网络层 4 + 认证 2)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 12:39:01 +08:00
co-authored by Claude Opus 4.7
commit acbe60c13c
144 changed files with 6887 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:slogan_app/core/network/api_client.dart';
import 'package:slogan_app/core/network/api_exception.dart';
import 'package:slogan_app/core/storage/token_storage.dart';
/// 可编程 mock 的 Dio adapter
class MockAdapter implements HttpClientAdapter {
Response Function(RequestOptions options)? onRequest;
@override
Future<ResponseBody> fetch(RequestOptions options,
Stream<Uint8List>? requestStream, Future<void>? cancelFuture) async {
final r = onRequest?.call(options);
if (r == null) return ResponseBody.fromString('', 500);
return ResponseBody.fromString(
jsonEncode(r.data), r.statusCode ?? 200,
headers: {
Headers.contentTypeHeader: [Headers.jsonContentType]
});
}
@override
void close({bool force = false}) {}
}
class _FakeTokenStorage extends TokenStorage {
String? _t;
_FakeTokenStorage(this._t);
@override
String? get token => _t;
@override
Future<void> load() async {}
@override
Future<void> save(String token) async => _t = token;
@override
Future<void> clear() async => _t = null;
}
void main() {
late MockAdapter adapter;
late ApiClient client;
setUp(() {
adapter = MockAdapter();
client = ApiClient(
tokenStorage: _FakeTokenStorage('test-token'),
dio: Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter);
});
test('成功响应解析 data', () async {
adapter.onRequest = (o) => Response(
requestOptions: o,
data: {'code': 0, 'message': 'OK', 'data': {'id': 1}});
final id = await client.post<Map<String, dynamic>>('/x', {});
expect(id['id'], 1);
});
test('code != 0 抛 ApiException 带 message', () async {
adapter.onRequest = (o) => Response(
requestOptions: o,
data: {'code': 50, 'message': '衣橱服装不足', 'data': null});
expect(
() => client.get<Map<String, dynamic>>('/x'),
throwsA(isA<ApiException>()
.having((e) => e.code, 'code', 50)
.having((e) => e.message, 'message', '衣橱服装不足')),
);
});
test('请求携带 Bearer token', () async {
String? auth;
adapter.onRequest = (o) {
auth = o.headers['Authorization'];
return Response(
requestOptions: o, data: {'code': 0, 'message': 'OK', 'data': null});
};
await client.get<dynamic>('/x');
expect(auth, 'Bearer test-token');
});
test('401 触发登出回调', () async {
var called = false;
client.onUnauthorized = () => called = true;
adapter.onRequest = (o) => Response(
requestOptions: o,
statusCode: 401,
data: {'code': 401, 'message': '未登录', 'data': null});
try {
await client.get<dynamic>('/x');
} catch (_) {}
expect(called, true);
});
}