docs(webrtc_controller_flutter): 更新项目文档以反映重构后的架构

AGENTS.md 与 README.md 同步更新:根据实际代码结构重写目录树、技术栈、架构分层及编码规范,移除旧版内联示例并补充新的开发约定与代码生成命令。
This commit is contained in:
2026-08-03 16:12:44 +08:00
parent 1918e5738e
commit d5e66a1777
51 changed files with 4711 additions and 1458 deletions

View File

@@ -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();
});

View File

@@ -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;
}
}

View File

@@ -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();
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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));
}
}
}

View File

@@ -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

View File

@@ -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(),
),
],
),
);
}
}