merge: 合并 K8 登录页视觉重设计

This commit is contained in:
Zhou Weidong committed 2026-10-17 17:15:09 +08:00
commit 99f64ab870
4 files changed
+989 -229

No files matched your search

+363
View File
@@ -0,0 +1,363 @@
// Author: fruit.com.cn
part of 'auth_view.dart';
class _AuthFields extends StatelessWidget {
const _AuthFields({required this.state, required this.callbacks});
final _AuthCardState state;
final _AuthCardCallbacks callbacks;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_fieldLabel(state.l10n.authEmailLabel),
const SizedBox(height: 8),
_emailField(),
const SizedBox(height: 16),
_fieldLabel(state.l10n.authPasswordLabel),
const SizedBox(height: 8),
_passwordField(),
],
);
}
Widget _emailField() {
final enabled = !state.status.busy;
return ListenableBuilder(
listenable: state.fields.emailFocus,
builder: (context, _) => SizedBox(
height: _fieldHeight,
child: TextField(
key: const Key('auth-email'),
controller: state.fields.email,
focusNode: state.fields.emailFocus,
enabled: enabled,
autofocus: true,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.email],
onChanged: callbacks.onEmailChanged,
onSubmitted: (_) => state.fields.passwordFocus.requestFocus(),
style: const TextStyle(color: AppColors.onPrimary, fontSize: 15),
decoration: _decoration(
hint: 'name@example.com',
icon: Icons.mail_outline_rounded,
focused: state.fields.emailFocus.hasFocus,
),
),
),
);
}
Widget _passwordField() {
final enabled = !state.status.busy;
final l10n = state.l10n;
return ListenableBuilder(
listenable: state.fields.passwordFocus,
builder: (context, _) => SizedBox(
height: _fieldHeight,
child: TextField(
key: const Key('auth-password'),
controller: state.fields.password,
focusNode: state.fields.passwordFocus,
enabled: enabled,
obscureText: state.status.obscurePassword,
keyboardType: TextInputType.visiblePassword,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.password],
onChanged: callbacks.onPasswordChanged,
onSubmitted: (_) => callbacks.onSubmit(),
style: const TextStyle(color: AppColors.onPrimary, fontSize: 15),
decoration: _decoration(
hint: l10n.authPasswordHint,
icon: Icons.lock_outline_rounded,
focused: state.fields.passwordFocus.hasFocus,
suffix: IconButton(
key: const Key('auth-password-visibility'),
constraints: const BoxConstraints.tightFor(
width: _minTapTarget,
height: _minTapTarget,
),
tooltip: state.status.obscurePassword
? l10n.showPassword
: l10n.hidePassword,
onPressed: enabled ? callbacks.onTogglePasswordVisibility : null,
icon: Icon(
state.status.obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
color: AppColors.muted,
),
),
),
),
),
);
}
Widget _fieldLabel(String label) => Text(
label,
style: const TextStyle(
color: AppColors.muted,
fontSize: 13,
fontWeight: FontWeight.w600,
),
);
InputDecoration _decoration({
required String hint,
required IconData icon,
required bool focused,
Widget? suffix,
}) {
final border = OutlineInputBorder(
borderRadius: BorderRadius.circular(_fieldRadius),
borderSide: BorderSide.none,
);
return InputDecoration(
hintText: hint,
hintStyle: const TextStyle(color: AppColors.muted, fontSize: 14),
prefixIcon: Icon(
icon,
size: 21,
color: focused ? AppColors.blueSoft : AppColors.muted,
),
prefixIconConstraints: const BoxConstraints(
minWidth: _minTapTarget,
minHeight: _minTapTarget,
),
suffixIcon: suffix,
filled: true,
fillColor: AppColors.bar,
isDense: true,
contentPadding: const EdgeInsets.only(right: 16),
border: border,
enabledBorder: border,
disabledBorder: border,
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(_fieldRadius),
borderSide: const BorderSide(
color: AppColors.blue,
width: _fieldBorderWidth,
),
),
);
}
}
class _AuthSubmitButton extends StatelessWidget {
const _AuthSubmitButton({required this.state, required this.onSubmit});
final _AuthCardState state;
final VoidCallback onSubmit;
@override
Widget build(BuildContext context) {
final status = state.status;
final l10n = state.l10n;
return SizedBox(
width: double.infinity,
height: _buttonHeight,
child: FilledButton(
key: const Key('auth-submit'),
onPressed: status.busy ? null : onSubmit,
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) {
return AppColors.blue.withValues(alpha: 0.5);
}
if (states.contains(WidgetState.pressed)) {
return AppColors.bluePressed;
}
return AppColors.blue;
}),
foregroundColor: const WidgetStatePropertyAll(AppColors.onPrimary),
elevation: const WidgetStatePropertyAll(0),
padding: const WidgetStatePropertyAll(EdgeInsets.zero),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_buttonRadius),
),
),
),
child: status.busy
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.onPrimary,
),
)
: Text(
status.signUpMode ? l10n.signUp : l10n.signIn,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
),
);
}
}
class _ModeSegment extends StatelessWidget {
const _ModeSegment({
required this.segmentKey,
required this.label,
required this.hiddenLabel,
required this.selected,
required this.onTap,
});
final Key segmentKey;
final String label;
final String hiddenLabel;
final bool selected;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
key: segmentKey,
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: AnimatedContainer(
duration: _segmentDuration,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? AppColors.blue : Colors.transparent,
borderRadius: BorderRadius.circular(18),
),
child: Stack(
fit: StackFit.expand,
children: [
Center(
child: Text(
label,
style: TextStyle(
color: selected ? AppColors.onPrimary : AppColors.muted,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
Positioned.fill(
child: ExcludeSemantics(
child: Opacity(
opacity: 0,
child: Center(
child: Text(
hiddenLabel,
maxLines: 1,
softWrap: false,
style: const TextStyle(fontSize: 14),
),
),
),
),
),
],
),
),
);
}
}
class _AuthBrand extends StatelessWidget {
const _AuthBrand({required this.tagline});
final String tagline;
@override
Widget build(BuildContext context) {
return Row(
key: const Key('auth-brand'),
children: [
Container(
key: const Key('auth-brand-mark'),
width: _brandMarkSize,
height: _brandMarkSize,
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: AppColors.blue.withValues(alpha: 0.32),
blurRadius: 24,
offset: const Offset(0, 10),
),
],
),
child: const Icon(
Icons.movie_filter_rounded,
color: AppColors.onPrimary,
size: 26,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context)!.appName,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 23,
fontWeight: FontWeight.w800,
height: 1.1,
),
),
const SizedBox(height: 4),
Text(
tagline,
style: const TextStyle(
color: AppColors.blueSoft,
fontSize: 13,
height: 1.35,
),
),
],
),
),
],
);
}
}
class _AuthError extends StatelessWidget {
const _AuthError({required this.message});
final String message;
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.error;
return Container(
key: const Key('auth-error-capsule'),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: color.withValues(alpha: _errorOpacity),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withValues(alpha: _errorBorderOpacity)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.error_outline_rounded, color: color, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
key: const Key('auth-error'),
style: TextStyle(color: color, fontSize: 13, height: 1.35),
),
),
],
),
);
}
}
+132 -229
View File
@@ -13,7 +13,26 @@ import '../../core/theme/app_theme.dart';
import '../../l10n/app_localizations.dart';
import 'auth_logic.dart';
part 'auth_widgets.dart';
part 'auth_form_widgets.dart';
const double _minTapTarget = 44;
const double _brandMarkSize = 48;
const double _contentMaxWidth = 440;
const double _cardRadius = 20;
const double _cardPadding = 20;
const double _cardBorderWidth = 1;
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 _errorOpacity = 0.10;
const double _errorBorderOpacity = 0.28;
const double _enterSlideDistance = 16;
const Duration _enterDuration = Duration(milliseconds: 220);
const Duration _segmentDuration = Duration(milliseconds: 160);
/// 登录 / 注册页:邮箱 + 密码。
class AuthPage extends StatefulWidget {
@@ -112,245 +131,129 @@ class _AuthPageState extends State<AuthPage> {
child: Scaffold(
resizeToAvoidBottomInset: true,
backgroundColor: AppColors.ink,
appBar: AppBar(
backgroundColor: AppColors.ink,
elevation: 0,
foregroundColor: AppColors.onPrimary,
title: Text(_logic.signUpMode ? l10n.signUp : l10n.signIn),
),
body: SafeArea(
child: AutofillGroup(
child: ListView(
key: const Key('auth-scroll'),
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.fromLTRB(
20,
12,
20,
28 + MediaQuery.viewInsetsOf(context).bottom,
),
children: [
_AuthBrand(tagline: l10n.authBrandTagline),
const SizedBox(height: 26),
Text(
_logic.signUpMode
? l10n.authSignUpTitle
: l10n.authSignInTitle,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 24,
fontWeight: FontWeight.w800,
body: Stack(
children: [
Positioned(
top: 0,
left: 0,
right: 0,
height: 420,
child: IgnorePointer(
child: Container(
key: const Key('auth-background-glow'),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.blue.withValues(alpha: _glowOpacity),
AppColors.blue.withValues(alpha: 0.05),
AppColors.ink.withValues(alpha: 0),
],
stops: const [0, 0.55, 1],
),
),
),
const SizedBox(height: 6),
Text(
l10n.authSubtitle,
style: const TextStyle(color: AppColors.muted, fontSize: 13),
),
const SizedBox(height: 22),
_emailField(l10n),
const SizedBox(height: 14),
_passwordField(l10n),
if (_logic.errorMessage != null) ...[
const SizedBox(height: 12),
_AuthError(message: _logic.errorMessage!),
],
const SizedBox(height: 22),
_submitButton(l10n),
const SizedBox(height: 10),
_switchModeButton(l10n),
],
),
),
),
),
);
}
Widget _emailField(AppLocalizations l10n) => _field(
child: TextField(
key: const Key('auth-email'),
controller: _emailController,
focusNode: _emailFocus,
enabled: !_logic.busy,
autofocus: true,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.email],
onChanged: (value) => _logic.email = value,
onSubmitted: (_) => _passwordFocus.requestFocus(),
style: const TextStyle(color: AppColors.onPrimary, fontSize: 15),
decoration: _decoration(l10n.authEmailLabel, 'name@example.com'),
),
);
Widget _passwordField(AppLocalizations l10n) => _field(
child: TextField(
key: const Key('auth-password'),
controller: _passwordController,
focusNode: _passwordFocus,
enabled: !_logic.busy,
obscureText: _obscurePassword,
keyboardType: TextInputType.visiblePassword,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.password],
onChanged: (value) => _logic.password = value,
onSubmitted: (_) => _submit(),
style: const TextStyle(color: AppColors.onPrimary, fontSize: 15),
decoration: _decoration(
l10n.authPasswordLabel,
l10n.authPasswordHint,
suffix: IconButton(
key: const Key('auth-password-visibility'),
constraints: const BoxConstraints.tightFor(
width: _minTapTarget,
height: _minTapTarget,
),
tooltip: _obscurePassword ? l10n.showPassword : l10n.hidePassword,
onPressed: _logic.busy
? null
: () => setState(() => _obscurePassword = !_obscurePassword),
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
color: AppColors.muted,
),
),
),
),
);
Widget _field({required Widget child}) =>
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [child]);
InputDecoration _decoration(String label, String hint, {Widget? suffix}) {
final scheme = Theme.of(context).colorScheme;
return InputDecoration(
labelText: label,
hintText: hint,
hintStyle: TextStyle(color: scheme.onSurfaceVariant, fontSize: 14),
suffixIcon: suffix,
filled: true,
fillColor: AppColors.card,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 17),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: scheme.primary, width: 1.5),
),
);
}
Widget _submitButton(AppLocalizations l10n) => SizedBox(
height: 50,
child: FilledButton(
key: const Key('auth-submit'),
onPressed: _logic.busy ? null : _submit,
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(25)),
),
child: _logic.busy
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.onPrimary,
),
)
: Text(
_logic.signUpMode ? l10n.signUp : l10n.signIn,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
),
),
);
Widget _switchModeButton(AppLocalizations l10n) => TextButton(
key: const Key('auth-switch-mode'),
style: TextButton.styleFrom(
minimumSize: const Size(0, _minTapTarget),
foregroundColor: AppColors.blueSoft,
),
onPressed: _logic.busy ? null : () => setState(() => _logic.toggleMode()),
child: Text(
_logic.signUpMode ? l10n.authSwitchToSignIn : l10n.authSwitchToSignUp,
style: const TextStyle(fontSize: 13),
),
);
}
class _AuthBrand extends StatelessWidget {
const _AuthBrand({required this.tagline});
final String tagline;
@override
Widget build(BuildContext context) {
return Row(
children: [
Container(
width: 48,
height: 48,
decoration: const BoxDecoration(
color: AppColors.blue,
shape: BoxShape.circle,
),
child: const Icon(
Icons.play_arrow_rounded,
color: AppColors.onPrimary,
size: 30,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context)!.appName,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 20,
fontWeight: FontWeight.w800,
SafeArea(
child: AutofillGroup(
child: ListView(
key: const Key('auth-scroll'),
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.fromLTRB(
20,
12,
20,
28 + MediaQuery.viewInsetsOf(context).bottom,
),
children: [
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: _contentMaxWidth,
),
child: 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: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (Navigator.of(context).canPop())
Align(
alignment: Alignment.centerLeft,
child: IconButton(
key: const Key('auth-back'),
tooltip: MaterialLocalizations.of(
context,
).backButtonTooltip,
onPressed: () =>
Navigator.of(context).pop(),
icon: const Icon(
Icons.arrow_back_rounded,
color: AppColors.onPrimary,
),
),
),
_AuthBrand(tagline: l10n.authBrandTagline),
const SizedBox(height: 28),
_authCard(l10n),
],
),
),
),
),
],
),
),
const SizedBox(height: 2),
Text(
tagline,
style: const TextStyle(color: AppColors.blueSoft, fontSize: 12),
),
],
),
),
],
),
],
),
);
}
}
class _AuthError extends StatelessWidget {
const _AuthError({required this.message});
Widget _authCard(AppLocalizations l10n) => _AuthCard(
state: _AuthCardState(
l10n: l10n,
status: _AuthFormStatus(
signUpMode: _logic.signUpMode,
busy: _logic.busy,
errorMessage: _logic.errorMessage,
obscurePassword: _obscurePassword,
),
fields: _AuthFieldControllers(
email: _emailController,
password: _passwordController,
emailFocus: _emailFocus,
passwordFocus: _passwordFocus,
),
),
callbacks: _AuthCardCallbacks(
onModeSelected: _selectMode,
onEmailChanged: (value) => _logic.email = value,
onPasswordChanged: (value) => _logic.password = value,
onTogglePasswordVisibility: () =>
setState(() => _obscurePassword = !_obscurePassword),
onSubmit: _submit,
),
);
final String message;
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.error;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.error_outline, color: color, size: 18),
const SizedBox(width: 6),
Expanded(
child: Text(
message,
key: const Key('auth-error'),
style: TextStyle(color: color, fontSize: 13),
),
),
],
);
void _selectMode(bool signUpMode) {
if (_logic.busy || _logic.signUpMode == signUpMode) return;
setState(() => _logic.toggleMode());
}
}
+183
View File
@@ -0,0 +1,183 @@
// Author: fruit.com.cn
part of 'auth_view.dart';
class _AuthCardState {
const _AuthCardState({
required this.l10n,
required this.status,
required this.fields,
});
final AppLocalizations l10n;
final _AuthFormStatus status;
final _AuthFieldControllers fields;
}
class _AuthFormStatus {
const _AuthFormStatus({
required this.signUpMode,
required this.busy,
required this.errorMessage,
required this.obscurePassword,
});
final bool signUpMode;
final bool busy;
final String? errorMessage;
final bool obscurePassword;
}
class _AuthFieldControllers {
const _AuthFieldControllers({
required this.email,
required this.password,
required this.emailFocus,
required this.passwordFocus,
});
final TextEditingController email;
final TextEditingController password;
final FocusNode emailFocus;
final FocusNode passwordFocus;
}
class _AuthCardCallbacks {
const _AuthCardCallbacks({
required this.onModeSelected,
required this.onEmailChanged,
required this.onPasswordChanged,
required this.onTogglePasswordVisibility,
required this.onSubmit,
});
final ValueChanged<bool> onModeSelected;
final ValueChanged<String> onEmailChanged;
final ValueChanged<String> onPasswordChanged;
final VoidCallback onTogglePasswordVisibility;
final VoidCallback onSubmit;
}
class _AuthCard extends StatelessWidget {
const _AuthCard({required this.state, required this.callbacks});
final _AuthCardState state;
final _AuthCardCallbacks callbacks;
@override
Widget build(BuildContext context) {
final l10n = state.l10n;
final status = state.status;
return Container(
key: const Key('auth-card'),
padding: const EdgeInsets.all(_cardPadding),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(_cardRadius),
border: Border.all(
color: AppColors.outlineVariantDark,
width: _cardBorderWidth,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_AuthModeSwitch(
l10n: l10n,
signUpMode: status.signUpMode,
busy: status.busy,
onSelected: callbacks.onModeSelected,
),
const SizedBox(height: 22),
Text(
status.signUpMode ? l10n.authSignUpTitle : l10n.authSignInTitle,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 25,
fontWeight: FontWeight.w800,
height: 1.2,
),
),
const SizedBox(height: 6),
Text(
l10n.authSubtitle,
style: const TextStyle(
color: AppColors.muted,
fontSize: 13,
height: 1.45,
),
),
const SizedBox(height: 22),
_AuthFields(state: state, callbacks: callbacks),
if (status.errorMessage != null) ...[
const SizedBox(height: 14),
_AuthError(message: status.errorMessage!),
],
const SizedBox(height: 22),
_AuthSubmitButton(state: state, onSubmit: callbacks.onSubmit),
],
),
);
}
}
class _AuthModeSwitch extends StatelessWidget {
const _AuthModeSwitch({
required this.l10n,
required this.signUpMode,
required this.busy,
required this.onSelected,
});
final AppLocalizations l10n;
final bool signUpMode;
final bool busy;
final ValueChanged<bool> onSelected;
@override
Widget build(BuildContext context) {
return TextButton(
key: const Key('auth-switch-mode'),
onPressed: busy ? null : () => onSelected(!signUpMode),
style: TextButton.styleFrom(
minimumSize: const Size(0, _minTapTarget),
padding: EdgeInsets.zero,
overlayColor: Colors.transparent,
splashFactory: NoSplash.splashFactory,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)),
),
child: Container(
height: _minTapTarget,
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: AppColors.bar,
borderRadius: BorderRadius.circular(22),
border: Border.all(color: AppColors.outlineVariantDark),
),
child: Row(
children: [
Expanded(
child: _ModeSegment(
segmentKey: const Key('auth-mode-sign-in'),
label: l10n.signIn,
hiddenLabel: l10n.authSwitchToSignIn,
selected: !signUpMode,
onTap: busy ? null : () => onSelected(false),
),
),
const SizedBox(width: 4),
Expanded(
child: _ModeSegment(
segmentKey: const Key('auth-mode-sign-up'),
label: l10n.signUp,
hiddenLabel: l10n.authSwitchToSignUp,
selected: signUpMode,
onTap: busy ? null : () => onSelected(true),
),
),
],
),
),
);
}
}
+311
View File
@@ -0,0 +1,311 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:fruitshorts/core/data/models/auth_session.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/auth_api.dart';
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/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/auth/auth_view.dart';
import '../support/fake_haptics.dart';
import '../support/fake_http_adapter.dart';
void main() {
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
});
Future<void> pumpAuthPage(
WidgetTester tester, {
AuthApi? api,
FakeHaptics? haptics,
}) async {
final auth = AuthController(
api ??
AuthApi(
ApiClient(
baseUrl: 'http://127.0.0.1:1',
adapter: FakeHttpAdapter(
(_) async => FakeHttpResponse('{}', 500),
),
),
),
AuthSessionStore(),
);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<AuthController>.value(value: auth),
Provider<Haptics>.value(value: haptics ?? FakeHaptics()),
],
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const AuthPage(),
),
),
);
await tester.pump(const Duration(milliseconds: 220));
}
testWidgets(
'should_render_branded_dark_layout_with_approved_tokens_when_page_opens',
(tester) async {
await pumpAuthPage(tester);
expect(find.byKey(const Key('auth-brand')), findsOneWidget);
expect(find.text('FruitShorts'), findsOneWidget);
expect(find.text('Short dramas made for Indonesia'), findsOneWidget);
expect(
tester.getSize(find.byKey(const Key('auth-brand-mark'))),
const Size(48, 48),
);
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 card = tester.widget<Container>(find.byKey(const Key('auth-card')));
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);
final email = tester.widget<TextField>(
find.byKey(const Key('auth-email')),
);
expect(email.decoration?.fillColor, AppColors.bar);
expect(tester.getSize(find.byKey(const Key('auth-email'))).height, 52);
final emailBorder = email.decoration!.border! as OutlineInputBorder;
expect(emailBorder.borderRadius, BorderRadius.circular(14));
final submit = tester.widget<FilledButton>(
find.byKey(const Key('auth-submit')),
);
expect(submit.style!.backgroundColor!.resolve(const {}), AppColors.blue);
expect(
submit.style!.backgroundColor!.resolve(const {WidgetState.pressed}),
AppColors.bluePressed,
);
expect(tester.getSize(find.byKey(const Key('auth-submit'))).height, 52);
expect(
(submit.style!.shape!.resolve(const {})! as RoundedRectangleBorder)
.borderRadius,
BorderRadius.circular(26),
);
expect(find.text('Sign In'), findsWidgets);
expect(find.text('Sign Up'), findsOneWidget);
expect(
tester.widget<Text>(find.text('Sign In').first).style?.fontSize,
isNot(0),
);
},
);
testWidgets('should_fade_and_slide_content_in_within_220ms', (tester) async {
await pumpAuthPage(tester);
final animation = tester.widget<TweenAnimationBuilder<double>>(
find.byKey(const Key('auth-enter-animation')),
);
expect(
animation.duration,
lessThanOrEqualTo(const Duration(milliseconds: 220)),
);
expect(animation.tween.begin, 0);
expect(animation.tween.end, 1);
});
testWidgets(
'should_switch_between_sign_in_and_sign_up_when_segment_is_tapped',
(tester) async {
await pumpAuthPage(tester);
expect(find.text('Welcome back'), findsOneWidget);
await tester.tap(find.byKey(const Key('auth-mode-sign-up')));
await tester.pumpAndSettle();
expect(find.text('Create your account'), findsOneWidget);
expect(find.text('Welcome back'), findsNothing);
expect(_segmentColor(tester, 'auth-mode-sign-up'), AppColors.blue);
expect(_segmentColor(tester, 'auth-mode-sign-in'), Colors.transparent);
await tester.tap(find.byKey(const Key('auth-mode-sign-in')));
await tester.pumpAndSettle();
expect(find.text('Welcome back'), findsOneWidget);
expect(find.text('Create your account'), findsNothing);
expect(_segmentColor(tester, 'auth-mode-sign-in'), AppColors.blue);
expect(_segmentColor(tester, 'auth-mode-sign-up'), Colors.transparent);
},
);
testWidgets(
'should_move_focus_and_toggle_password_when_auth_controls_are_used',
(tester) async {
await pumpAuthPage(tester);
await tester.tap(find.byKey(const Key('auth-email')));
await tester.testTextInput.receiveAction(TextInputAction.next);
await tester.pump();
expect(
FocusManager.instance.primaryFocus?.debugLabel,
'auth-password-focus',
);
await tester.tap(find.byKey(const Key('auth-email')));
await tester.pump();
final email = tester.widget<TextField>(
find.byKey(const Key('auth-email')),
);
final focusedBorder =
email.decoration!.focusedBorder! as OutlineInputBorder;
expect(focusedBorder.borderSide.color, AppColors.blue);
expect(focusedBorder.borderSide.width, 1.5);
expect(
tester.widget<Icon>(find.byIcon(Icons.mail_outline_rounded)).color,
AppColors.blueSoft,
);
await tester.tap(find.byKey(const Key('auth-password-visibility')));
await tester.pump();
expect(
tester
.widget<TextField>(find.byKey(const Key('auth-password')))
.obscureText,
isFalse,
);
expect(find.byTooltip('Hide password'), findsOneWidget);
},
);
testWidgets(
'should_disable_form_mode_switch_and_submit_when_sign_in_is_pending',
(tester) async {
final api = _PendingAuthApi();
final haptics = FakeHaptics();
await pumpAuthPage(tester, api: api, haptics: haptics);
await tester.enterText(find.byKey(const Key('auth-email')), 'a@b.com');
await tester.enterText(
find.byKey(const Key('auth-password')),
'abcd1234',
);
await tester.tap(find.byKey(const Key('auth-submit')));
await tester.pump();
expect(api.loginCalls, 1);
expect(haptics.lightImpacts, 1);
expect(
tester.widget<TextField>(find.byKey(const Key('auth-email'))).enabled,
isFalse,
);
expect(
tester
.widget<TextField>(find.byKey(const Key('auth-password')))
.enabled,
isFalse,
);
expect(
tester
.widget<TextButton>(find.byKey(const Key('auth-switch-mode')))
.onPressed,
isNull,
);
expect(
tester
.widget<FilledButton>(find.byKey(const Key('auth-submit')))
.onPressed,
isNull,
);
api.release();
await tester.pumpAndSettle();
},
);
testWidgets('should_show_validation_message_inside_semantic_error_capsule', (
tester,
) async {
await pumpAuthPage(tester);
await tester.enterText(find.byKey(const Key('auth-email')), 'a@b.com');
await tester.enterText(find.byKey(const Key('auth-password')), 'abcdefgh');
await tester.tap(find.byKey(const Key('auth-submit')));
await tester.pumpAndSettle();
final capsuleFinder = find.byKey(const Key('auth-error-capsule'));
expect(capsuleFinder, findsOneWidget);
final capsule = tester.widget<Container>(capsuleFinder);
final decoration = capsule.decoration! as BoxDecoration;
expect(decoration.borderRadius, BorderRadius.circular(12));
expect(decoration.color?.a, closeTo(0.1, 0.001));
final errorFinder = find.byKey(const Key('auth-error'));
final error = tester.widget<Text>(errorFinder);
final scheme = Theme.of(tester.element(errorFinder)).colorScheme;
expect(error.style?.color, scheme.error);
expect(
find.descendant(
of: capsuleFinder,
matching: find.byIcon(Icons.error_outline_rounded),
),
findsOneWidget,
);
});
}
Color? _segmentColor(WidgetTester tester, String key) {
final segment = find.byKey(Key(key));
final animated = tester.widget<AnimatedContainer>(
find.descendant(of: segment, matching: find.byType(AnimatedContainer)),
);
return (animated.decoration! as BoxDecoration).color;
}
class _PendingAuthApi extends AuthApi {
_PendingAuthApi()
: super(
ApiClient(
baseUrl: 'http://127.0.0.1:1',
adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)),
),
);
final _gate = Completer<void>();
int loginCalls = 0;
void release() => _gate.complete();
@override
Future<AuthSession> login({
required String email,
required String password,
required String deviceId,
String platform = '',
String appVersion = '',
}) async {
loginCalls++;
await _gate.future;
throw const ApiException(500, 'pending failure');
}
}