Files
ReelShort/lib/pages/auth/auth_view.dart
T
Vicky 67670d46b1 feat(auth): 接入 Google 登录
- 登录页保留邮箱与 Google 两个入口,Google 按钮按官方规范改为白底四色图标
- 新增 GoogleAuthService:封装 initialize/authenticate,失败以结果对象返回
- AuthApi 增加 googleLogin,AuthController 增加 signInWithGoogle
- iOS 补充 CFBundleURLTypes 与 GIDClientID,Bundle ID 统一为 com.stormg.lumadrama
- Google 请求期间按钮显示转圈并禁止重复点击
2026-11-13 12:30:47 +08:00

658 lines
22 KiB
Dart

// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/remote/api_client.dart';
import '../../core/formatting/error_text.dart';
import '../../core/data/services/app_config_controller.dart';
import '../../core/data/services/auth_controller.dart';
import '../../core/data/services/google_auth_service.dart';
import '../../core/platform/haptics.dart';
import '../../core/platform/keyboard_inset.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/centered_toast.dart';
import '../../core/widgets/remote_image.dart';
import '../../core/widgets/subpage_header.dart';
import '../../l10n/app_localizations.dart';
import 'auth_legal_links.dart';
import 'auth_logic.dart';
part 'auth_widgets.dart';
part 'auth_form_widgets.dart';
const double _minTapTarget = 44;
const double _brandMarkSize = 88;
const double _compactBrandMarkSize = 48;
const double _brandMarkRadius = 26;
const double _compactBrandMarkRadius = 14;
const String _appLogoAsset = 'assets/icon/ymb.png';
const String _googleIconAsset = 'assets/icon/google_g.png';
const double _contentMaxWidth = 440;
const double _fieldHeight = 52;
const double _fieldRadius = 12;
const double _fieldBorderWidth = 1;
const double _buttonHeight = 50;
const double _buttonRadius = 25;
const double _errorOpacity = 0.10;
const double _errorBorderOpacity = 0.28;
const double _enterSlideDistance = 16;
const double _topContentPadding = 56;
const double _bottomContentPadding = 28;
const double _compactTopContentPadding = 40;
const double _compactHeightBreakpoint = 720;
const double _compactBrandGap = 16;
const double _entryButtonHeight = 52;
const double _entryButtonRadius = 26;
const double _entryButtonFontSize = 15;
const double _entryIconSize = 20;
const double _loadingStrokeWidth = 2;
const Color _googleOnLight = Color(0xFF1F1F1F);
const Duration _enterDuration = Duration(milliseconds: 220);
Widget _brandMarkWidget({
Key? key,
required double size,
required double radius,
}) {
return ClipRRect(
key: key,
borderRadius: BorderRadius.circular(radius),
child: Image.asset(
_appLogoAsset,
width: size,
height: size,
fit: BoxFit.cover,
),
);
}
/// 登录 / 注册页:邮箱 + 密码。
class AuthPage extends StatefulWidget {
const AuthPage({super.key, this.signUpMode = false});
final bool signUpMode;
@override
State<AuthPage> createState() => _AuthPageState();
}
class _AuthPageState extends State<AuthPage> {
late AuthLogic _logic;
late final GoogleAuthService _googleAuth;
late final TextEditingController _emailController;
late final TextEditingController _passwordController;
late final FocusNode _emailFocus;
late final FocusNode _passwordFocus;
late final ScrollController _scrollController;
final _keyboardInset = KeyboardInsetNotifier.instance;
double _lastKeyboardInset = 0;
bool _obscurePassword = true;
bool _emailEntryOpen = false;
@override
void initState() {
super.initState();
_logic = AuthLogic(signUpMode: widget.signUpMode);
_googleAuth = GoogleAuthService();
_emailController = TextEditingController();
_passwordController = TextEditingController();
_emailFocus = FocusNode(debugLabel: 'auth-email-focus');
_passwordFocus = FocusNode(debugLabel: 'auth-password-focus');
_scrollController = ScrollController();
_emailFocus.addListener(_onFocusChanged);
_passwordFocus.addListener(_onFocusChanged);
_keyboardInset.addListener(_onKeyboardInsetChanged);
unawaited(_keyboardInset.start());
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final inset = effectiveKeyboardInset(context, _keyboardInset);
if ((inset - _lastKeyboardInset).abs() >= 1) {
_lastKeyboardInset = inset;
_scheduleRevealFocusedField();
}
}
@override
void dispose() {
_keyboardInset.removeListener(_onKeyboardInsetChanged);
_emailFocus.removeListener(_onFocusChanged);
_passwordFocus.removeListener(_onFocusChanged);
_emailController.dispose();
_passwordController.dispose();
_emailFocus.dispose();
_passwordFocus.dispose();
_scrollController.dispose();
super.dispose();
}
void _onKeyboardInsetChanged() {
if (!mounted) return;
setState(() {});
_scheduleRevealFocusedField();
}
void _onFocusChanged() {
if (_emailFocus.hasFocus || _passwordFocus.hasFocus) {
_scheduleRevealFocusedField();
}
}
/// 点在当前邮箱或密码框之外时取消焦点。
void _releaseFieldFocus(PointerDownEvent event) {
final focus = FocusManager.instance.primaryFocus;
if (focus != _emailFocus && focus != _passwordFocus) return;
final renderObject = focus?.context?.findRenderObject();
if (renderObject is! RenderBox || !renderObject.hasSize) {
focus?.unfocus();
return;
}
final bounds = renderObject.localToGlobal(Offset.zero) & renderObject.size;
if (!bounds.contains(event.position)) focus?.unfocus();
}
void _scheduleRevealFocusedField() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _revealFocusedField();
});
}
void _revealFocusedField() {
if (!_scrollController.hasClients) return;
final focus = _passwordFocus.hasFocus
? _passwordFocus
: _emailFocus.hasFocus
? _emailFocus
: null;
final fieldContext = focus?.context;
final renderObject = fieldContext?.findRenderObject();
if (renderObject is! RenderBox || !renderObject.hasSize) return;
final keyboardInset = effectiveKeyboardInset(context, _keyboardInset);
if (keyboardInset <= 0) return;
final fieldBottom = renderObject
.localToGlobal(Offset(0, renderObject.size.height))
.dy;
final visibleBottom =
MediaQuery.sizeOf(context).height - keyboardInset - 24;
final overlap = fieldBottom - visibleBottom;
if (overlap <= 0) return;
final position = _scrollController.position;
final target = (position.pixels + overlap)
.clamp(position.minScrollExtent, position.maxScrollExtent)
.toDouble();
if (target <= position.pixels + 0.5) return;
unawaited(
_scrollController.animateTo(
target,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
),
);
}
String _errorText(AppLocalizations l10n, AuthFieldError error) {
switch (error) {
case AuthFieldError.emailRequired:
return l10n.authEmailRequired;
case AuthFieldError.emailInvalid:
return l10n.authEmailInvalid;
case AuthFieldError.passwordRequired:
return l10n.authPasswordRequired;
case AuthFieldError.passwordLength:
return l10n.authPasswordLength;
case AuthFieldError.passwordWeak:
return l10n.authPasswordWeak;
}
}
Future<void> _submit() async {
if (_logic.busy) return;
final l10n = AppLocalizations.of(context)!;
final invalid = _logic.validate();
if (invalid != null) {
setState(() => _logic.errorMessage = _errorText(l10n, invalid));
return;
}
unawaited(context.read<Haptics>().lightImpact());
FocusScope.of(context).unfocus();
setState(() {
_logic.busy = true;
_logic.errorMessage = null;
});
final auth = context.read<AuthController>();
String? failureMessage;
try {
if (_logic.signUpMode) {
await auth.register(
email: _logic.normalizedEmail,
password: _logic.password,
);
} else {
await auth.signIn(
email: _logic.normalizedEmail,
password: _logic.password,
);
}
if (!mounted) return;
Navigator.of(context).maybePop();
} on ApiException catch (error) {
failureMessage = userFacingApiMessage(l10n, error.code, error.message);
} catch (_) {
failureMessage = l10n.authNetworkError;
} finally {
if (mounted) setState(() => _logic.busy = false);
}
if (!mounted || failureMessage == null) return;
showCenteredToast(context, failureMessage);
}
/// Google 登录:先取 Google idToken,再交给后端换发本应用令牌。
///
/// 用户取消不提示错误;平台不支持或授权失败提示重试。
Future<void> _signInWithGoogle() async {
if (_logic.busy) return;
final l10n = AppLocalizations.of(context)!;
unawaited(context.read<Haptics>().lightImpact());
FocusScope.of(context).unfocus();
setState(() {
_logic.busy = true;
_logic.errorMessage = null;
});
final auth = context.read<AuthController>();
String? failureMessage;
try {
final outcome = await _googleAuth.signIn();
if (!mounted) return;
if (outcome.canceled) return;
if (!outcome.isSuccess) {
failureMessage = l10n.authGoogleFailed;
} else {
await auth.signInWithGoogle(outcome.idToken!);
if (!mounted) return;
Navigator.of(context).maybePop();
}
} on ApiException catch (error) {
failureMessage = userFacingApiMessage(l10n, error.code, error.message);
} catch (_) {
failureMessage = l10n.authNetworkError;
} finally {
if (mounted) setState(() => _logic.busy = false);
}
if (!mounted || failureMessage == null) return;
showCenteredToast(context, failureMessage);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Theme(
data: AppTheme.dark(),
child: Scaffold(
resizeToAvoidBottomInset: false,
backgroundColor: AppColors.ink,
body: Stack(
children: [
const Positioned.fill(
child: IgnorePointer(
child: DecoratedBox(
key: Key('auth-page-background'),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.followingGlow,
AppColors.followingGlowMid,
AppColors.appBackground,
],
stops: [0, 0.14, 0.3],
),
),
),
),
),
// 全屏海报背景:压暗保证文字可读,顶部再叠品牌红光。
Positioned.fill(child: IgnorePointer(child: _AuthPosterBackdrop())),
const Positioned.fill(
child: IgnorePointer(
child: DecoratedBox(
key: Key('auth-poster-scrim'),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0x66000000),
Color(0x14000000),
Color(0xE0000000),
],
stops: [0, 0.42, 1],
),
),
),
),
),
const Positioned.fill(
child: IgnorePointer(
child: DecoratedBox(
key: Key('auth-red-glow'),
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment(0, -1.15),
radius: 1.3,
colors: [AppColors.primaryBorder, AppColors.transparent],
),
),
),
),
),
SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
final compact =
constraints.maxHeight < _compactHeightBreakpoint;
final top = compact
? _compactTopContentPadding
: _topContentPadding;
final bottom =
_bottomContentPadding +
effectiveKeyboardInset(context, _keyboardInset);
final available = constraints.maxHeight - top - bottom;
final minHeight = available > 0 ? available : 0.0;
return Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: _releaseFieldFocus,
child: AutofillGroup(
child: SingleChildScrollView(
key: const Key('auth-scroll'),
controller: _scrollController,
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.fromLTRB(
AppDimens.pageHorizontal,
top,
AppDimens.pageHorizontal,
bottom,
),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: minHeight),
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: _contentMaxWidth,
),
child: _content(
l10n,
compact: compact,
minHeight: minHeight,
),
),
),
),
),
),
);
},
),
),
if (Navigator.of(context).canPop())
Positioned(
top: 0,
left: 0,
right: 0,
child: SafeArea(
child: Align(
alignment: Alignment.topLeft,
child: Padding(
padding: const EdgeInsets.only(left: 14, top: 8),
child: AppSubpageBackButton(
backKey: const Key('auth-back'),
onBack: () {
if (_emailEntryOpen) {
setState(() => _emailEntryOpen = false);
} else {
Navigator.of(context).pop();
}
},
),
),
),
),
),
],
),
),
);
}
Widget _content(
AppLocalizations l10n, {
required bool compact,
required double minHeight,
}) => TweenAnimationBuilder<double>(
key: const Key('auth-enter-animation'),
tween: Tween(begin: 0, end: 1),
duration: _enterDuration,
curve: Curves.easeOutCubic,
builder: (context, value, child) => Opacity(
opacity: value,
child: Transform.translate(
offset: Offset(0, _enterSlideDistance * (1 - value)),
child: child,
),
),
child: _emailEntryOpen
? Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_AuthBrand(tagline: l10n.authBrandTagline, compact: compact),
SizedBox(height: compact ? _compactBrandGap : 28),
_authCard(l10n),
],
)
: _landing(l10n, minHeight: minHeight),
);
/// 落地页内容需要有限高度才能用 Spacer 三段分割,这里显式钉住可用高度。
Widget _landing(AppLocalizations l10n, {required double minHeight}) =>
SizedBox(
height: minHeight,
child: Column(
key: const Key('auth-landing'),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Spacer(flex: 5),
Center(
child: _brandMarkWidget(
key: const Key('auth-brand-logo'),
size: _brandMarkSize,
radius: _brandMarkRadius,
),
),
const Spacer(flex: 2),
_AuthEntryButton(
key: const Key('auth-entry-email'),
label: l10n.authEmailSignIn,
icon: Icons.mail_outline_rounded,
primary: true,
onTap: () => setState(() => _emailEntryOpen = true),
),
const SizedBox(height: 12),
_AuthEntryButton(
key: const Key('auth-entry-google'),
label: l10n.authContinueGoogle,
imageAsset: _googleIconAsset,
light: true,
primary: false,
loading: _logic.busy,
onTap: _signInWithGoogle,
),
const SizedBox(height: 14),
const Opacity(opacity: 0.75, child: AuthLegalLinks()),
const Spacer(flex: 6),
],
),
);
Widget _authCard(AppLocalizations l10n) => _AuthCard(
state: _AuthCardState(
l10n: l10n,
status: _AuthFormStatus(
signUpMode: _logic.signUpMode,
busy: _logic.busy,
errorMessage: _logic.errorMessage,
fieldError: _logic.fieldError,
obscurePassword: _obscurePassword,
),
fields: _AuthFieldControllers(
email: _emailController,
password: _passwordController,
emailFocus: _emailFocus,
passwordFocus: _passwordFocus,
),
),
callbacks: _AuthCardCallbacks(
onModeSelected: _selectMode,
onEmailChanged: (value) => setState(() {
_logic.email = value;
_logic.clearFieldError();
}),
onPasswordChanged: (value) => setState(() {
_logic.password = value;
_logic.clearFieldError();
}),
onTogglePasswordVisibility: () =>
setState(() => _obscurePassword = !_obscurePassword),
onSubmit: _submit,
),
);
void _selectMode(bool signUpMode) {
if (_logic.busy || _logic.signUpMode == signUpMode) return;
setState(() => _logic.toggleMode());
}
}
class _AuthEntryButton extends StatelessWidget {
const _AuthEntryButton({
super.key,
required this.label,
required this.primary,
required this.onTap,
this.icon,
this.imageAsset,
this.light = false,
this.loading = false,
});
final String label;
final bool primary;
final VoidCallback onTap;
final IconData? icon;
/// 需要显示彩色品牌图标时用图片;与 [icon] 二选一。
final String? imageAsset;
/// 白底深字样式(Google 官方按钮规范),用于第三方登录入口。
final bool light;
/// 请求进行中:显示转圈并禁止重复点击。
final bool loading;
@override
Widget build(BuildContext context) {
// 主按钮纯品牌红;次按钮为无边框深色玻璃(白 6%);第三方登录用白底深字。
final background = primary
? AppColors.brandRed
: (light ? Colors.white : Colors.white.withValues(alpha: 0.06));
final foreground = primary
? AppColors.onPrimary
: (light ? _googleOnLight : AppColors.onSurface);
return Material(
color: background,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_entryButtonRadius),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: loading ? null : onTap,
borderRadius: BorderRadius.circular(_entryButtonRadius),
child: SizedBox(
height: _entryButtonHeight,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (loading) ...[
SizedBox(
width: _entryIconSize,
height: _entryIconSize,
child: CircularProgressIndicator(
strokeWidth: _loadingStrokeWidth,
valueColor: AlwaysStoppedAnimation<Color>(foreground),
),
),
const SizedBox(width: 10),
] else if (imageAsset != null) ...[
Image.asset(
imageAsset!,
width: _entryIconSize,
height: _entryIconSize,
),
const SizedBox(width: 10),
] else if (icon != null) ...[
Icon(icon, size: _entryIconSize, color: foreground),
const SizedBox(width: 8),
],
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: foreground,
fontSize: _entryButtonFontSize,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
);
}
}
/// 登录页全屏海报背景(单张,无拼缝)。
///
/// 优先用后台下发的 `loginBackgroundUrl`(后台未配置或加载失败时回落到本地默认图)。
class _AuthPosterBackdrop extends StatelessWidget {
const _AuthPosterBackdrop();
static const String _defaultAsset = 'assets/auth/bg_3.jpg';
@override
Widget build(BuildContext context) {
final remoteUrl = context.watch<AppConfigController>().loginBackgroundUrl;
return RemoteImage(
key: const Key('auth-poster-backdrop'),
remoteUrl,
fit: BoxFit.cover,
fallbackWidget: Image.asset(
_defaultAsset,
key: const Key('auth-poster-default'),
fit: BoxFit.cover,
alignment: Alignment.center,
),
);
}
}