docs(webrtc_controller_flutter): 更新项目文档以反映重构后的架构
AGENTS.md 与 README.md 同步更新:根据实际代码结构重写目录树、技术栈、架构分层及编码规范,移除旧版内联示例并补充新的开发约定与代码生成命令。
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../domain/auth_repository.dart';
|
||||
import 'dio_auth_repository.dart';
|
||||
|
||||
/// 认证仓库单例(登录 / 刷新 / 绑定列表 / TURN 凭证)。
|
||||
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
||||
return DioAuthRepository();
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../app/constants/app_constants.dart';
|
||||
import '../../../core/network/api_exception.dart';
|
||||
import '../../../core/storage/token_storage.dart';
|
||||
import '../domain/auth_repository.dart';
|
||||
|
||||
/// 基于 Dio 的 [AuthRepository] 实现。
|
||||
class DioAuthRepository implements AuthRepository {
|
||||
DioAuthRepository({Dio? dio, TokenStorage? storage})
|
||||
: _dio = dio ?? Dio(_baseOptions),
|
||||
_storage = storage ?? const TokenStorage();
|
||||
|
||||
static final BaseOptions _baseOptions = BaseOptions(
|
||||
baseUrl: kApiBase,
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
|
||||
final Dio _dio;
|
||||
final TokenStorage _storage;
|
||||
|
||||
// 刷新单飞:避免并发触发多次刷新。
|
||||
Future<Map<String, dynamic>>? _refreshing;
|
||||
|
||||
String? _accessToken;
|
||||
|
||||
@override
|
||||
String? get accessToken => _accessToken;
|
||||
|
||||
@override
|
||||
Future<void> restore() async {
|
||||
_accessToken = await _storage.readAccess();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> hasRefreshToken() async =>
|
||||
await _storage.readRefresh() != null;
|
||||
|
||||
@override
|
||||
Future<String?> getUsername() => _storage.readUsername();
|
||||
|
||||
Future<void> _saveTokens({
|
||||
required String accessToken,
|
||||
String? refreshToken,
|
||||
String? username,
|
||||
}) async {
|
||||
_accessToken = accessToken;
|
||||
await _storage.writeAccess(accessToken);
|
||||
if (refreshToken != null) {
|
||||
await _storage.writeRefresh(refreshToken);
|
||||
}
|
||||
if (username != null) {
|
||||
await _storage.writeUsername(username);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> login(String username, String password) async {
|
||||
final data = await _post(
|
||||
'/api/auth/login',
|
||||
body: {'username': username, 'password': password},
|
||||
);
|
||||
await _saveTokens(
|
||||
accessToken: data['accessToken'] as String,
|
||||
refreshToken: data['refreshToken'] as String?,
|
||||
username: username,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> register(String username, String password) =>
|
||||
_post('/api/auth/register',
|
||||
body: {'username': username, 'password': password});
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> refresh() async {
|
||||
final rt = await _storage.readRefresh();
|
||||
if (rt == null) {
|
||||
await clear();
|
||||
throw const ApiException('NO_REFRESH_TOKEN', 401);
|
||||
}
|
||||
_refreshing ??= _doRefresh(rt).whenComplete(() => _refreshing = null);
|
||||
return _refreshing!;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _doRefresh(String rt) async {
|
||||
final data = await _post('/api/auth/refresh', body: {'refreshToken': rt});
|
||||
await _saveTokens(
|
||||
accessToken: data['accessToken'] as String,
|
||||
refreshToken: data['refreshToken'] as String?,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> verify() => _get('/api/client/verify');
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> bindings() => _get('/api/client/bindings');
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>?> turnCredentials() async {
|
||||
try {
|
||||
return await _get('/api/client/turn-credentials');
|
||||
} on ApiException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
_accessToken = null;
|
||||
await _storage.clear();
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _post(
|
||||
String path, {
|
||||
required Map<String, dynamic> body,
|
||||
}) async {
|
||||
final res = await _dio.post<dynamic>(
|
||||
path,
|
||||
data: jsonEncode(body),
|
||||
);
|
||||
return _handle(res);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _get(String path) async {
|
||||
final res = await _dio.get<dynamic>(
|
||||
path,
|
||||
options: Options(
|
||||
headers: {
|
||||
if (_accessToken != null) 'Authorization': 'Bearer $_accessToken',
|
||||
},
|
||||
),
|
||||
);
|
||||
return _handle(res);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _handle(Response<dynamic> res) {
|
||||
final data = res.data;
|
||||
final body = (data is Map)
|
||||
? data.cast<String, dynamic>()
|
||||
: <String, dynamic>{};
|
||||
if (res.statusCode == 401) {
|
||||
throw ApiException(body['code'] as String? ?? 'UNAUTHORIZED', 401);
|
||||
}
|
||||
if (res.statusCode == null || res.statusCode! < 200 || res.statusCode! >= 300) {
|
||||
throw ApiException(
|
||||
body['error'] as String? ?? 'HTTP ${res.statusCode}',
|
||||
res.statusCode ?? -1,
|
||||
);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/// 账号体系与自助接口抽象(对应服务端 /api/auth/* 与 /api/client/*)。
|
||||
abstract interface class AuthRepository {
|
||||
/// 内存中的 accessToken(掉线即失)。
|
||||
String? get accessToken;
|
||||
|
||||
/// 从安全存储恢复令牌(应用启动时调用)。
|
||||
Future<void> restore();
|
||||
|
||||
Future<bool> hasRefreshToken();
|
||||
|
||||
Future<String?> getUsername();
|
||||
|
||||
Future<Map<String, dynamic>> login(String username, String password);
|
||||
|
||||
Future<Map<String, dynamic>> register(String username, String password);
|
||||
|
||||
/// 刷新令牌:带单飞锁,并发调用共享同一次刷新结果。可能轮换 refreshToken。
|
||||
Future<Map<String, dynamic>> refresh();
|
||||
|
||||
Future<Map<String, dynamic>> verify();
|
||||
|
||||
Future<Map<String, dynamic>> bindings();
|
||||
|
||||
/// 拉取 TURN 短期凭证(服务端开启时返回 iceServers);关闭时返回 null。
|
||||
Future<Map<String, dynamic>?> turnCredentials();
|
||||
|
||||
Future<void> clear();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'auth_state.freezed.dart';
|
||||
|
||||
/// 登录状态。
|
||||
@freezed
|
||||
class AuthState with _$AuthState {
|
||||
const factory AuthState({
|
||||
@Default(false) bool loggedIn,
|
||||
@Default(false) bool restoring,
|
||||
String? username,
|
||||
String? error,
|
||||
}) = _AuthState;
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'auth_state.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
|
||||
);
|
||||
|
||||
/// @nodoc
|
||||
mixin _$AuthState {
|
||||
bool get loggedIn => throw _privateConstructorUsedError;
|
||||
bool get restoring => throw _privateConstructorUsedError;
|
||||
String? get username => throw _privateConstructorUsedError;
|
||||
String? get error => throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of AuthState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$AuthStateCopyWith<AuthState> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $AuthStateCopyWith<$Res> {
|
||||
factory $AuthStateCopyWith(AuthState value, $Res Function(AuthState) then) =
|
||||
_$AuthStateCopyWithImpl<$Res, AuthState>;
|
||||
@useResult
|
||||
$Res call({bool loggedIn, bool restoring, String? username, String? error});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$AuthStateCopyWithImpl<$Res, $Val extends AuthState>
|
||||
implements $AuthStateCopyWith<$Res> {
|
||||
_$AuthStateCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of AuthState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? loggedIn = null,
|
||||
Object? restoring = null,
|
||||
Object? username = freezed,
|
||||
Object? error = freezed,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
loggedIn: null == loggedIn
|
||||
? _value.loggedIn
|
||||
: loggedIn // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
restoring: null == restoring
|
||||
? _value.restoring
|
||||
: restoring // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
username: freezed == username
|
||||
? _value.username
|
||||
: username // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
error: freezed == error
|
||||
? _value.error
|
||||
: error // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
)
|
||||
as $Val,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$AuthStateImplCopyWith<$Res>
|
||||
implements $AuthStateCopyWith<$Res> {
|
||||
factory _$$AuthStateImplCopyWith(
|
||||
_$AuthStateImpl value,
|
||||
$Res Function(_$AuthStateImpl) then,
|
||||
) = __$$AuthStateImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call({bool loggedIn, bool restoring, String? username, String? error});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$AuthStateImplCopyWithImpl<$Res>
|
||||
extends _$AuthStateCopyWithImpl<$Res, _$AuthStateImpl>
|
||||
implements _$$AuthStateImplCopyWith<$Res> {
|
||||
__$$AuthStateImplCopyWithImpl(
|
||||
_$AuthStateImpl _value,
|
||||
$Res Function(_$AuthStateImpl) _then,
|
||||
) : super(_value, _then);
|
||||
|
||||
/// Create a copy of AuthState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? loggedIn = null,
|
||||
Object? restoring = null,
|
||||
Object? username = freezed,
|
||||
Object? error = freezed,
|
||||
}) {
|
||||
return _then(
|
||||
_$AuthStateImpl(
|
||||
loggedIn: null == loggedIn
|
||||
? _value.loggedIn
|
||||
: loggedIn // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
restoring: null == restoring
|
||||
? _value.restoring
|
||||
: restoring // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
username: freezed == username
|
||||
? _value.username
|
||||
: username // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
error: freezed == error
|
||||
? _value.error
|
||||
: error // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
class _$AuthStateImpl implements _AuthState {
|
||||
const _$AuthStateImpl({
|
||||
this.loggedIn = false,
|
||||
this.restoring = false,
|
||||
this.username,
|
||||
this.error,
|
||||
});
|
||||
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool loggedIn;
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool restoring;
|
||||
@override
|
||||
final String? username;
|
||||
@override
|
||||
final String? error;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AuthState(loggedIn: $loggedIn, restoring: $restoring, username: $username, error: $error)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$AuthStateImpl &&
|
||||
(identical(other.loggedIn, loggedIn) ||
|
||||
other.loggedIn == loggedIn) &&
|
||||
(identical(other.restoring, restoring) ||
|
||||
other.restoring == restoring) &&
|
||||
(identical(other.username, username) ||
|
||||
other.username == username) &&
|
||||
(identical(other.error, error) || other.error == error));
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(runtimeType, loggedIn, restoring, username, error);
|
||||
|
||||
/// Create a copy of AuthState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$AuthStateImplCopyWith<_$AuthStateImpl> get copyWith =>
|
||||
__$$AuthStateImplCopyWithImpl<_$AuthStateImpl>(this, _$identity);
|
||||
}
|
||||
|
||||
abstract class _AuthState implements AuthState {
|
||||
const factory _AuthState({
|
||||
final bool loggedIn,
|
||||
final bool restoring,
|
||||
final String? username,
|
||||
final String? error,
|
||||
}) = _$AuthStateImpl;
|
||||
|
||||
@override
|
||||
bool get loggedIn;
|
||||
@override
|
||||
bool get restoring;
|
||||
@override
|
||||
String? get username;
|
||||
@override
|
||||
String? get error;
|
||||
|
||||
/// Create a copy of AuthState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$AuthStateImplCopyWith<_$AuthStateImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../data/auth_providers.dart';
|
||||
import '../domain/auth_repository.dart';
|
||||
import '../domain/auth_state.dart';
|
||||
|
||||
part 'auth_controller.g.dart';
|
||||
|
||||
/// 登录状态控制器。
|
||||
@Riverpod(keepAlive: true)
|
||||
class AuthController extends _$AuthController {
|
||||
AuthRepository get _repository => ref.read(authRepositoryProvider);
|
||||
|
||||
@override
|
||||
FutureOr<AuthState> build() async {
|
||||
final repository = ref.watch(authRepositoryProvider);
|
||||
await repository.restore();
|
||||
final loggedIn =
|
||||
repository.accessToken != null && await repository.hasRefreshToken();
|
||||
return AuthState(
|
||||
loggedIn: loggedIn,
|
||||
restoring: false,
|
||||
username: await repository.getUsername(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 登录:成功返回 true,失败返回 false 并写入错误信息。
|
||||
Future<bool> login(String username, String password) async {
|
||||
try {
|
||||
await _repository.login(username, password);
|
||||
state = AsyncData(
|
||||
AuthState(loggedIn: true, username: username),
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
state = AsyncData(
|
||||
AuthState(loggedIn: false, error: e.toString()),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 退出登录:清空令牌并复位状态。
|
||||
Future<void> logout() async {
|
||||
await _repository.clear();
|
||||
state = const AsyncData(AuthState());
|
||||
}
|
||||
|
||||
/// 清空错误信息。
|
||||
void clearError() {
|
||||
final current = state.valueOrNull;
|
||||
if (current != null && current.error != null) {
|
||||
state = AsyncData(current.copyWith(error: null));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'auth_controller.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$authControllerHash() => r'35d9a474535207949af85ca44019d70f50afd7c2';
|
||||
|
||||
/// 登录状态控制器。
|
||||
///
|
||||
/// Copied from [AuthController].
|
||||
@ProviderFor(AuthController)
|
||||
final authControllerProvider =
|
||||
AsyncNotifierProvider<AuthController, AuthState>.internal(
|
||||
AuthController.new,
|
||||
name: r'authControllerProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$authControllerHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$AuthController = AsyncNotifier<AuthState>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:webrtc_controller_flutter/l10n/app_localizations.dart';
|
||||
|
||||
import '../auth_controller.dart';
|
||||
|
||||
/// 登录对话框:输入用户名/密码,调用登录保存令牌。
|
||||
class LoginDialog extends ConsumerStatefulWidget {
|
||||
const LoginDialog({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginDialog> createState() => _LoginDialogState();
|
||||
}
|
||||
|
||||
class _LoginDialogState extends ConsumerState<LoginDialog> {
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return CupertinoAlertDialog(
|
||||
title: Text(l10n.login),
|
||||
content: Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Column(
|
||||
children: [
|
||||
CupertinoTextField(
|
||||
controller: _usernameController,
|
||||
placeholder: l10n.username,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
CupertinoTextField(
|
||||
controller: _passwordController,
|
||||
placeholder: l10n.password,
|
||||
obscureText: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
child: Text(l10n.cancel),
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
CupertinoDialogAction(
|
||||
child: Text(l10n.login),
|
||||
onPressed: () async {
|
||||
final user = _usernameController.text.trim();
|
||||
final pass = _passwordController.text.trim();
|
||||
if (user.isEmpty || pass.isEmpty) {
|
||||
_showAlert(context, l10n.usernamePasswordRequired);
|
||||
return;
|
||||
}
|
||||
final ok = await ref
|
||||
.read(authControllerProvider.notifier)
|
||||
.login(user, pass);
|
||||
if (!context.mounted) return;
|
||||
if (ok) {
|
||||
Navigator.of(context).pop(true);
|
||||
} else {
|
||||
final error = ref.read(authControllerProvider).valueOrNull?.error;
|
||||
_showAlert(
|
||||
context,
|
||||
l10n.loginFailed(error ?? ''),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static void _showAlert(BuildContext context, String message) {
|
||||
showCupertinoDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => CupertinoAlertDialog(
|
||||
content: Text(message),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
child: const Text('确定'),
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user