feat(flutter): 修复登录输入并升级登录页

This commit is contained in:
Zhou Weidong committed 2026-10-20 16:21:06 +08:00
1 parent f72cb41670
commit 81bb1543bb
5 files changed
+233 -141

No files matched your search

+29 -42
View File
@@ -22,11 +22,11 @@ class _AuthFields extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_fieldLabel(state.l10n.authEmailLabel),
const SizedBox(height: 8),
const SizedBox(height: 6),
_emailField(context, hasError: emailError),
const SizedBox(height: 16),
const SizedBox(height: 14),
_fieldLabel(state.l10n.authPasswordLabel),
const SizedBox(height: 8),
const SizedBox(height: 6),
_passwordField(context, hasError: passwordError),
if (state.status.signUpMode) ...[
const SizedBox(height: 8),
@@ -60,7 +60,8 @@ class _AuthFields extends StatelessWidget {
autofillHints: const [AutofillHints.email],
onChanged: callbacks.onEmailChanged,
onSubmitted: (_) => state.fields.passwordFocus.requestFocus(),
style: const TextStyle(color: AppColors.onPrimary, fontSize: 15),
style: const TextStyle(color: AppColors.onSurface, fontSize: 15),
cursorColor: AppColors.blue,
decoration: _decoration(
context: context,
hint: 'name@example.com',
@@ -91,7 +92,8 @@ class _AuthFields extends StatelessWidget {
autofillHints: const [AutofillHints.password],
onChanged: callbacks.onPasswordChanged,
onSubmitted: (_) => callbacks.onSubmit(),
style: const TextStyle(color: AppColors.onPrimary, fontSize: 15),
style: const TextStyle(color: AppColors.onSurface, fontSize: 15),
cursorColor: AppColors.blue,
decoration: _decoration(
context: context,
hint: l10n.authPasswordHint,
@@ -125,8 +127,8 @@ class _AuthFields extends StatelessWidget {
label,
style: const TextStyle(
color: AppColors.muted,
fontSize: 13,
fontWeight: FontWeight.w600,
fontSize: 12,
fontWeight: FontWeight.w700,
),
);
@@ -140,7 +142,10 @@ class _AuthFields extends StatelessWidget {
}) {
final border = OutlineInputBorder(
borderRadius: BorderRadius.circular(_fieldRadius),
borderSide: BorderSide.none,
borderSide: const BorderSide(
color: AppColors.border,
width: _fieldBorderWidth,
),
);
final errorColor = Theme.of(context).colorScheme.error;
final errorBorder = OutlineInputBorder(
@@ -149,11 +154,11 @@ class _AuthFields extends StatelessWidget {
);
return InputDecoration(
hintText: hint,
hintStyle: const TextStyle(color: AppColors.muted, fontSize: 14),
hintStyle: const TextStyle(color: AppColors.subtle, fontSize: 14),
prefixIcon: Icon(
icon,
size: 21,
color: focused ? AppColors.blueSoft : AppColors.muted,
color: focused ? AppColors.blue : AppColors.muted,
),
prefixIconConstraints: const BoxConstraints(
minWidth: _minTapTarget,
@@ -161,7 +166,7 @@ class _AuthFields extends StatelessWidget {
),
suffixIcon: suffix,
filled: true,
fillColor: AppColors.bar,
fillColor: AppColors.card,
isDense: true,
contentPadding: const EdgeInsets.only(right: 16),
border: border,
@@ -229,8 +234,8 @@ class _AuthSubmitButton extends StatelessWidget {
: Text(
status.signUpMode ? l10n.signUp : l10n.signIn,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
fontSize: 15,
fontWeight: FontWeight.w800,
),
),
),
@@ -273,7 +278,7 @@ class _ModeSegment extends StatelessWidget {
child: Text(
label,
style: TextStyle(
color: selected ? AppColors.onPrimary : AppColors.muted,
color: selected ? AppColors.onPrimary : AppColors.onSurface,
fontSize: 14,
fontWeight: FontWeight.w700,
),
@@ -327,29 +332,11 @@ class _AuthBrand extends StatelessWidget {
);
}
Widget _brandMark() => Container(
Widget _brandMark() => _brandMarkWidget(
key: const Key('auth-brand-mark'),
width: _brandMarkSize,
height: _brandMarkSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: AppColors.blue.withValues(alpha: 0.32),
blurRadius: 24,
offset: const Offset(0, 10),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Image.asset(
'assets/icon/splash_mark.png',
width: _brandMarkSize,
height: _brandMarkSize,
fit: BoxFit.cover,
),
),
size: compact ? _compactBrandMarkSize : _brandMarkSize,
radius: compact ? _compactBrandMarkRadius : _brandMarkRadius,
iconSize: compact ? _compactBrandIconSize : _brandIconSize,
);
Widget _brandCopy(BuildContext context, {required bool centered}) => Column(
@@ -361,9 +348,9 @@ class _AuthBrand extends StatelessWidget {
AppLocalizations.of(context)!.appName,
textAlign: centered ? TextAlign.center : TextAlign.start,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 26,
fontWeight: FontWeight.w800,
color: AppColors.onSurface,
fontSize: 24,
fontWeight: FontWeight.w900,
height: 1.1,
),
),
@@ -372,8 +359,8 @@ class _AuthBrand extends StatelessWidget {
tagline,
textAlign: centered ? TextAlign.center : TextAlign.start,
style: const TextStyle(
color: AppColors.blueSoft,
fontSize: 14,
color: AppColors.muted,
fontSize: 13,
height: 1.35,
),
),
@@ -406,7 +393,7 @@ class _AuthError extends StatelessWidget {
child: Text(
message,
key: const Key('auth-error'),
style: TextStyle(color: color, fontSize: 13, height: 1.35),
style: TextStyle(color: color, fontSize: 12, height: 1.35),
),
),
],
+103 -71
View File
@@ -11,6 +11,7 @@ import '../../core/platform/haptics.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/subpage_header.dart';
import '../../l10n/app_localizations.dart';
import 'auth_logic.dart';
import 'phone_auth_sheet.dart';
@@ -19,18 +20,21 @@ part 'auth_widgets.dart';
part 'auth_form_widgets.dart';
const double _minTapTarget = 44;
const double _brandMarkSize = 48;
const double _brandMarkSize = 72;
const double _compactBrandMarkSize = 48;
const double _brandMarkRadius = 20;
const double _compactBrandMarkRadius = 14;
const double _brandIconSize = 36;
const double _compactBrandIconSize = 26;
const double _contentMaxWidth = 440;
// Auth keeps its approved 20px card silhouette.
const double _cardRadius = AppDimens.authCardRadius;
const double _cardPadding = AppDimens.cardPadding;
const double _cardBorderWidth = 1;
const double _cardPadding = 20;
const double _fieldHeight = 52;
const double _fieldRadius = 14;
const double _fieldBorderWidth = 1.5;
const double _buttonHeight = 52;
const double _buttonRadius = 26;
const double _glowOpacity = 0.18;
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;
@@ -39,11 +43,48 @@ const double _bottomContentPadding = 28;
const double _compactTopContentPadding = 48;
const double _compactHeightBreakpoint = 720;
const double _compactBrandGap = 16;
const double _warmGlowSize = 260;
const double _warmGlowOpacity = 0.06;
const double _glowSize = 280;
const double _warmGlowOpacity = 0.18;
const double _entryButtonHeight = 50;
const double _entryButtonRadius = 25;
const double _entryButtonFontSize = 15;
const double _entryIconSize = 20;
const Duration _enterDuration = Duration(milliseconds: 220);
const Duration _segmentDuration = Duration(milliseconds: 160);
Widget _brandMarkWidget({
Key? key,
required double size,
required double radius,
required double iconSize,
}) {
return Container(
key: key,
width: size,
height: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(radius),
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.blue, AppColors.secondary],
),
boxShadow: [
BoxShadow(
color: AppColors.blue.withValues(alpha: 0.24),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: Icon(
Icons.play_arrow_rounded,
color: AppColors.onPrimary,
size: iconSize,
),
);
}
/// 登录 / 注册页:邮箱 + 密码。
class AuthPage extends StatefulWidget {
const AuthPage({super.key, this.signUpMode = false});
@@ -147,34 +188,39 @@ class _AuthPageState extends State<AuthPage> {
backgroundColor: AppColors.ink,
body: Stack(
children: [
const Positioned.fill(
child: IgnorePointer(
child: DecoratedBox(
key: Key('auth-page-background'),
decoration: BoxDecoration(gradient: AppTheme.pageGradient),
),
),
),
Positioned(
top: 0,
left: 0,
right: 0,
height: 420,
top: -90,
right: -70,
width: _glowSize,
height: _glowSize,
child: IgnorePointer(
child: Container(
key: const Key('auth-background-glow'),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
AppColors.blue.withValues(alpha: _glowOpacity),
AppColors.blue.withValues(alpha: 0.05),
AppColors.ink.withValues(alpha: 0),
AppColors.surfaceTop,
AppColors.surfaceTop.withValues(alpha: 0),
],
stops: const [0, 0.55, 1],
),
),
),
),
),
Positioned(
top: 72,
right: -80,
width: _warmGlowSize,
height: _warmGlowSize,
left: -110,
bottom: -90,
width: _glowSize,
height: _glowSize,
child: IgnorePointer(
child: Container(
key: const Key('auth-background-warm-glow'),
@@ -239,23 +285,16 @@ class _AuthPageState extends State<AuthPage> {
child: Align(
alignment: Alignment.topLeft,
child: Padding(
padding: const EdgeInsets.only(left: 4, top: 4),
child: IconButton(
key: const Key('auth-back'),
tooltip: MaterialLocalizations.of(
context,
).backButtonTooltip,
onPressed: () {
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();
}
},
icon: const Icon(
Icons.arrow_back_rounded,
color: AppColors.onPrimary,
),
),
),
),
@@ -298,58 +337,44 @@ class _AuthPageState extends State<AuthPage> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
child: _brandMarkWidget(
key: const Key('auth-brand-logo'),
width: 64,
height: 64,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.blue, AppColors.secondary],
),
),
child: const Icon(
Icons.play_arrow_rounded,
color: AppColors.onPrimary,
size: 32,
),
size: _brandMarkSize,
radius: _brandMarkRadius,
iconSize: _brandIconSize,
),
),
const SizedBox(height: 14),
const SizedBox(height: 16),
Text(
l10n.fruitShortsBrand,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 20,
fontWeight: FontWeight.w800,
fontSize: 24,
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 6),
Text(
l10n.authLandingSubtitle,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.subtle, fontSize: 11),
style: const TextStyle(color: AppColors.muted, fontSize: 13),
),
const SizedBox(height: 22),
const SizedBox(height: 28),
_AuthEntryButton(
key: const Key('auth-entry-phone'),
label: l10n.authPhoneSignIn,
icon: Icons.phone_android_rounded,
background: AppColors.input,
foreground: AppColors.onSurface,
primary: true,
onTap: _openPhoneSheet,
),
const SizedBox(height: 11),
const SizedBox(height: 12),
_AuthEntryButton(
key: const Key('auth-entry-email'),
label: l10n.authEmailSignIn,
icon: Icons.mail_outline_rounded,
background: AppColors.input,
foreground: AppColors.onSurface,
primary: false,
onTap: () => setState(() => _emailEntryOpen = true),
),
const SizedBox(height: 18),
@@ -417,33 +442,40 @@ class _AuthEntryButton extends StatelessWidget {
const _AuthEntryButton({
super.key,
required this.label,
required this.background,
required this.foreground,
required this.primary,
required this.onTap,
this.icon,
});
final String label;
final Color background;
final Color foreground;
final bool primary;
final VoidCallback onTap;
final IconData? icon;
@override
Widget build(BuildContext context) {
final background = primary ? AppColors.blue : AppColors.card;
final foreground = primary ? AppColors.onPrimary : AppColors.onSurface;
return Material(
color: background,
borderRadius: BorderRadius.circular(9),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_entryButtonRadius),
side: primary
? BorderSide.none
: const BorderSide(color: AppColors.border),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(_entryButtonRadius),
child: SizedBox(
height: 44,
height: _entryButtonHeight,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) Icon(icon, size: 16, color: foreground),
if (icon != null) const SizedBox(width: 7),
if (icon != null)
Icon(icon, size: _entryIconSize, color: foreground),
if (icon != null) const SizedBox(width: 8),
Flexible(
child: Text(
label,
@@ -451,8 +483,8 @@ class _AuthEntryButton extends StatelessWidget {
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: foreground,
fontSize: 13,
fontWeight: FontWeight.w600,
fontSize: _entryButtonFontSize,
fontWeight: FontWeight.w800,
),
),
),
+6 -10
View File
@@ -76,10 +76,7 @@ class _AuthCard extends StatelessWidget {
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(_cardRadius),
border: Border.all(
color: AppColors.outlineVariantDark,
width: _cardBorderWidth,
),
boxShadow: const [AppTheme.cardShadow],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -94,7 +91,7 @@ class _AuthCard extends StatelessWidget {
Text(
status.signUpMode ? l10n.authSignUpTitle : l10n.authSignInTitle,
style: const TextStyle(
color: AppColors.onPrimary,
color: AppColors.onSurface,
fontSize: 25,
fontWeight: FontWeight.w800,
height: 1.2,
@@ -149,12 +146,11 @@ class _AuthModeSwitch extends StatelessWidget {
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)),
),
child: Container(
height: _minTapTarget,
padding: const EdgeInsets.all(3),
height: 36,
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: AppColors.bar,
borderRadius: BorderRadius.circular(22),
border: Border.all(color: AppColors.outlineVariantDark),
color: AppColors.raised,
borderRadius: BorderRadius.circular(18),
),
child: Row(
children: [
+1 -1
View File
@@ -53,7 +53,7 @@ void main() {
final errorBorder = email.decoration!.errorBorder! as OutlineInputBorder;
final scheme = Theme.of(tester.element(emailFinder)).colorScheme;
expect(errorBorder.borderSide.color, scheme.error);
expect(errorBorder.borderSide.width, 1.5);
expect(errorBorder.borderSide.width, 1);
});
}
+94 -17
View File
@@ -13,6 +13,7 @@ import 'package:fruitshorts/core/data/services/auth_controller.dart';
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
import 'package:fruitshorts/core/platform/haptics.dart';
import 'package:fruitshorts/core/theme/app_colors.dart';
import 'package:fruitshorts/core/theme/app_theme.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/auth/auth_view.dart';
@@ -30,6 +31,7 @@ void main() {
AuthApi? api,
FakeHaptics? haptics,
bool openEmail = true,
bool asRoute = false,
}) async {
final auth = AuthController(
api ??
@@ -53,11 +55,15 @@ void main() {
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const AuthPage(),
home: asRoute ? const _AuthRouteHost() : const AuthPage(),
),
),
);
await tester.pump(const Duration(milliseconds: 220));
if (asRoute) {
await tester.tap(find.byKey(const Key('auth-open')));
await tester.pumpAndSettle();
}
if (openEmail) {
await tester.ensureVisible(find.byKey(const Key('auth-entry-email')));
await tester.tap(find.byKey(const Key('auth-entry-email')));
@@ -78,21 +84,46 @@ void main() {
);
expect(
tester.getSize(find.byKey(const Key('auth-brand-logo'))),
const Size(64, 64),
const Size(72, 72),
);
expect(find.byKey(const Key('auth-entry-google')), findsNothing);
expect(find.byKey(const Key('auth-entry-phone')), findsOneWidget);
expect(find.byKey(const Key('auth-entry-email')), findsOneWidget);
expect(find.byKey(const Key('auth-legal')), findsOneWidget);
expect(
tester.getSize(find.byKey(const Key('auth-entry-phone'))).height,
50,
);
expect(
tester.getSize(find.byKey(const Key('auth-entry-email'))).height,
50,
);
final phoneButton = tester.widget<Material>(
find.descendant(
of: find.byKey(const Key('auth-entry-phone')),
matching: find.byType(Material),
),
);
expect(phoneButton.color, AppColors.blue);
final emailButton = tester.widget<Material>(
find.descendant(
of: find.byKey(const Key('auth-entry-email')),
matching: find.byType(Material),
),
);
expect(emailButton.color, AppColors.card);
expect(
(emailButton.shape! as RoundedRectangleBorder).side.color,
AppColors.border,
);
final glow = tester.widget<Container>(
find.byKey(const Key('auth-background-glow')),
);
final glowDecoration = glow.decoration! as BoxDecoration;
final gradient = glowDecoration.gradient! as LinearGradient;
for (final color in gradient.colors) {
expect(color.a, lessThanOrEqualTo(0.18));
}
final gradient = glowDecoration.gradient! as RadialGradient;
expect(gradient.colors.first, AppColors.surfaceTop);
expect(gradient.colors.last.a, 0);
await tester.tap(find.byKey(const Key('auth-entry-email')));
await tester.pumpAndSettle();
@@ -101,19 +132,28 @@ void main() {
final cardDecoration = card.decoration! as BoxDecoration;
expect(cardDecoration.color, AppColors.card);
expect(cardDecoration.borderRadius, BorderRadius.circular(20));
expect(
(cardDecoration.border! as Border).top.color,
AppColors.outlineVariantDark,
);
expect((cardDecoration.border! as Border).top.width, 1);
expect(cardDecoration.boxShadow, contains(AppTheme.cardShadow));
expect(card.padding, const EdgeInsets.all(20));
final email = tester.widget<TextField>(
find.byKey(const Key('auth-email')),
);
expect(email.decoration?.fillColor, AppColors.bar);
expect(email.decoration?.fillColor, AppColors.card);
expect(email.style?.color, AppColors.onSurface);
expect(email.style?.fontSize, 15);
expect(email.cursorColor, AppColors.blue);
expect(tester.getSize(find.byKey(const Key('auth-email'))).height, 52);
final emailBorder = email.decoration!.border! as OutlineInputBorder;
final emailBorder =
email.decoration!.enabledBorder! as OutlineInputBorder;
expect(emailBorder.borderRadius, BorderRadius.circular(14));
expect(emailBorder.borderSide.color, AppColors.border);
expect(emailBorder.borderSide.width, 1);
final password = tester.widget<TextField>(
find.byKey(const Key('auth-password')),
);
expect(password.style?.color, AppColors.onSurface);
expect(password.style?.fontSize, 15);
expect(password.cursorColor, AppColors.blue);
final submit = tester.widget<FilledButton>(
find.byKey(const Key('auth-submit')),
@@ -123,11 +163,11 @@ void main() {
submit.style!.backgroundColor!.resolve(const {WidgetState.pressed}),
AppColors.bluePressed,
);
expect(tester.getSize(find.byKey(const Key('auth-submit'))).height, 52);
expect(tester.getSize(find.byKey(const Key('auth-submit'))).height, 50);
expect(
(submit.style!.shape!.resolve(const {})! as RoundedRectangleBorder)
.borderRadius,
BorderRadius.circular(26),
BorderRadius.circular(25),
);
expect(find.text('Sign In'), findsWidgets);
@@ -139,6 +179,23 @@ void main() {
},
);
testWidgets('should_render_40px_back_button_when_page_is_pushed', (
tester,
) async {
await pumpAuthPage(tester, openEmail: false, asRoute: true);
final back = find.byKey(const Key('auth-back'));
expect(tester.getSize(back), const Size(40, 40));
expect(
tester
.widget<Material>(
find.descendant(of: back, matching: find.byType(Material)),
)
.color,
AppColors.card,
);
});
testWidgets('should_fade_and_slide_content_in_within_220ms', (tester) async {
await pumpAuthPage(tester);
@@ -198,10 +255,10 @@ void main() {
final focusedBorder =
email.decoration!.focusedBorder! as OutlineInputBorder;
expect(focusedBorder.borderSide.color, AppColors.blue);
expect(focusedBorder.borderSide.width, 1.5);
expect(focusedBorder.borderSide.width, 1);
expect(
tester.widget<Icon>(find.byIcon(Icons.mail_outline_rounded)).color,
AppColors.blueSoft,
AppColors.blue,
);
await tester.tap(find.byKey(const Key('auth-password-visibility')));
@@ -280,6 +337,7 @@ void main() {
final error = tester.widget<Text>(errorFinder);
final scheme = Theme.of(tester.element(errorFinder)).colorScheme;
expect(error.style?.color, scheme.error);
expect(error.style?.fontSize, 12);
expect(
find.descendant(
of: capsuleFinder,
@@ -325,3 +383,22 @@ class _PendingAuthApi extends AuthApi {
throw const ApiException(500, 'pending failure');
}
}
class _AuthRouteHost extends StatelessWidget {
const _AuthRouteHost();
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: TextButton(
key: const Key('auth-open'),
onPressed: () => Navigator.of(
context,
).push<void>(MaterialPageRoute(builder: (_) => const AuthPage())),
child: const Text('Open auth'),
),
),
);
}
}