From 79cffcb04198ebd0f925d4084ed3278e4b09a93f Mon Sep 17 00:00:00 2001 From: TongTongStudio Date: Mon, 3 Aug 2026 16:47:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E6=B7=BB=E5=8A=A0=E7=99=BB?= =?UTF-8?q?=E5=BD=95/=E6=B3=A8=E5=86=8C=E9=A1=B5=E9=9D=A2=E5=8F=8A?= =?UTF-8?q?=E4=BB=A4=E7=89=8C=E6=81=A2=E5=A4=8D=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 SplashPage(启动页)和 LoginPage(登录页),支持路由跳转 - 扩展 AuthState,增加 submitting/alert/busy 状态及消费机制 - AuthController 增加 checkLoginState/register/showAlert 等方法 - 删除旧模型文件(binding_item, token_response, verify_response) - 删除旧版 LoginPage,迁移至 features/auth 目录 - 更新本地化字符串,增加注册相关文案 - 调整默认 API 地址为本地开发环境 --- .../lib/app/constants/app_constants.dart | 4 +- .../lib/app/router/app_router.dart | 23 +- .../lib/features/auth/domain/auth_state.dart | 10 + .../auth/domain/auth_state.freezed.dart | 78 ++++- .../auth/presentation/auth_controller.dart | 133 +++++++- .../auth/presentation/auth_controller.g.dart | 5 +- .../auth/presentation/pages/login_page.dart | 285 ++++++++++++++++++ .../auth/presentation/pages/splash_page.dart | 64 ++++ .../presentation/widgets/login_dialog.dart | 1 + webrtc_controller_flutter/lib/l10n/app_en.arb | 4 + .../lib/l10n/app_localizations.dart | 24 ++ .../lib/l10n/app_localizations_en.dart | 14 + .../lib/l10n/app_localizations_zh.dart | 14 + webrtc_controller_flutter/lib/l10n/app_zh.arb | 4 + .../lib/models/binding_item.dart | 77 ----- .../lib/models/token_response.dart | 25 -- .../lib/models/verify_response.dart | 39 --- .../lib/pages/login_page.dart | 214 ------------- .../lib/pages/splash_page.dart | 67 ---- .../test/widget_test.dart | 8 +- 20 files changed, 642 insertions(+), 451 deletions(-) create mode 100644 webrtc_controller_flutter/lib/features/auth/presentation/pages/login_page.dart create mode 100644 webrtc_controller_flutter/lib/features/auth/presentation/pages/splash_page.dart delete mode 100644 webrtc_controller_flutter/lib/models/binding_item.dart delete mode 100644 webrtc_controller_flutter/lib/models/token_response.dart delete mode 100644 webrtc_controller_flutter/lib/models/verify_response.dart delete mode 100644 webrtc_controller_flutter/lib/pages/login_page.dart delete mode 100644 webrtc_controller_flutter/lib/pages/splash_page.dart diff --git a/webrtc_controller_flutter/lib/app/constants/app_constants.dart b/webrtc_controller_flutter/lib/app/constants/app_constants.dart index abc0c9e..0799a7f 100644 --- a/webrtc_controller_flutter/lib/app/constants/app_constants.dart +++ b/webrtc_controller_flutter/lib/app/constants/app_constants.dart @@ -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'; diff --git a/webrtc_controller_flutter/lib/app/router/app_router.dart b/webrtc_controller_flutter/lib/app/router/app_router.dart index 4395fd7..9165ed1 100644 --- a/webrtc_controller_flutter/lib/app/router/app_router.dart +++ b/webrtc_controller_flutter/lib/app/router/app_router.dart @@ -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', diff --git a/webrtc_controller_flutter/lib/features/auth/domain/auth_state.dart b/webrtc_controller_flutter/lib/features/auth/domain/auth_state.dart index 8c69d44..b400154 100644 --- a/webrtc_controller_flutter/lib/features/auth/domain/auth_state.dart +++ b/webrtc_controller_flutter/lib/features/auth/domain/auth_state.dart @@ -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; } diff --git a/webrtc_controller_flutter/lib/features/auth/domain/auth_state.freezed.dart b/webrtc_controller_flutter/lib/features/auth/domain/auth_state.freezed.dart index 84d1f30..dfb6113 100644 --- a/webrtc_controller_flutter/lib/features/auth/domain/auth_state.freezed.dart +++ b/webrtc_controller_flutter/lib/features/auth/domain/auth_state.freezed.dart @@ -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. diff --git a/webrtc_controller_flutter/lib/features/auth/presentation/auth_controller.dart b/webrtc_controller_flutter/lib/features/auth/presentation/auth_controller.dart index 1a19f18..6b2b646 100644 --- a/webrtc_controller_flutter/lib/features/auth/presentation/auth_controller.dart +++ b/webrtc_controller_flutter/lib/features/auth/presentation/auth_controller.dart @@ -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 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 _verifyQuietly() async { + try { + await _repository.verify(); + return true; + } on Object { + return false; + } + } + + Future _reset() async { + final username = await _repository.getUsername(); + await _repository.clear(); + state = AsyncData(AuthState(username: username)); + } + + /// 登录:成功返回 true,失败返回 false 并写入 [AuthState.error]。 Future 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 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 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'; + } } diff --git a/webrtc_controller_flutter/lib/features/auth/presentation/auth_controller.g.dart b/webrtc_controller_flutter/lib/features/auth/presentation/auth_controller.g.dart index bc0823a..f445a0b 100644 --- a/webrtc_controller_flutter/lib/features/auth/presentation/auth_controller.g.dart +++ b/webrtc_controller_flutter/lib/features/auth/presentation/auth_controller.g.dart @@ -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 = diff --git a/webrtc_controller_flutter/lib/features/auth/presentation/pages/login_page.dart b/webrtc_controller_flutter/lib/features/auth/presentation/pages/login_page.dart new file mode 100644 index 0000000..6050190 --- /dev/null +++ b/webrtc_controller_flutter/lib/features/auth/presentation/pages/login_page.dart @@ -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 createState() => _LoginPageState(); +} + +class _LoginPageState extends ConsumerState { + 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 _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 _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( + 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 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, + ), + ); + } +} diff --git a/webrtc_controller_flutter/lib/features/auth/presentation/pages/splash_page.dart b/webrtc_controller_flutter/lib/features/auth/presentation/pages/splash_page.dart new file mode 100644 index 0000000..394fcda --- /dev/null +++ b/webrtc_controller_flutter/lib/features/auth/presentation/pages/splash_page.dart @@ -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 createState() => _SplashPageState(); +} + +class _SplashPageState extends ConsumerState { + @override + void initState() { + super.initState(); + // build() 期间不能直接改动 provider 状态,推迟到首帧之后。 + WidgetsBinding.instance.addPostFrameCallback((_) => _bootstrap()); + } + + Future _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(), + ], + ), + ), + ); + } +} diff --git a/webrtc_controller_flutter/lib/features/auth/presentation/widgets/login_dialog.dart b/webrtc_controller_flutter/lib/features/auth/presentation/widgets/login_dialog.dart index b8593c8..9404f98 100644 --- a/webrtc_controller_flutter/lib/features/auth/presentation/widgets/login_dialog.dart +++ b/webrtc_controller_flutter/lib/features/auth/presentation/widgets/login_dialog.dart @@ -69,6 +69,7 @@ class _LoginDialogState extends ConsumerState { Navigator.of(context).pop(true); } else { final error = ref.read(authControllerProvider).valueOrNull?.error; + debugPrint('login error: $error'); _showAlert( context, l10n.loginFailed(error ?? ''), diff --git a/webrtc_controller_flutter/lib/l10n/app_en.arb b/webrtc_controller_flutter/lib/l10n/app_en.arb index 0e6ab28..0c14e4c 100644 --- a/webrtc_controller_flutter/lib/l10n/app_en.arb +++ b/webrtc_controller_flutter/lib/l10n/app_en.arb @@ -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", diff --git a/webrtc_controller_flutter/lib/l10n/app_localizations.dart b/webrtc_controller_flutter/lib/l10n/app_localizations.dart index 3898e91..cfcca7b 100644 --- a/webrtc_controller_flutter/lib/l10n/app_localizations.dart +++ b/webrtc_controller_flutter/lib/l10n/app_localizations.dart @@ -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: diff --git a/webrtc_controller_flutter/lib/l10n/app_localizations_en.dart b/webrtc_controller_flutter/lib/l10n/app_localizations_en.dart index 33ce158..f6182ed 100644 --- a/webrtc_controller_flutter/lib/l10n/app_localizations_en.dart +++ b/webrtc_controller_flutter/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/webrtc_controller_flutter/lib/l10n/app_localizations_zh.dart b/webrtc_controller_flutter/lib/l10n/app_localizations_zh.dart index 78d020a..99501f6 100644 --- a/webrtc_controller_flutter/lib/l10n/app_localizations_zh.dart +++ b/webrtc_controller_flutter/lib/l10n/app_localizations_zh.dart @@ -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 => '取消'; diff --git a/webrtc_controller_flutter/lib/l10n/app_zh.arb b/webrtc_controller_flutter/lib/l10n/app_zh.arb index 87282bd..62706dc 100644 --- a/webrtc_controller_flutter/lib/l10n/app_zh.arb +++ b/webrtc_controller_flutter/lib/l10n/app_zh.arb @@ -3,6 +3,10 @@ "appTitle": "WebRTC 控制端", "login": "登录", "logout": "退出登录", + "registerAccount": "注册新账号", + "registerSuccess": "注册成功,请登录", + "registerFailed": "注册失败:{error}", + "loginSubtitle": "登录后可连接已绑定的被控设备", "cancel": "取消", "confirm": "确定", "connect": "连接", diff --git a/webrtc_controller_flutter/lib/models/binding_item.dart b/webrtc_controller_flutter/lib/models/binding_item.dart deleted file mode 100644 index 16e30ac..0000000 --- a/webrtc_controller_flutter/lib/models/binding_item.dart +++ /dev/null @@ -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 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 listFrom(dynamic raw) { - if (raw is! List) return const []; - return raw - .whereType() - .map((e) => BindingItem.fromJson(Map.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, - ); -} diff --git a/webrtc_controller_flutter/lib/models/token_response.dart b/webrtc_controller_flutter/lib/models/token_response.dart deleted file mode 100644 index ca75a4a..0000000 --- a/webrtc_controller_flutter/lib/models/token_response.dart +++ /dev/null @@ -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 json) => TokenResponse( - accessToken: (json['accessToken'] ?? '') as String, - refreshToken: json['refreshToken'] as String?, - tokenType: json['tokenType'] as String?, - expiresIn: (json['expiresIn'] as num?)?.toInt() ?? 0, - ); -} diff --git a/webrtc_controller_flutter/lib/models/verify_response.dart b/webrtc_controller_flutter/lib/models/verify_response.dart deleted file mode 100644 index 0882366..0000000 --- a/webrtc_controller_flutter/lib/models/verify_response.dart +++ /dev/null @@ -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 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?, - ); -} diff --git a/webrtc_controller_flutter/lib/pages/login_page.dart b/webrtc_controller_flutter/lib/pages/login_page.dart deleted file mode 100644 index b88a75b..0000000 --- a/webrtc_controller_flutter/lib/pages/login_page.dart +++ /dev/null @@ -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 createState() => _LoginPageState(); -} - -class _LoginPageState extends State { - 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 _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 _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('注册新账号'), - ), - ], - ), - ), - ), - ), - ), - ); - } -} diff --git a/webrtc_controller_flutter/lib/pages/splash_page.dart b/webrtc_controller_flutter/lib/pages/splash_page.dart deleted file mode 100644 index 26bbf6c..0000000 --- a/webrtc_controller_flutter/lib/pages/splash_page.dart +++ /dev/null @@ -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 createState() => _SplashPageState(); -} - -class _SplashPageState extends State { - @override - void initState() { - super.initState(); - _bootstrap(); - } - - Future _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(), - ], - ), - ), - ); - } -} diff --git a/webrtc_controller_flutter/test/widget_test.dart b/webrtc_controller_flutter/test/widget_test.dart index 7b05c5a..ffe4d14 100644 --- a/webrtc_controller_flutter/test/widget_test.dart +++ b/webrtc_controller_flutter/test/widget_test.dart @@ -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); }); }