feat(auth): 添加登录/注册页面及令牌恢复流程
- 新增 SplashPage(启动页)和 LoginPage(登录页),支持路由跳转 - 扩展 AuthState,增加 submitting/alert/busy 状态及消费机制 - AuthController 增加 checkLoginState/register/showAlert 等方法 - 删除旧模型文件(binding_item, token_response, verify_response) - 删除旧版 LoginPage,迁移至 features/auth 目录 - 更新本地化字符串,增加注册相关文案 - 调整默认 API 地址为本地开发环境
This commit is contained in:
@@ -3,11 +3,11 @@
|
||||
/// 服务端 HTTP 基址(与信令同源)。部署时通过 --dart-define=API_BASE= 注入。
|
||||
const String kApiBase = String.fromEnvironment(
|
||||
'API_BASE',
|
||||
defaultValue: 'https://www.ttstd.com',
|
||||
defaultValue: 'http://192.168.5.224:8080',
|
||||
);
|
||||
|
||||
/// 信令 WebSocket 默认地址。
|
||||
const String kDefaultSignalServer = 'wss://www.ttstd.com/signal';
|
||||
const String kDefaultSignalServer = 'ws://192.168.5.224:8080/ws/signal';
|
||||
|
||||
/// DataChannel 标签,控制端与被控端需一致。
|
||||
const String kDataChannelLabel = 'control_channel';
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../features/auth/presentation/pages/login_page.dart';
|
||||
import '../../features/auth/presentation/pages/splash_page.dart';
|
||||
import '../../features/connection/presentation/pages/control_page.dart';
|
||||
import '../../features/connection/presentation/pages/setup_page.dart';
|
||||
|
||||
/// 应用路由:/ = 连接设置页,/control = 控制面板页。
|
||||
/// 应用路由。
|
||||
///
|
||||
/// - `/splash`:启动页,恢复并校验令牌后重定向(初始路由)
|
||||
/// - `/login`:登录页,支持通过 `message` 查询参数展示失效提示
|
||||
/// - `/`:连接设置页
|
||||
/// - `/control`:控制面板页
|
||||
final appRouter = GoRouter(
|
||||
initialLocation: '/',
|
||||
initialLocation: '/splash',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/splash',
|
||||
name: 'splash',
|
||||
builder: (context, state) => const SplashPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
builder: (context, state) => LoginPage(
|
||||
message: state.uri.queryParameters['message'],
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/',
|
||||
name: 'setup',
|
||||
|
||||
@@ -3,12 +3,22 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
part 'auth_state.freezed.dart';
|
||||
|
||||
/// 登录状态。
|
||||
///
|
||||
/// [alert] 为一次性提示(登录失效、注册成功等),UI 展示后须调用
|
||||
/// `AuthController.consumeAlert()` 消费,避免重建时重复弹出。
|
||||
@freezed
|
||||
class AuthState with _$AuthState {
|
||||
const AuthState._();
|
||||
|
||||
const factory AuthState({
|
||||
@Default(false) bool loggedIn,
|
||||
@Default(false) bool restoring,
|
||||
@Default(false) bool submitting,
|
||||
String? username,
|
||||
String? error,
|
||||
String? alert,
|
||||
}) = _AuthState;
|
||||
|
||||
/// 是否应禁用输入与提交按钮。
|
||||
bool get busy => submitting || restoring;
|
||||
}
|
||||
|
||||
@@ -19,8 +19,10 @@ final _privateConstructorUsedError = UnsupportedError(
|
||||
mixin _$AuthState {
|
||||
bool get loggedIn => throw _privateConstructorUsedError;
|
||||
bool get restoring => throw _privateConstructorUsedError;
|
||||
bool get submitting => throw _privateConstructorUsedError;
|
||||
String? get username => throw _privateConstructorUsedError;
|
||||
String? get error => throw _privateConstructorUsedError;
|
||||
String? get alert => throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of AuthState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@@ -34,7 +36,14 @@ 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});
|
||||
$Res call({
|
||||
bool loggedIn,
|
||||
bool restoring,
|
||||
bool submitting,
|
||||
String? username,
|
||||
String? error,
|
||||
String? alert,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@@ -54,8 +63,10 @@ class _$AuthStateCopyWithImpl<$Res, $Val extends AuthState>
|
||||
$Res call({
|
||||
Object? loggedIn = null,
|
||||
Object? restoring = null,
|
||||
Object? submitting = null,
|
||||
Object? username = freezed,
|
||||
Object? error = freezed,
|
||||
Object? alert = freezed,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
@@ -67,6 +78,10 @@ class _$AuthStateCopyWithImpl<$Res, $Val extends AuthState>
|
||||
? _value.restoring
|
||||
: restoring // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
submitting: null == submitting
|
||||
? _value.submitting
|
||||
: submitting // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
username: freezed == username
|
||||
? _value.username
|
||||
: username // ignore: cast_nullable_to_non_nullable
|
||||
@@ -75,6 +90,10 @@ class _$AuthStateCopyWithImpl<$Res, $Val extends AuthState>
|
||||
? _value.error
|
||||
: error // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
alert: freezed == alert
|
||||
? _value.alert
|
||||
: alert // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
)
|
||||
as $Val,
|
||||
);
|
||||
@@ -90,7 +109,14 @@ abstract class _$$AuthStateImplCopyWith<$Res>
|
||||
) = __$$AuthStateImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call({bool loggedIn, bool restoring, String? username, String? error});
|
||||
$Res call({
|
||||
bool loggedIn,
|
||||
bool restoring,
|
||||
bool submitting,
|
||||
String? username,
|
||||
String? error,
|
||||
String? alert,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@@ -109,8 +135,10 @@ class __$$AuthStateImplCopyWithImpl<$Res>
|
||||
$Res call({
|
||||
Object? loggedIn = null,
|
||||
Object? restoring = null,
|
||||
Object? submitting = null,
|
||||
Object? username = freezed,
|
||||
Object? error = freezed,
|
||||
Object? alert = freezed,
|
||||
}) {
|
||||
return _then(
|
||||
_$AuthStateImpl(
|
||||
@@ -122,6 +150,10 @@ class __$$AuthStateImplCopyWithImpl<$Res>
|
||||
? _value.restoring
|
||||
: restoring // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
submitting: null == submitting
|
||||
? _value.submitting
|
||||
: submitting // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
username: freezed == username
|
||||
? _value.username
|
||||
: username // ignore: cast_nullable_to_non_nullable
|
||||
@@ -130,6 +162,10 @@ class __$$AuthStateImplCopyWithImpl<$Res>
|
||||
? _value.error
|
||||
: error // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
alert: freezed == alert
|
||||
? _value.alert
|
||||
: alert // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -137,13 +173,15 @@ class __$$AuthStateImplCopyWithImpl<$Res>
|
||||
|
||||
/// @nodoc
|
||||
|
||||
class _$AuthStateImpl implements _AuthState {
|
||||
class _$AuthStateImpl extends _AuthState {
|
||||
const _$AuthStateImpl({
|
||||
this.loggedIn = false,
|
||||
this.restoring = false,
|
||||
this.submitting = false,
|
||||
this.username,
|
||||
this.error,
|
||||
});
|
||||
this.alert,
|
||||
}) : super._();
|
||||
|
||||
@override
|
||||
@JsonKey()
|
||||
@@ -152,13 +190,18 @@ class _$AuthStateImpl implements _AuthState {
|
||||
@JsonKey()
|
||||
final bool restoring;
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool submitting;
|
||||
@override
|
||||
final String? username;
|
||||
@override
|
||||
final String? error;
|
||||
@override
|
||||
final String? alert;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AuthState(loggedIn: $loggedIn, restoring: $restoring, username: $username, error: $error)';
|
||||
return 'AuthState(loggedIn: $loggedIn, restoring: $restoring, submitting: $submitting, username: $username, error: $error, alert: $alert)';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -170,14 +213,24 @@ class _$AuthStateImpl implements _AuthState {
|
||||
other.loggedIn == loggedIn) &&
|
||||
(identical(other.restoring, restoring) ||
|
||||
other.restoring == restoring) &&
|
||||
(identical(other.submitting, submitting) ||
|
||||
other.submitting == submitting) &&
|
||||
(identical(other.username, username) ||
|
||||
other.username == username) &&
|
||||
(identical(other.error, error) || other.error == error));
|
||||
(identical(other.error, error) || other.error == error) &&
|
||||
(identical(other.alert, alert) || other.alert == alert));
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(runtimeType, loggedIn, restoring, username, error);
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
loggedIn,
|
||||
restoring,
|
||||
submitting,
|
||||
username,
|
||||
error,
|
||||
alert,
|
||||
);
|
||||
|
||||
/// Create a copy of AuthState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@@ -188,22 +241,29 @@ class _$AuthStateImpl implements _AuthState {
|
||||
__$$AuthStateImplCopyWithImpl<_$AuthStateImpl>(this, _$identity);
|
||||
}
|
||||
|
||||
abstract class _AuthState implements AuthState {
|
||||
abstract class _AuthState extends AuthState {
|
||||
const factory _AuthState({
|
||||
final bool loggedIn,
|
||||
final bool restoring,
|
||||
final bool submitting,
|
||||
final String? username,
|
||||
final String? error,
|
||||
final String? alert,
|
||||
}) = _$AuthStateImpl;
|
||||
const _AuthState._() : super._();
|
||||
|
||||
@override
|
||||
bool get loggedIn;
|
||||
@override
|
||||
bool get restoring;
|
||||
@override
|
||||
bool get submitting;
|
||||
@override
|
||||
String? get username;
|
||||
@override
|
||||
String? get error;
|
||||
@override
|
||||
String? get alert;
|
||||
|
||||
/// Create a copy of AuthState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../../core/network/api_exception.dart';
|
||||
import '../data/auth_providers.dart';
|
||||
import '../domain/auth_repository.dart';
|
||||
import '../domain/auth_state.dart';
|
||||
@@ -7,6 +8,9 @@ import '../domain/auth_state.dart';
|
||||
part 'auth_controller.g.dart';
|
||||
|
||||
/// 登录状态控制器。
|
||||
///
|
||||
/// 会话级持有(`keepAlive`),负责令牌恢复、登录 / 注册 / 退出,
|
||||
/// 以及启动期的登录态校验(verify + refresh 重试)。
|
||||
@Riverpod(keepAlive: true)
|
||||
class AuthController extends _$AuthController {
|
||||
AuthRepository get _repository => ref.read(authRepositoryProvider);
|
||||
@@ -19,38 +23,143 @@ class AuthController extends _$AuthController {
|
||||
repository.accessToken != null && await repository.hasRefreshToken();
|
||||
return AuthState(
|
||||
loggedIn: loggedIn,
|
||||
restoring: false,
|
||||
username: await repository.getUsername(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 登录:成功返回 true,失败返回 false 并写入错误信息。
|
||||
AuthState get _current => state.valueOrNull ?? const AuthState();
|
||||
|
||||
/// 校验本地令牌是否仍然有效(对应启动页流程)。
|
||||
///
|
||||
/// 无令牌直接返回 false;`verify` 失败时尝试 `refresh` 后重试一次,
|
||||
/// 仍失败则清空令牌。
|
||||
Future<bool> checkLoginState() async {
|
||||
final repository = _repository;
|
||||
await repository.restore();
|
||||
if (repository.accessToken == null) {
|
||||
if (!await repository.hasRefreshToken()) {
|
||||
await _reset();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (await _verifyQuietly()) {
|
||||
state = AsyncData(
|
||||
_current.copyWith(
|
||||
loggedIn: true,
|
||||
username: await repository.getUsername(),
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// access token 失效:用 refreshToken 续期后再校验一次。
|
||||
try {
|
||||
await repository.refresh();
|
||||
} on Object {
|
||||
await _reset();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (await _verifyQuietly()) {
|
||||
state = AsyncData(
|
||||
_current.copyWith(
|
||||
loggedIn: true,
|
||||
username: await repository.getUsername(),
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
await _reset();
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> _verifyQuietly() async {
|
||||
try {
|
||||
await _repository.verify();
|
||||
return true;
|
||||
} on Object {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reset() async {
|
||||
final username = await _repository.getUsername();
|
||||
await _repository.clear();
|
||||
state = AsyncData(AuthState(username: username));
|
||||
}
|
||||
|
||||
/// 登录:成功返回 true,失败返回 false 并写入 [AuthState.error]。
|
||||
Future<bool> login(String username, String password) async {
|
||||
state = AsyncData(_current.copyWith(submitting: true, error: null));
|
||||
try {
|
||||
await _repository.login(username, password);
|
||||
state = AsyncData(
|
||||
AuthState(loggedIn: true, username: username),
|
||||
);
|
||||
state = AsyncData(AuthState(loggedIn: true, username: username));
|
||||
return true;
|
||||
} catch (e) {
|
||||
state = AsyncData(
|
||||
AuthState(loggedIn: false, error: e.toString()),
|
||||
_current.copyWith(
|
||||
submitting: false,
|
||||
loggedIn: false,
|
||||
error: _describe(e, '登录失败'),
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 退出登录:清空令牌并复位状态。
|
||||
/// 注册:成功后不自动登录,仅通过 [AuthState.alert] 提示前往登录。
|
||||
Future<bool> register(String username, String password) async {
|
||||
state = AsyncData(_current.copyWith(submitting: true, error: null));
|
||||
try {
|
||||
await _repository.register(username, password);
|
||||
state = AsyncData(
|
||||
_current.copyWith(
|
||||
submitting: false,
|
||||
error: null,
|
||||
alert: '注册成功,请登录',
|
||||
),
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
state = AsyncData(
|
||||
_current.copyWith(
|
||||
submitting: false,
|
||||
error: _describe(e, '注册失败'),
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 退出登录:清空令牌并复位状态(保留用户名用于回显)。
|
||||
Future<void> logout() async {
|
||||
await _repository.clear();
|
||||
state = const AsyncData(AuthState());
|
||||
await _reset();
|
||||
}
|
||||
|
||||
/// 展示一次性提示(如从主页因登录失效跳转时携带的文案)。
|
||||
void showAlert(String message) {
|
||||
state = AsyncData(_current.copyWith(alert: message));
|
||||
}
|
||||
|
||||
/// 消费一次性提示,避免重建时重复展示。
|
||||
void consumeAlert() {
|
||||
if (_current.alert != null) {
|
||||
state = AsyncData(_current.copyWith(alert: null));
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空错误信息。
|
||||
void clearError() {
|
||||
final current = state.valueOrNull;
|
||||
if (current != null && current.error != null) {
|
||||
state = AsyncData(current.copyWith(error: null));
|
||||
if (_current.error != null) {
|
||||
state = AsyncData(_current.copyWith(error: null));
|
||||
}
|
||||
}
|
||||
|
||||
/// 优先展示服务端返回的错误码,其余异常回落到通用提示。
|
||||
String _describe(Object e, String fallback) {
|
||||
if (e is ApiException) return e.code;
|
||||
return '$fallback:$e';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,13 @@ part of 'auth_controller.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$authControllerHash() => r'35d9a474535207949af85ca44019d70f50afd7c2';
|
||||
String _$authControllerHash() => r'49ebaa798497842d2219e19dbf93618e5e15f0de';
|
||||
|
||||
/// 登录状态控制器。
|
||||
///
|
||||
/// 会话级持有(`keepAlive`),负责令牌恢复、登录 / 注册 / 退出,
|
||||
/// 以及启动期的登录态校验(verify + refresh 重试)。
|
||||
///
|
||||
/// Copied from [AuthController].
|
||||
@ProviderFor(AuthController)
|
||||
final authControllerProvider =
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:webrtc_controller_flutter/l10n/app_localizations.dart';
|
||||
|
||||
import '../../domain/auth_state.dart';
|
||||
import '../auth_controller.dart';
|
||||
|
||||
/// 独立登录页,对应 Android 端 `activity/login/LoginActivity`。
|
||||
///
|
||||
/// - 用户名 / 密码双输入,均非空才允许提交;
|
||||
/// - 支持注册(`POST /api/auth/register`),注册成功后停留在本页提示登录;
|
||||
/// - 登录成功(`POST /api/auth/login`)后跳转设置页 `/`;
|
||||
/// - 提交过程中禁用输入与按钮,展示加载指示。
|
||||
///
|
||||
/// 业务逻辑全部位于 [AuthController],本页仅消费状态。
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
const LoginPage({super.key, this.message});
|
||||
|
||||
/// 从其他页面因登录失效跳转过来时展示的提示文案。
|
||||
final String? message;
|
||||
|
||||
@override
|
||||
ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _passwordFocus = FocusNode();
|
||||
|
||||
/// 本地校验错误(未触达网络层时的即时提示)。
|
||||
String? _localError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final notifier = ref.read(authControllerProvider.notifier);
|
||||
final message = widget.message;
|
||||
if (message != null && message.isNotEmpty) {
|
||||
notifier.showAlert(message);
|
||||
}
|
||||
// 回显上次登录的用户名(对应 LoginViewModel.lastUsername())。
|
||||
final username = ref.read(authControllerProvider).valueOrNull?.username;
|
||||
if (username != null && username.isNotEmpty) {
|
||||
_usernameController.text = username;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
_passwordFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 读取并校验输入;返回 null 表示校验未通过(错误已展示)。
|
||||
({String username, String password})? _readInput(AppLocalizations l10n) {
|
||||
final username = _usernameController.text.trim();
|
||||
final password = _passwordController.text.trim();
|
||||
if (username.isEmpty || password.isEmpty) {
|
||||
setState(() => _localError = l10n.usernamePasswordRequired);
|
||||
return null;
|
||||
}
|
||||
setState(() => _localError = null);
|
||||
return (username: username, password: password);
|
||||
}
|
||||
|
||||
Future<void> _login(AppLocalizations l10n) async {
|
||||
final input = _readInput(l10n);
|
||||
if (input == null) return;
|
||||
|
||||
final ok = await ref
|
||||
.read(authControllerProvider.notifier)
|
||||
.login(input.username, input.password);
|
||||
if (!mounted || !ok) return;
|
||||
context.go('/');
|
||||
}
|
||||
|
||||
Future<void> _register(AppLocalizations l10n) async {
|
||||
final input = _readInput(l10n);
|
||||
if (input == null) return;
|
||||
|
||||
await ref
|
||||
.read(authControllerProvider.notifier)
|
||||
.register(input.username, input.password);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final authState =
|
||||
ref.watch(authControllerProvider).valueOrNull ?? const AuthState();
|
||||
final busy = authState.busy;
|
||||
|
||||
// 一次性提示:展示后立即消费,避免重建时重复弹出。
|
||||
ref.listen(authControllerProvider, (previous, next) {
|
||||
final alert = next.valueOrNull?.alert;
|
||||
if (alert == null || alert.isEmpty) return;
|
||||
ref.read(authControllerProvider.notifier).consumeAlert();
|
||||
_showAlert(context, alert, l10n);
|
||||
});
|
||||
|
||||
final error = _localError ?? authState.error;
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
navigationBar: CupertinoNavigationBar(middle: Text(l10n.login)),
|
||||
child: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_LoginHeader(title: l10n.appTitle),
|
||||
const SizedBox(height: 32),
|
||||
_LoginField(
|
||||
controller: _usernameController,
|
||||
enabled: !busy,
|
||||
placeholder: l10n.username,
|
||||
icon: CupertinoIcons.person,
|
||||
textInputAction: TextInputAction.next,
|
||||
onSubmitted: (_) => _passwordFocus.requestFocus(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_LoginField(
|
||||
controller: _passwordController,
|
||||
focusNode: _passwordFocus,
|
||||
enabled: !busy,
|
||||
placeholder: l10n.password,
|
||||
icon: CupertinoIcons.lock,
|
||||
obscureText: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => busy ? null : _login(l10n),
|
||||
),
|
||||
if (error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_ErrorText(message: error),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
CupertinoButton.filled(
|
||||
onPressed: busy ? null : () => _login(l10n),
|
||||
child: busy
|
||||
? const CupertinoActivityIndicator(
|
||||
color: CupertinoColors.white,
|
||||
)
|
||||
: Text(l10n.login),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CupertinoButton(
|
||||
onPressed: busy ? null : () => _register(l10n),
|
||||
child: Text(l10n.registerAccount),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static void _showAlert(
|
||||
BuildContext context,
|
||||
String message,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
showCupertinoDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => CupertinoAlertDialog(
|
||||
content: Text(message),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(l10n.confirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 页头:图标 + 标题 + 副标题。
|
||||
class _LoginHeader extends StatelessWidget {
|
||||
const _LoginHeader({required this.title});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Column(
|
||||
children: [
|
||||
const Icon(
|
||||
CupertinoIcons.device_phone_portrait,
|
||||
size: 56,
|
||||
color: CupertinoColors.activeBlue,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.loginSubtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 带前置图标的登录输入框。
|
||||
class _LoginField extends StatelessWidget {
|
||||
const _LoginField({
|
||||
required this.controller,
|
||||
required this.enabled,
|
||||
required this.placeholder,
|
||||
required this.icon,
|
||||
required this.textInputAction,
|
||||
required this.onSubmitted,
|
||||
this.focusNode,
|
||||
this.obscureText = false,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final FocusNode? focusNode;
|
||||
final bool enabled;
|
||||
final String placeholder;
|
||||
final IconData icon;
|
||||
final bool obscureText;
|
||||
final TextInputAction textInputAction;
|
||||
final ValueChanged<String> onSubmitted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CupertinoTextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
enabled: enabled,
|
||||
placeholder: placeholder,
|
||||
obscureText: obscureText,
|
||||
autocorrect: false,
|
||||
textInputAction: textInputAction,
|
||||
onSubmitted: onSubmitted,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12),
|
||||
prefix: Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: Icon(icon, size: 20, color: CupertinoColors.systemGrey),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 表单错误提示文案。
|
||||
class _ErrorText extends StatelessWidget {
|
||||
const _ErrorText({required this.message});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: CupertinoColors.destructiveRed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:webrtc_controller_flutter/l10n/app_localizations.dart';
|
||||
|
||||
import '../auth_controller.dart';
|
||||
|
||||
/// 启动页,对应 Android 端 `activity/splash/SplashActivity`。
|
||||
///
|
||||
/// 流程:
|
||||
/// 1. 由 [AuthController] 从安全存储恢复令牌;
|
||||
/// 2. 调用 `checkLoginState()` 校验(失效时用 refreshToken 续期后重试一次);
|
||||
/// 3. 校验通过跳转设置页(`/`),否则清空令牌并跳转登录页(`/login`)。
|
||||
class SplashPage extends ConsumerStatefulWidget {
|
||||
const SplashPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SplashPage> createState() => _SplashPageState();
|
||||
}
|
||||
|
||||
class _SplashPageState extends ConsumerState<SplashPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// build() 期间不能直接改动 provider 状态,推迟到首帧之后。
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _bootstrap());
|
||||
}
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
final loggedIn =
|
||||
await ref.read(authControllerProvider.notifier).checkLoginState();
|
||||
if (!mounted) return;
|
||||
context.go(loggedIn ? '/' : '/login');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return CupertinoPageScaffold(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
CupertinoIcons.device_phone_portrait,
|
||||
size: 64,
|
||||
color: CupertinoColors.activeBlue,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
l10n.appTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const CupertinoActivityIndicator(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@ class _LoginDialogState extends ConsumerState<LoginDialog> {
|
||||
Navigator.of(context).pop(true);
|
||||
} else {
|
||||
final error = ref.read(authControllerProvider).valueOrNull?.error;
|
||||
debugPrint('login error: $error');
|
||||
_showAlert(
|
||||
context,
|
||||
l10n.loginFailed(error ?? ''),
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"appTitle": "WebRTC Controller",
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"registerAccount": "Create Account",
|
||||
"registerSuccess": "Registered successfully, please log in",
|
||||
"registerFailed": "Registration failed: {error}",
|
||||
"loginSubtitle": "Log in to connect your bound devices",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "OK",
|
||||
"connect": "Connect",
|
||||
|
||||
@@ -116,6 +116,30 @@ abstract class AppLocalizations {
|
||||
/// **'退出登录'**
|
||||
String get logout;
|
||||
|
||||
/// No description provided for @registerAccount.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'注册新账号'**
|
||||
String get registerAccount;
|
||||
|
||||
/// No description provided for @registerSuccess.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'注册成功,请登录'**
|
||||
String get registerSuccess;
|
||||
|
||||
/// No description provided for @registerFailed.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'注册失败:{error}'**
|
||||
String registerFailed(Object error);
|
||||
|
||||
/// No description provided for @loginSubtitle.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'登录后可连接已绑定的被控设备'**
|
||||
String get loginSubtitle;
|
||||
|
||||
/// No description provided for @cancel.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
|
||||
@@ -17,6 +17,20 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get logout => 'Logout';
|
||||
|
||||
@override
|
||||
String get registerAccount => 'Create Account';
|
||||
|
||||
@override
|
||||
String get registerSuccess => 'Registered successfully, please log in';
|
||||
|
||||
@override
|
||||
String registerFailed(Object error) {
|
||||
return 'Registration failed: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get loginSubtitle => 'Log in to connect your bound devices';
|
||||
|
||||
@override
|
||||
String get cancel => 'Cancel';
|
||||
|
||||
|
||||
@@ -17,6 +17,20 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get logout => '退出登录';
|
||||
|
||||
@override
|
||||
String get registerAccount => '注册新账号';
|
||||
|
||||
@override
|
||||
String get registerSuccess => '注册成功,请登录';
|
||||
|
||||
@override
|
||||
String registerFailed(Object error) {
|
||||
return '注册失败:$error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get loginSubtitle => '登录后可连接已绑定的被控设备';
|
||||
|
||||
@override
|
||||
String get cancel => '取消';
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"appTitle": "WebRTC 控制端",
|
||||
"login": "登录",
|
||||
"logout": "退出登录",
|
||||
"registerAccount": "注册新账号",
|
||||
"registerSuccess": "注册成功,请登录",
|
||||
"registerFailed": "注册失败:{error}",
|
||||
"loginSubtitle": "登录后可连接已绑定的被控设备",
|
||||
"cancel": "取消",
|
||||
"confirm": "确定",
|
||||
"connect": "连接",
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
/// 绑定关系条目,对应服务端 `ClientController#bindingView`。
|
||||
///
|
||||
/// 与 Android 端 `network/model/BindingItem` 字段保持一致。
|
||||
class BindingItem {
|
||||
final String? bindingId;
|
||||
|
||||
/// 被控端设备唯一标识,即连接时的目标设备 ID。
|
||||
final String deviceUid;
|
||||
|
||||
final String? userId;
|
||||
|
||||
/// OWNER / MEMBER。
|
||||
final String? role;
|
||||
|
||||
final String? alias;
|
||||
|
||||
/// ACTIVE / REVOKED。
|
||||
final String? status;
|
||||
|
||||
/// 被控端当前是否在线(信令网络中存在活跃会话)。
|
||||
final bool online;
|
||||
|
||||
const BindingItem({
|
||||
this.bindingId,
|
||||
required this.deviceUid,
|
||||
this.userId,
|
||||
this.role,
|
||||
this.alias,
|
||||
this.status,
|
||||
this.online = false,
|
||||
});
|
||||
|
||||
factory BindingItem.fromJson(Map<String, dynamic> json) => BindingItem(
|
||||
bindingId: json['bindingId'] as String?,
|
||||
deviceUid: (json['deviceUid'] ?? json['deviceId'] ?? '').toString(),
|
||||
userId: json['userId'] as String?,
|
||||
role: json['role'] as String?,
|
||||
alias: json['alias'] as String?,
|
||||
status: json['status'] as String?,
|
||||
online: json['online'] == true,
|
||||
);
|
||||
|
||||
/// 将服务端返回的列表字段解析为绑定条目集合。
|
||||
static List<BindingItem> listFrom(dynamic raw) {
|
||||
if (raw is! List) return const [];
|
||||
return raw
|
||||
.whereType<Map>()
|
||||
.map((e) => BindingItem.fromJson(Map<String, dynamic>.from(e)))
|
||||
.where((e) => e.deviceUid.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 是否为设备所有者。
|
||||
bool get isOwner => role == 'OWNER';
|
||||
|
||||
/// 列表展示名称:有别名时优先展示别名,否则展示设备 ID。
|
||||
String get name =>
|
||||
(alias != null && alias!.trim().isNotEmpty) ? alias! : deviceUid;
|
||||
|
||||
/// 下拉展示文案:有别名时附带设备 ID。
|
||||
String displayName() {
|
||||
if (alias != null && alias!.trim().isNotEmpty) {
|
||||
return '$alias ($deviceUid)';
|
||||
}
|
||||
return deviceUid;
|
||||
}
|
||||
|
||||
BindingItem copyWith({bool? online}) => BindingItem(
|
||||
bindingId: bindingId,
|
||||
deviceUid: deviceUid,
|
||||
userId: userId,
|
||||
role: role,
|
||||
alias: alias,
|
||||
status: status,
|
||||
online: online ?? this.online,
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/// 令牌响应,对应服务端 `POST /api/auth/login` 与 `POST /api/auth/refresh`。
|
||||
///
|
||||
/// 与 Android 端 `network/model/TokenResponse` 字段保持一致。
|
||||
class TokenResponse {
|
||||
final String accessToken;
|
||||
final String? refreshToken;
|
||||
final String? tokenType;
|
||||
|
||||
/// accessToken 有效期(秒)。
|
||||
final int expiresIn;
|
||||
|
||||
const TokenResponse({
|
||||
required this.accessToken,
|
||||
this.refreshToken,
|
||||
this.tokenType,
|
||||
this.expiresIn = 0,
|
||||
});
|
||||
|
||||
factory TokenResponse.fromJson(Map<String, dynamic> json) => TokenResponse(
|
||||
accessToken: (json['accessToken'] ?? '') as String,
|
||||
refreshToken: json['refreshToken'] as String?,
|
||||
tokenType: json['tokenType'] as String?,
|
||||
expiresIn: (json['expiresIn'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
/// 令牌校验响应,对应服务端 `GET /api/client/verify`。
|
||||
///
|
||||
/// 与 Android 端 `network/model/VerifyResponse` 字段保持一致。
|
||||
class VerifyResponse {
|
||||
final bool valid;
|
||||
|
||||
/// USER / DEVICE。
|
||||
final String? principalType;
|
||||
final String? principalId;
|
||||
final String? displayName;
|
||||
|
||||
/// 过期时间(秒级时间戳)。
|
||||
final int expiresAt;
|
||||
|
||||
/// 剩余有效秒数。
|
||||
final int remainingSeconds;
|
||||
|
||||
final String? message;
|
||||
|
||||
const VerifyResponse({
|
||||
required this.valid,
|
||||
this.principalType,
|
||||
this.principalId,
|
||||
this.displayName,
|
||||
this.expiresAt = 0,
|
||||
this.remainingSeconds = 0,
|
||||
this.message,
|
||||
});
|
||||
|
||||
factory VerifyResponse.fromJson(Map<String, dynamic> json) => VerifyResponse(
|
||||
valid: json['valid'] == true,
|
||||
principalType: json['principalType'] as String?,
|
||||
principalId: json['principalId'] as String?,
|
||||
displayName: json['displayName'] as String?,
|
||||
expiresAt: (json['expiresAt'] as num?)?.toInt() ?? 0,
|
||||
remainingSeconds: (json['remainingSeconds'] as num?)?.toInt() ?? 0,
|
||||
message: json['message'] as String?,
|
||||
);
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../api/api_client.dart';
|
||||
|
||||
/// 独立登录页,对应 Android 端 `activity/login/LoginActivity`。
|
||||
///
|
||||
/// 行为与 Android 端保持一致:
|
||||
/// - 用户名 / 密码双输入,均非空才允许提交;
|
||||
/// - 支持注册(`POST /api/auth/register`),注册成功后停留在登录页提示登录;
|
||||
/// - 登录成功(`POST /api/auth/login`)后持久化令牌并回显用户名,
|
||||
/// 通过命名路由 `/home` 替换栈顶进入主页;
|
||||
/// - 提交过程中禁用输入与按钮,展示加载指示。
|
||||
class LoginPage extends StatefulWidget {
|
||||
const LoginPage({super.key, required this.apiClient, this.message});
|
||||
|
||||
final ApiClient apiClient;
|
||||
|
||||
/// 从主页因登录失效跳转过来时展示的提示文案。
|
||||
final String? message;
|
||||
|
||||
@override
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage> {
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _passwordFocus = FocusNode();
|
||||
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_error = widget.message;
|
||||
// 回显上次登录的用户名(对应 LoginViewModel.lastUsername())。
|
||||
widget.apiClient.getUsername().then((name) {
|
||||
if (mounted && name != null && name.isNotEmpty) {
|
||||
_usernameController.text = name;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
_passwordFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 读取并校验输入;返回 null 表示校验未通过(错误已展示)。
|
||||
({String username, String password})? _readInput() {
|
||||
final username = _usernameController.text.trim();
|
||||
final password = _passwordController.text.trim();
|
||||
if (username.isEmpty || password.isEmpty) {
|
||||
setState(() => _error = '请输入用户名和密码');
|
||||
return null;
|
||||
}
|
||||
return (username: username, password: password);
|
||||
}
|
||||
|
||||
Future<void> _login() async {
|
||||
final input = _readInput();
|
||||
if (input == null) return;
|
||||
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await widget.apiClient.login(input.username, input.password);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pushReplacementNamed('/home');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = _describe(e, '登录失败');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _register() async {
|
||||
final input = _readInput();
|
||||
if (input == null) return;
|
||||
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await widget.apiClient.register(input.username, input.password);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = '注册成功,请登录';
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = _describe(e, '注册失败');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 优先展示服务端返回的错误文案,其余异常回落到通用提示。
|
||||
String _describe(Object e, String fallback) {
|
||||
if (e is ApiException) return e.code;
|
||||
return '$fallback:$e';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CupertinoPageScaffold(
|
||||
navigationBar: const CupertinoNavigationBar(middle: Text('登录')),
|
||||
child: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Icon(
|
||||
CupertinoIcons.device_phone_portrait,
|
||||
size: 56,
|
||||
color: CupertinoColors.activeBlue,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'WebRTC 控制端',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'登录后可连接已绑定的被控设备',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: CupertinoColors.systemGrey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
CupertinoTextField(
|
||||
controller: _usernameController,
|
||||
enabled: !_busy,
|
||||
placeholder: '用户名',
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(left: 12),
|
||||
child: Icon(CupertinoIcons.person,
|
||||
size: 20, color: CupertinoColors.systemGrey),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
autocorrect: false,
|
||||
onSubmitted: (_) => _passwordFocus.requestFocus(),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 14, horizontal: 12),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CupertinoTextField(
|
||||
controller: _passwordController,
|
||||
focusNode: _passwordFocus,
|
||||
enabled: !_busy,
|
||||
placeholder: '密码',
|
||||
obscureText: true,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(left: 12),
|
||||
child: Icon(CupertinoIcons.lock,
|
||||
size: 20, color: CupertinoColors.systemGrey),
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _busy ? null : _login(),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 14, horizontal: 12),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: CupertinoColors.destructiveRed,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
CupertinoButton.filled(
|
||||
onPressed: _busy ? null : _login,
|
||||
child: _busy
|
||||
? const CupertinoActivityIndicator(
|
||||
color: CupertinoColors.white)
|
||||
: const Text('登录'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CupertinoButton(
|
||||
onPressed: _busy ? null : _register,
|
||||
child: const Text('注册新账号'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../api/api_client.dart';
|
||||
|
||||
/// 启动页,对应 Android 端 `activity/splash/SplashActivity`。
|
||||
///
|
||||
/// 流程与 Android 端一致:
|
||||
/// 1. 从安全存储恢复本地令牌;
|
||||
/// 2. 无令牌 -> 直接进入登录页;
|
||||
/// 3. 有令牌 -> 调用 `GET /api/client/verify` 校验,失效则用 refreshToken
|
||||
/// 续期后再校验一次(`ApiClient.checkLoginState()`);
|
||||
/// 4. 校验通过进入主页,否则清空令牌并进入登录页。
|
||||
class SplashPage extends StatefulWidget {
|
||||
const SplashPage({super.key, required this.apiClient});
|
||||
|
||||
final ApiClient apiClient;
|
||||
|
||||
@override
|
||||
State<SplashPage> createState() => _SplashPageState();
|
||||
}
|
||||
|
||||
class _SplashPageState extends State<SplashPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bootstrap();
|
||||
}
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
await widget.apiClient.restore();
|
||||
final loggedIn = await widget.apiClient.checkLoginState();
|
||||
if (!mounted) return;
|
||||
if (loggedIn) {
|
||||
Navigator.of(context).pushReplacementNamed('/home');
|
||||
} else {
|
||||
// 令牌已失效,清空后要求重新登录。
|
||||
await widget.apiClient.clear();
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pushReplacementNamed('/login');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const CupertinoPageScaffold(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
CupertinoIcons.device_phone_portrait,
|
||||
size: 64,
|
||||
color: CupertinoColors.activeBlue,
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
Text(
|
||||
'WebRTC 控制端',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 24),
|
||||
CupertinoActivityIndicator(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,18 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:webrtc_controller_flutter/app/app.dart';
|
||||
import 'package:webrtc_controller_flutter/features/auth/presentation/pages/splash_page.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('App builds and shows setup page', (WidgetTester tester) async {
|
||||
testWidgets('App builds and shows splash page', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const ProviderScope(child: WebrtcControllerApp()),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// 连接设置页(CupertinoPageScaffold + 导航栏)应正常渲染。
|
||||
// 初始路由为 /splash:启动页负责恢复并校验登录态。
|
||||
expect(find.byType(SplashPage), findsOneWidget);
|
||||
expect(find.byType(CupertinoPageScaffold), findsOneWidget);
|
||||
expect(find.byType(CupertinoNavigationBar), findsOneWidget);
|
||||
expect(find.byType(CupertinoActivityIndicator), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user