diff --git a/lib/data/repositories/user_state_repository.dart b/lib/data/repositories/user_state_repository.dart index b3fcaaa..f0440e6 100644 --- a/lib/data/repositories/user_state_repository.dart +++ b/lib/data/repositories/user_state_repository.dart @@ -2,8 +2,10 @@ import '../state/app_interaction_state.dart'; abstract class UserStateRepository { Future isLoggedIn(); - Future login(LoginMethod method); + Future login(LoginMethod method, {String? phone}); Future logout(); + Future getPhone(); + Future getLoginMethod(); Future> getFavorites(); Future toggleFavorite(String reportId); @@ -20,6 +22,8 @@ abstract class UserStateRepository { class MemoryUserStateRepository implements UserStateRepository { bool _loggedIn = false; + String? _phone; + LoginMethod? _loginMethod; final Set _favorites = {}; final Set _savedListens = {}; final List _history = []; @@ -29,15 +33,25 @@ class MemoryUserStateRepository implements UserStateRepository { Future isLoggedIn() async => _loggedIn; @override - Future login(LoginMethod method) async { + Future login(LoginMethod method, {String? phone}) async { _loggedIn = true; + _loginMethod = method; + _phone = phone; } @override Future logout() async { _loggedIn = false; + _phone = null; + _loginMethod = null; } + @override + Future getPhone() async => _phone; + + @override + Future getLoginMethod() async => _loginMethod; + @override Future> getFavorites() async => {..._favorites}; diff --git a/lib/data/state/app_interaction_state.dart b/lib/data/state/app_interaction_state.dart index aabded4..4d05478 100644 --- a/lib/data/state/app_interaction_state.dart +++ b/lib/data/state/app_interaction_state.dart @@ -1,17 +1,33 @@ import '../models/models.dart'; class AuthState { - const AuthState({this.loggedIn = false, this.pendingAction}); + const AuthState({ + this.loggedIn = false, + this.pendingAction, + this.phone, + this.loginMethod, + }); final bool loggedIn; final PendingLoginAction? pendingAction; + final String? phone; + final LoginMethod? loginMethod; - AuthState copyWith({bool? loggedIn, Object? pendingAction = _sentinel}) { + AuthState copyWith({ + bool? loggedIn, + Object? pendingAction = _sentinel, + Object? phone = _sentinel, + Object? loginMethod = _sentinel, + }) { return AuthState( loggedIn: loggedIn ?? this.loggedIn, pendingAction: identical(pendingAction, _sentinel) ? this.pendingAction : pendingAction as PendingLoginAction?, + phone: identical(phone, _sentinel) ? this.phone : phone as String?, + loginMethod: identical(loginMethod, _sentinel) + ? this.loginMethod + : loginMethod as LoginMethod?, ); } } diff --git a/lib/data/state/app_state_controllers.dart b/lib/data/state/app_state_controllers.dart index 9fbb04f..59fdbc5 100644 --- a/lib/data/state/app_state_controllers.dart +++ b/lib/data/state/app_state_controllers.dart @@ -49,7 +49,11 @@ class AuthController extends StateNotifier { final UserStateRepository _repository; Future _load() async { - state = state.copyWith(loggedIn: await _repository.isLoggedIn()); + state = state.copyWith( + loggedIn: await _repository.isLoggedIn(), + phone: await _repository.getPhone(), + loginMethod: await _repository.getLoginMethod(), + ); } void requireLogin(PendingLoginAction action) { @@ -57,10 +61,14 @@ class AuthController extends StateNotifier { state = state.copyWith(pendingAction: action); } - Future login(LoginMethod method) async { + Future login(LoginMethod method, {String? phone}) async { final pending = state.pendingAction; - await _repository.login(method); - state = const AuthState(loggedIn: true); + await _repository.login(method, phone: phone); + state = AuthState( + loggedIn: true, + phone: phone ?? await _repository.getPhone(), + loginMethod: method, + ); return pending; } diff --git a/lib/features/auth/login_page.dart b/lib/features/auth/login_page.dart new file mode 100644 index 0000000..8bc7a67 --- /dev/null +++ b/lib/features/auth/login_page.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../data/providers.dart'; +import '../../data/state/app_interaction_state.dart'; +import '../../routing/app_routes.dart'; +import '../../theme/app_icons.dart'; +import '../../theme/yanting_text.dart'; +import '../../theme/yanting_tokens.dart'; +import '../../widgets/app_buttons.dart'; +import '../../widgets/app_card.dart'; +import '../../widgets/page_header.dart'; +import '../../widgets/states.dart'; + +class LoginPage extends HookConsumerWidget { + const LoginPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final phoneController = useTextEditingController(); + final codeController = useTextEditingController(); + final agreed = useState(false); + final loading = useState(false); + final theme = ShadTheme.of(context); + final auth = ref.watch(authControllerProvider); + + Future submit() async { + final phone = phoneController.text.trim(); + final code = codeController.text.trim(); + if (phone.length < 8) { + showAppToast(context, '请输入手机号'); + return; + } + if (code.length < 4) { + showAppToast(context, '请输入验证码'); + return; + } + if (!agreed.value) { + showAppToast(context, '请先同意用户协议和隐私政策'); + return; + } + + loading.value = true; + try { + await ref + .read(authControllerProvider.notifier) + .login(LoginMethod.phone, phone: phone); + await ref.read(profileControllerProvider.notifier).refresh(); + if (!context.mounted) return; + showAppToast(context, '已登录 ${maskPhone(phone)}'); + if (context.canPop()) { + context.pop(); + } else { + context.go(AppRoutes.profile); + } + } finally { + loading.value = false; + } + } + + return Scaffold( + backgroundColor: theme.colorScheme.background, + appBar: AppBar( + leading: IconButton( + icon: const Icon(AppIcons.arrowLeft), + onPressed: () { + if (context.canPop()) { + context.pop(); + } else { + context.go(AppRoutes.profile); + } + }, + ), + title: const Text('登录 · Login'), + ), + body: ListView( + padding: const EdgeInsets.fromLTRB( + YantingSpacing.screenX, + 4, + YantingSpacing.screenX, + 20, + ), + children: [ + const PageHeader(title: '登录研听', subtitle: '先把登录逻辑接通,弹窗入口保持不变'), + if (auth.loggedIn) ...[ + AppCard( + color: theme.colorScheme.secondary, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('当前已登录', style: YantingText.cardTitle), + const SizedBox(height: 6), + Text( + auth.phone == null + ? '本地登录态已生效' + : '手机号 ${maskPhone(auth.phone!)}', + style: YantingText.body.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + ], + ), + ), + const SizedBox(height: YantingSpacing.x3), + ], + AppCard( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('手机号登录', style: YantingText.sectionTitle), + const SizedBox(height: 12), + ShadInput( + controller: phoneController, + placeholder: const Text('请输入手机号'), + keyboardType: TextInputType.phone, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + const SizedBox(height: 12), + ShadInput( + controller: codeController, + placeholder: const Text('验证码'), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + trailing: Padding( + padding: const EdgeInsets.only(right: 4), + child: AppButton( + label: '发送验证码', + kind: AppButtonKind.ghost, + compact: true, + onPressed: () => showAppToast(context, '验证码功能待接入'), + ), + ), + ), + const SizedBox(height: 14), + ShadCheckbox( + value: agreed.value, + onChanged: (value) => agreed.value = value, + label: const Text('我已阅读并同意'), + sublabel: const Text('《用户协议》和《隐私政策》'), + ), + const SizedBox(height: 18), + AppButton( + label: loading.value ? '登录中...' : '登录', + expand: true, + onPressed: loading.value ? null : submit, + ), + ], + ), + ), + const SizedBox(height: YantingSpacing.x3), + AppCard( + color: theme.colorScheme.secondary, + child: Text( + '当前版本先做本地登录态和页面流转,后端接口接入后再替换为真实校验。', + style: YantingText.meta.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + ), + ], + ), + ); + } +} + +String maskPhone(String phone) { + if (phone.length < 7) return phone; + return '${phone.substring(0, 3)}****${phone.substring(phone.length - 4)}'; +} diff --git a/lib/features/profile/profile_page.dart b/lib/features/profile/profile_page.dart index ce95516..7134466 100644 --- a/lib/features/profile/profile_page.dart +++ b/lib/features/profile/profile_page.dart @@ -55,7 +55,7 @@ class ProfilePage extends ConsumerWidget { children: [ const PageHeader(title: '我的'), if (auth.loggedIn) ...[ - const _LoggedInHeader(), + _LoggedInHeader(auth: auth), const SizedBox(height: YantingSpacing.x3), _StatsCard( favoriteCount: favoriteCount, @@ -103,12 +103,7 @@ class ProfilePage extends ConsumerWidget { AppButton( label: '登录 / 注册', expand: true, - onPressed: () => showLoginSheet( - context, - reason: '登录后同步收藏、历史和听单', - onPhoneLogin: () => _login(ref, LoginMethod.phone), - onSecondaryLogin: () => _login(ref, LoginMethod.wechat), - ), + onPressed: () => context.push(AppRoutes.login), ), ], const SizedBox(height: 18), @@ -177,18 +172,6 @@ class ProfilePage extends ConsumerWidget { title: '退出登录', onTap: () => ref.read(authControllerProvider.notifier).logout(), ), - _MenuRow( - icon: AppIcons.fileList, - title: '用户协议', - onTap: () => - _showOutbound(context, ref, 'user_agreement', '用户协议'), - ), - _MenuRow( - icon: AppIcons.shield, - title: '隐私政策', - onTap: () => - _showOutbound(context, ref, 'privacy_policy', '隐私政策'), - ), ], ), const SizedBox(height: YantingSpacing.x3), @@ -337,24 +320,34 @@ class ProfilePage extends ConsumerWidget { } class _LoggedInHeader extends StatelessWidget { - const _LoggedInHeader(); + const _LoggedInHeader({required this.auth}); + + final AuthState auth; @override Widget build(BuildContext context) { + final colors = ShadTheme.of(context).colorScheme; + final phone = auth.phone; + final methodLabel = switch (auth.loginMethod) { + LoginMethod.phone => '手机号', + LoginMethod.wechat => '微信', + LoginMethod.apple => 'Apple', + _ => '本地登录', + }; return AppCard( - color: const Color(0xFF133D00), - borderColor: const Color(0xFF133D00), + color: colors.brandSoft, + borderColor: colors.brandSoftBorder, padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 30), child: Row( children: [ CircleAvatar( radius: 34, - backgroundColor: const Color(0xFF385E26), - foregroundColor: YantingColors.primary, + backgroundColor: colors.background, + foregroundColor: colors.primary, child: Text( - '研', + phone == null || phone.isEmpty ? '研' : phone.characters.first, style: YantingText.sectionTitle.copyWith( - color: YantingColors.primary, + color: colors.primary, fontSize: 27, ), ), @@ -365,17 +358,17 @@ class _LoggedInHeader extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '研界用户', + phone == null || phone.isEmpty ? '研界用户' : '手机号用户', style: YantingText.sectionTitle.copyWith( - color: Colors.white, + color: colors.foreground, fontSize: 22, ), ), const SizedBox(height: 4), Text( - '已登录 · 手机号', + '已登录 · $methodLabel${phone == null || phone.isEmpty ? '' : ' · ${_maskPhone(phone)}'}', style: YantingText.body.copyWith( - color: Colors.white.withValues(alpha: 0.75), + color: colors.mutedForeground, fontSize: 15, ), ), @@ -388,6 +381,11 @@ class _LoggedInHeader extends StatelessWidget { } } +String _maskPhone(String phone) { + if (phone.length < 7) return phone; + return '${phone.substring(0, 3)}****${phone.substring(phone.length - 4)}'; +} + class _StatsCard extends StatelessWidget { const _StatsCard({ required this.favoriteCount, diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart index 4386799..1da4cad 100644 --- a/lib/features/settings/settings_page.dart +++ b/lib/features/settings/settings_page.dart @@ -13,6 +13,7 @@ import '../../theme/yanting_tokens.dart'; import '../../widgets/app_card.dart'; import '../../widgets/page_header.dart'; import '../../widgets/sheets.dart'; +import '../../widgets/states.dart'; class SettingsPage extends ConsumerWidget { const SettingsPage({super.key}); @@ -21,6 +22,7 @@ class SettingsPage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final themeMode = ref.watch(themeModeProvider); final scheme = ShadTheme.of(context).colorScheme; + final auth = ref.watch(authControllerProvider); return Scaffold( appBar: AppBar( @@ -138,6 +140,21 @@ class SettingsPage extends ConsumerWidget { ], ), ), + if (auth.loggedIn) ...[ + const SizedBox(height: YantingSpacing.x3), + Text('账户', style: YantingText.sectionTitle), + const SizedBox(height: 12), + AppCard( + padding: EdgeInsets.zero, + child: _ActionTile( + icon: Icons.logout, + title: '退出登录', + subtitle: '退出后本地登录态会清空', + destructive: true, + onTap: () => _confirmLogout(context, ref), + ), + ), + ], ], ), ); @@ -157,6 +174,33 @@ class SettingsPage extends ConsumerWidget { .recordOutbound(OutboundEvent(scene: scene)), ); } + + Future _confirmLogout(BuildContext context, WidgetRef ref) async { + final ok = await showShadDialog( + context: context, + variant: ShadDialogVariant.alert, + builder: (dialogContext) => ShadDialog.alert( + title: const Text('退出登录'), + description: const Text('退出后,本设备的登录态会清空,再次登录可继续使用。'), + actions: [ + ShadButton.outline( + child: const Text('取消'), + onPressed: () => Navigator.of(dialogContext).pop(false), + ), + ShadButton( + child: const Text('确定退出'), + onPressed: () => Navigator.of(dialogContext).pop(true), + ), + ], + ), + ); + if (ok != true) return; + await ref.read(authControllerProvider.notifier).logout(); + await ref.read(profileControllerProvider.notifier).refresh(); + if (!context.mounted) return; + showAppToast(context, '已退出登录'); + context.go(AppRoutes.profile); + } } class _ThemeModeTile extends StatelessWidget { @@ -267,3 +311,60 @@ class _LinkTile extends StatelessWidget { ); } } + +class _ActionTile extends StatelessWidget { + const _ActionTile({ + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + this.destructive = false, + }); + + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onTap; + final bool destructive; + + @override + Widget build(BuildContext context) { + final colors = ShadTheme.of(context).colorScheme; + final titleColor = destructive ? colors.destructive : colors.foreground; + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15), + child: Row( + children: [ + Icon(icon, size: 20, color: titleColor), + const SizedBox(width: 13), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: YantingText.body.copyWith( + color: titleColor, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: YantingText.meta.copyWith( + color: colors.mutedForeground, + fontSize: 12.5, + ), + ), + ], + ), + ), + const Icon(AppIcons.arrowRight, size: 18), + ], + ), + ), + ); + } +} diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index 7ca9812..d4a5f4d 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -7,6 +7,7 @@ import '../data/providers.dart'; import '../features/detail/report_detail_page.dart'; import '../features/feed/feed_page.dart'; import '../features/home/home_page.dart'; +import '../features/auth/login_page.dart'; import '../features/institutions/institution_detail_page.dart'; import '../features/institutions/institutions_page.dart'; import '../features/listen/listen_page.dart'; @@ -148,6 +149,11 @@ final routerProvider = Provider((ref) { ); }, ), + GoRoute( + path: AppRoutes.login, + pageBuilder: (context, state) => + _slidePage(state: state, child: const LoginPage()), + ), GoRoute( path: AppRoutes.institutionDetail, pageBuilder: (context, state) { diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index 8185c9c..795e44b 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -12,6 +12,7 @@ abstract final class AppRoutes { static const institutions = '/institutions'; static const listen = '/listen'; static const profile = '/profile'; + static const login = '/login'; static const settings = '/settings'; static const reportDetail = '/reports/:id'; static const institutionDetail = '/institutions/:id';