diff --git a/lib/app.dart b/lib/app.dart index 7026a1fa..e36ccee2 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -50,6 +50,7 @@ class UnnMobile extends StatelessWidget { }, ligtherTextColor: Color(0xFF717A84), idkWhatColor: Color(0xFF989EA9), + wideButtonBottomColor: Color(0xFF006254), ), ], ); diff --git a/lib/ui/unn_mobile_colors.dart b/lib/ui/unn_mobile_colors.dart index 95bfe0bc..683d0a5d 100644 --- a/lib/ui/unn_mobile_colors.dart +++ b/lib/ui/unn_mobile_colors.dart @@ -11,6 +11,7 @@ class UnnMobileColors extends ThemeExtension { final Map? scheduleSubjectTypeColors; final Color? ligtherTextColor; final Color? idkWhatColor; + final Color? wideButtonBottomColor; const UnnMobileColors({ required this.scheduleDayHighlight, @@ -18,6 +19,7 @@ class UnnMobileColors extends ThemeExtension { required this.scheduleSubjectTypeColors, required this.ligtherTextColor, required this.idkWhatColor, + required this.wideButtonBottomColor, }); @override @@ -27,6 +29,8 @@ class UnnMobileColors extends ThemeExtension { Map? scheduleSubjectTypeColors, Color? ligtherTextColor, Color? idkWhatColor, + Color? wideButtonTopColor, + Color? wideButtonBottomColor, }) => UnnMobileColors( scheduleDayHighlight: scheduleDayHighlight ?? this.scheduleDayHighlight, @@ -36,6 +40,8 @@ class UnnMobileColors extends ThemeExtension { scheduleSubjectTypeColors ?? this.scheduleSubjectTypeColors, ligtherTextColor: ligtherTextColor ?? this.ligtherTextColor, idkWhatColor: idkWhatColor ?? this.idkWhatColor, + wideButtonBottomColor: + wideButtonBottomColor ?? this.wideButtonBottomColor, ); @override @@ -65,7 +71,6 @@ class UnnMobileColors extends ThemeExtension { scheduleSubjectTypeColors!.keys.map((SubjectType key) { final Color thisColor = scheduleSubjectTypeColors![key]!; final Color otherColor = otherColors.scheduleSubjectTypeColors![key]!; - return Color.lerp(thisColor, otherColor, t) ?? thisColor; }), ), @@ -79,6 +84,11 @@ class UnnMobileColors extends ThemeExtension { otherColors.idkWhatColor, t, ), + wideButtonBottomColor: Color.lerp( + wideButtonBottomColor, + otherColors.wideButtonBottomColor, + t, + ), ); } } diff --git a/lib/ui/views/auth_page/auth_page.dart b/lib/ui/views/auth_page/auth_page.dart index 7f5984f4..d54b4de5 100644 --- a/lib/ui/views/auth_page/auth_page.dart +++ b/lib/ui/views/auth_page/auth_page.dart @@ -1,7 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -// Copyright 2025 BitCodersNN - -import 'dart:math' as math; +// Copyright 2026 BitCodersNN import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -12,22 +10,57 @@ import 'package:unn_mobile/core/misc/app_open_tracker.dart'; import 'package:unn_mobile/core/viewmodels/auth_page/auth_page_view_model.dart'; import 'package:unn_mobile/core/viewmodels/base_view_model.dart'; import 'package:unn_mobile/ui/router.dart'; +import 'package:unn_mobile/ui/views/auth_page/widgets/auth_error_text.dart'; +import 'package:unn_mobile/ui/views/auth_page/widgets/auth_text_field.dart'; import 'package:unn_mobile/ui/views/base_view.dart'; import 'package:unn_mobile/ui/widgets/dialogs/analytics_confirm_dialog.dart'; import 'package:unn_mobile/ui/widgets/dialogs/changelog_dialog.dart'; -import 'package:unn_mobile/ui/widgets/text_field_with_shadow.dart'; import 'package:unn_mobile/ui/widgets/wide_button.dart'; +enum _InputType { + login, + password, +} + class AuthPage extends StatefulWidget { const AuthPage({super.key}); @override - State createState() => AuthPageWithState(); + State createState() => _AuthPageState(); } -class AuthPageWithState extends State { +class _AuthPageState extends State { + static const String _title = 'Авторизация'; + static const String _buttonText = 'Войти'; + static const String _logoAssetPath = 'assets/images/auth-logo.svg'; + static const double _logoAspectRatio = 338 / 178; + static const double _maxLogoHeight = 178; + static const double _titleFontSize = 25; + static const double _titleHeightFactor = 1.2; + static const double _titleTopPadding = 32; + static const double _logoTopPadding = 32; + static const double _titleBlockHeight = + _titleTopPadding + _titleFontSize * _titleHeightFactor + _logoTopPadding; + static const double _minCardHeight = 360; + static const double _cardTopRadius = 50; + static const double _cardTopPadding = 20; + static const double _cardBottomPadding = 20; + static const double _cardHorizontalPadding = 20; + static const double _errorToFieldPadding = 27; + static const double _buttonTopPadding = 56; + static const Duration _scrollDuration = Duration(milliseconds: 250); + static const Duration _resizeDuration = Duration(milliseconds: 250); + static const Duration _keyboardAppearanceDelay = Duration(milliseconds: 300); + static const Curve _scrollCurve = Curves.easeOutCubic; + static const Curve _resizeCurve = Curves.easeOutCubic; + final _loginTextController = TextEditingController(); final _passwordTextController = TextEditingController(); + final _loginFocusNode = FocusNode(); + final _passwordFocusNode = FocusNode(); + final _loginFieldKey = GlobalKey(); + final _passwordFieldKey = GlobalKey(); + final _formScrollController = ScrollController(); bool _hasSubmittedOnce = false; @@ -35,318 +68,290 @@ class AuthPageWithState extends State { void initState() { super.initState(); - stateUpdater() => setState(() => {}); - - _loginTextController.addListener(stateUpdater); - _passwordTextController.addListener(stateUpdater); - - WidgetsBinding.instance.addPostFrameCallback((_) async { - if (await Injector.appInstance - .get() - .isFirstTimeOpenOnVersion()) { - if (mounted) { - await showAnalyticsConfirmation(context); - } - if (mounted) { - await showDialog( - context: context, - builder: (context) => const ChangelogDialog(), - ); - } - } - }); + _loginTextController.addListener(_handleInputChanged); + _passwordTextController.addListener(_handleInputChanged); + _loginFocusNode.addListener(_handleLoginFocusChanged); + _passwordFocusNode.addListener(_handlePasswordFocusChanged); + + WidgetsBinding.instance.addPostFrameCallback( + (_) => _showFirstTimeOpenDialogs(), + ); + } + + @override + void dispose() { + _loginTextController + ..removeListener(_handleInputChanged) + ..dispose(); + _passwordTextController + ..removeListener(_handleInputChanged) + ..dispose(); + _loginFocusNode + ..removeListener(_handleLoginFocusChanged) + ..dispose(); + _passwordFocusNode + ..removeListener(_handlePasswordFocusChanged) + ..dispose(); + _formScrollController.dispose(); + super.dispose(); + } + + void _handleInputChanged() => setState(() {}); + + void _handleLoginFocusChanged() { + if (_loginFocusNode.hasFocus) { + _revealField( + _loginFieldKey, + _loginFocusNode, + alignment: 0, + ); + } + } + + void _handlePasswordFocusChanged() { + if (_passwordFocusNode.hasFocus) { + _revealField( + _passwordFieldKey, + _passwordFocusNode, + alignment: 1, + ); + } } @override Widget build(BuildContext context) => BaseView( builder: (context, viewModel, child) { final theme = Theme.of(context); - final authTitle = _authTitle(context); - final authBody = _authBody( - context, - viewModel, - _evaluateAuthLogoHeightFactor( - context, - 0.25, - authTitle.preferredSize.height, - ), - ); + final isKeyboardVisible = MediaQuery.viewInsetsOf(context).bottom > 0; return Scaffold( - resizeToAvoidBottomInset: false, - appBar: authTitle, backgroundColor: theme.colorScheme.surfaceContainerLowest, - body: authBody, + body: SafeArea( + bottom: false, + child: LayoutBuilder( + builder: (context, constraints) => Column( + children: [ + const SizedBox(height: _titleTopPadding), + Text( + _title, + style: TextStyle( + fontFamily: 'Inter', + fontSize: _titleFontSize, + height: _titleHeightFactor, + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(height: _logoTopPadding), + AnimatedContainer( + duration: _resizeDuration, + curve: _resizeCurve, + height: isKeyboardVisible + ? 0 + : _logoHeight(constraints.maxHeight), + child: AspectRatio( + aspectRatio: _logoAspectRatio, + child: SvgPicture.asset( + _logoAssetPath, + fit: BoxFit.contain, + ), + ), + ), + Expanded(child: _formCard(context, viewModel)), + ], + ), + ), + ), ); }, ); - double _evaluateAuthLogoHeightFactor( - BuildContext context, - double minimumAuthLogoHeightFactor, - double titleHeight, - ) { - // Вычисленная экспериментальным путём высота формы, - // с учётом высоты всех ошибок валидации и ошибки ответа от auth model - const maximumFormHeight = 452.0; - - final double screenHeight = context.heightByFactor(1); - - final double baseAuthLogoHeightFactor = - (screenHeight - maximumFormHeight - titleHeight) / screenHeight; - - return math.min(baseAuthLogoHeightFactor, minimumAuthLogoHeightFactor); - } - - AppBar _authTitle(BuildContext context) { + Widget _formCard(BuildContext context, AuthPageViewModel viewModel) { final theme = Theme.of(context); - return AppBar( - backgroundColor: theme.colorScheme.surfaceContainerLowest, - title: Center( - child: Text( - 'Авторизация', - style: _baseTextStyle( - textColor: theme.colorScheme.primary, - fontSize: 25, - fontWeight: FontWeight.bold, - ), + final authErrorText = viewModel.authErrorText; + final authError = authErrorText.isEmpty ? null : '$authErrorText!'; + + return DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainer, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(_cardTopRadius), ), + boxShadow: [ + BoxShadow( + offset: Offset.zero, + blurRadius: 10, + color: theme.shadowColor.withValues(alpha: 0.2), + ), + ], ), - ); - } - - Widget _authBody( - BuildContext context, - AuthPageViewModel viewModel, - double authLogoHeightFactor, - ) => - Center( + child: SingleChildScrollView( + controller: _formScrollController, + padding: const EdgeInsets.fromLTRB( + _cardHorizontalPadding, + _cardTopPadding, + _cardHorizontalPadding, + _cardBottomPadding, + ), child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _authLogo(context, authLogoHeightFactor), - _authForm(context, viewModel), + AuthErrorText( + text: authError, + boldPrefix: 'Ошибка: ', + ), + const SizedBox(height: _errorToFieldPadding), + AutofillGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _loginField(), + _passwordField(viewModel), + const SizedBox(height: _buttonTopPadding), + _loginButton(context, viewModel), + ], + ), + ), ], ), + ), + ); + } + + Widget _loginField() => AuthTextField( + key: _loginFieldKey, + controller: _loginTextController, + focusNode: _loginFocusNode, + labelText: 'Логин', + errorText: _validateInputOrElseReturnError(_InputType.login), + obscured: false, + autofillHints: const [AutofillHints.username], + textInputAction: TextInputAction.next, + onSubmitted: (value) => _passwordFocusNode.requestFocus(), ); - Widget _authLogo(BuildContext context, double heightFactor) { - const double logoHeightFactor = 0.8; - const double paddingHeightFactor = (1 - logoHeightFactor) / 2; + Widget _passwordField(AuthPageViewModel viewModel) => AuthTextField( + key: _passwordFieldKey, + controller: _passwordTextController, + focusNode: _passwordFocusNode, + labelText: 'Пароль', + errorText: _validateInputOrElseReturnError(_InputType.password), + obscured: true, + autofillHints: const [AutofillHints.password], + textInputAction: TextInputAction.done, + onSubmitted: (value) => _submit(viewModel), + ); - final double logoHeight = - context.heightByFactor(logoHeightFactor * heightFactor); - final double paddingHeight = - context.heightByFactor(paddingHeightFactor * heightFactor); + Widget _loginButton(BuildContext context, AuthPageViewModel viewModel) { + final theme = Theme.of(context); - return Padding( - padding: EdgeInsets.symmetric(vertical: paddingHeight), - child: _authLogoPic(logoHeight), + return WideButton( + onPressed: () => _submit(viewModel), + child: viewModel.state == ViewState.busy + ? SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator( + color: theme.colorScheme.onPrimary, + strokeWidth: 2.5, + ), + ) + : Text( + _buttonText, + style: TextStyle( + fontFamily: 'Inter', + fontSize: 17, + fontWeight: FontWeight.bold, + color: theme.colorScheme.onPrimary, + ), + ), ); } - Widget _authLogoPic(double height) => SvgPicture.asset( - 'assets/images/auth-logo.svg', - height: height, - ); + double _logoHeight(double availableHeight) => + (availableHeight - _titleBlockHeight - _minCardHeight) + .clamp(0, _maxLogoHeight) + .toDouble(); - Widget _authForm(BuildContext context, AuthPageViewModel viewModel) { - final formContainer = _authFormContainer( - context, - elements: [ - _authErrorMessageIfNeeded(context, viewModel), - _authFormInputLogin(), - _authFormInputPassword(), - _authFormForgetPassword(context), - _authFormLoginButton(context, viewModel), - ], + void _revealField( + GlobalKey fieldKey, + FocusNode fieldFocusNode, { + required double alignment, + }) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _scrollFieldIntoView(fieldKey, fieldFocusNode, alignment), ); - - return Flexible( - child: formContainer, + Future.delayed( + _keyboardAppearanceDelay, + () => _scrollFieldIntoView(fieldKey, fieldFocusNode, alignment), ); } - Widget _authErrorMessageIfNeeded( - BuildContext context, - AuthPageViewModel viewModel, + void _scrollFieldIntoView( + GlobalKey fieldKey, + FocusNode fieldFocusNode, + double alignment, ) { - final authErrorMessage = viewModel.authErrorText; - - if (authErrorMessage.isEmpty) { - return const Text(''); + final fieldContext = fieldKey.currentContext; + if (!mounted || fieldContext == null || !fieldFocusNode.hasFocus) { + return; } - return Center( - child: RichText( - text: TextSpan( - style: _baseTextStyle( - textColor: Theme.of(context).colorScheme.error, - fontSize: 15, - ), - children: [ - const TextSpan( - text: 'Ошибка: ', - style: TextStyle(fontWeight: FontWeight.bold), - ), - TextSpan(text: '$authErrorMessage!'), - ], - ), - ), + Scrollable.ensureVisible( + fieldContext, + alignment: alignment, + duration: _scrollDuration, + curve: _scrollCurve, ); } - Container _authFormContainer( - BuildContext context, { - List elements = const [], - }) { - final columnForm = AutofillGroup( - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: elements, - ), - ); + void _scrollFormToTop() { + if (!_formScrollController.hasClients) { + return; + } - final theme = Theme.of(context); - return Container( - padding: const EdgeInsets.only( - left: 20, - right: 20, - top: 20, - ), - height: MediaQuery.of(context).size.height, - width: double.infinity, - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainer, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(50.0), - topRight: Radius.circular(50.0), - ), - boxShadow: [ - BoxShadow( - offset: Offset.zero, - blurRadius: 10, - color: theme.shadowColor.withAlpha(51), - ), - ], - ), - child: columnForm, + _formScrollController.animateTo( + 0, + duration: _scrollDuration, + curve: _scrollCurve, ); } - Widget _authFormInputLogin() => _inputField( - labelText: 'Логин', - errorText: _validateInputOrElseReturnError(_InputType.login), - textEditingController: _loginTextController, - autofillHints: [AutofillHints.username], - ); + Future _submit(AuthPageViewModel viewModel) async { + if (viewModel.state == ViewState.busy) { + return; + } - Widget _authFormInputPassword() => _inputField( - obscuredText: true, - labelText: 'Пароль', - errorText: _validateInputOrElseReturnError(_InputType.password), - textEditingController: _passwordTextController, - autofillHints: [AutofillHints.password], - ); + setState(() { + _hasSubmittedOnce = true; + }); - Container _inputField({ - required String labelText, - required TextEditingController textEditingController, - bool obscuredText = false, - String? errorText, - Iterable? autofillHints, - }) => - Container( - padding: const EdgeInsets.only(top: 30), - child: TextFieldWithBoxShadow( - obscuredText: obscuredText, - height: 56, - errorText: errorText, - labelText: labelText, - controller: textEditingController, - autofillHints: autofillHints, - ), - ); + if (_validateInputOrElseReturnError(_InputType.login) != null) { + _loginFocusNode.requestFocus(); + return; + } - Widget _authFormForgetPassword(BuildContext context) { - final theme = Theme.of(context); - return Padding( - padding: const EdgeInsets.only(top: 12), - child: Text( - '', //"Забыли пароль?", - style: _baseTextStyle( - textColor: theme.primaryColor, - ), - ), + if (_validateInputOrElseReturnError(_InputType.password) != null) { + _passwordFocusNode.requestFocus(); + return; + } + + final isLoginSuccess = await viewModel.login( + _loginTextController.text, + _passwordTextController.text, ); - } - TextStyle _baseTextStyle({ - Color? textColor, - double? fontSize = 17, - FontWeight? fontWeight, - }) => - TextStyle( - fontFamily: 'Inter', - fontSize: fontSize, - fontWeight: fontWeight, - color: textColor, - ); + if (!mounted) { + return; + } - Widget _authFormLoginButton( - BuildContext context, - AuthPageViewModel viewModel, - ) { - final theme = Theme.of(context); - return Center( - child: Padding( - padding: const EdgeInsets.only(top: 30), - child: WideButton( - onPressed: () => _loginButtonTapHandler(context, viewModel), - child: viewModel.state == ViewState.busy - ? SizedBox( - height: 22, - width: 22, - child: CircularProgressIndicator( - color: theme.colorScheme.onPrimary, - ), - ) - : Text( - 'Войти', - style: _baseTextStyle( - textColor: theme.colorScheme.onPrimary, - fontWeight: FontWeight.bold, - fontSize: 17, - ), - ), - ), - // Container( - // width: double.infinity, - // height: 56, - // decoration: BoxDecoration( - // borderRadius: BorderRadius.circular(50), - // gradient: LinearGradient( - // begin: Alignment.topCenter, - // end: Alignment.bottomCenter, - // colors: [ - // theme.colorScheme.primaryFixedDim, - // theme.primaryColor, - // ], - // ), - // ), - // child: ElevatedButton( - // style: ElevatedButton.styleFrom( - // backgroundColor: Colors.transparent, - // shadowColor: Colors.transparent, - // shape: RoundedRectangleBorder( - // borderRadius: BorderRadius.circular(50), - // ), - // ), - - // ), - // ), - ), - ); + if (!isLoginSuccess) { + _scrollFormToTop(); + return; + } + + TextInput.finishAutofillContext(shouldSave: true); + GoRouter.of(context).go(loadingPageRoute); } String? _validateInputOrElseReturnError(_InputType type) { @@ -355,7 +360,7 @@ class AuthPageWithState extends State { : _passwordTextController.text; if (_hasSubmittedOnce && value.isEmpty) { - return "Введите ${type == _InputType.login ? "логин" : "пароль"}!"; + return 'Введите ${type == _InputType.login ? 'логин' : 'пароль'}!'; } if (type == _InputType.login && value.contains(' ')) { @@ -365,45 +370,26 @@ class AuthPageWithState extends State { return null; } - void _loginButtonTapHandler( - BuildContext context, - AuthPageViewModel viewModel, - ) { - if (viewModel.state == ViewState.busy) { + Future _showFirstTimeOpenDialogs() async { + if (!await Injector.appInstance + .get() + .isFirstTimeOpenOnVersion()) { return; } - setState(() { - _hasSubmittedOnce = true; - }); - - if (_validateInputOrElseReturnError(_InputType.login) != null || - _validateInputOrElseReturnError(_InputType.password) != null) { + if (!mounted) { return; } - viewModel - .login( - _loginTextController.text, - _passwordTextController.text, - ) - .then((isLoginSuccess) { - if (isLoginSuccess) { - if (context.mounted) { - TextInput.finishAutofillContext(shouldSave: true); - GoRouter.of(context).go(loadingPageRoute); - } - } - }); - } -} + await showAnalyticsConfirmation(context); -extension on BuildContext { - double heightByFactor(double factor) => - MediaQuery.of(this).size.height * factor; -} + if (!mounted) { + return; + } -enum _InputType { - login, - password, + await showDialog( + context: context, + builder: (context) => const ChangelogDialog(), + ); + } } diff --git a/lib/ui/views/auth_page/widgets/auth_error_text.dart b/lib/ui/views/auth_page/widgets/auth_error_text.dart new file mode 100644 index 00000000..3b8ab990 --- /dev/null +++ b/lib/ui/views/auth_page/widgets/auth_error_text.dart @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 BitCodersNN + +import 'package:flutter/material.dart'; + +class AuthErrorText extends StatelessWidget { + final String? text; + final String? boldPrefix; + final double reservedHeight; + final EdgeInsets contentPadding; + + const AuthErrorText({ + this.text, + this.boldPrefix, + this.reservedHeight = 28, + this.contentPadding = EdgeInsets.zero, + super.key, + }); + + @override + Widget build(BuildContext context) { + final errorText = text; + final style = TextStyle( + fontFamily: 'Inter', + fontSize: 15, + color: Theme.of(context).colorScheme.error, + ); + + return AnimatedSize( + duration: _duration, + curve: _curve, + alignment: AlignmentDirectional.topStart, + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: reservedHeight), + child: AnimatedSwitcher( + duration: _duration, + switchInCurve: _curve, + switchOutCurve: Curves.easeIn, + layoutBuilder: (currentChild, previousChildren) => Stack( + alignment: AlignmentDirectional.centerStart, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ), + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: child, + ), + child: errorText == null || errorText.isEmpty + ? const SizedBox.shrink(key: ValueKey('empty')) + : Padding( + key: ValueKey('$boldPrefix$errorText'), + padding: contentPadding, + child: Text.rich( + TextSpan( + style: style, + children: [ + if (boldPrefix != null) + TextSpan( + text: boldPrefix, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + TextSpan(text: errorText), + ], + ), + ), + ), + ), + ), + ); + } + + static const Duration _duration = Duration(milliseconds: 250); + static const Curve _curve = Curves.easeOutCubic; +} diff --git a/lib/ui/views/auth_page/widgets/auth_text_field.dart b/lib/ui/views/auth_page/widgets/auth_text_field.dart new file mode 100644 index 00000000..27cd8103 --- /dev/null +++ b/lib/ui/views/auth_page/widgets/auth_text_field.dart @@ -0,0 +1,362 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 BitCodersNN + +import 'package:flutter/material.dart'; +import 'package:unn_mobile/ui/views/auth_page/widgets/auth_error_text.dart'; + +class _FieldMetrics { + static const double boxHeight = 56; + static const double errorSlotHeight = 40; + static const double contentPadding = 20; + static const double borderRadius = 16; + static const double borderWidth = 2; + static const double notchGap = 4; + static const double shadowOffsetY = 3; + static const double shadowBlurRadius = 4; + static const double labelFontSize = 15; + static const double valueFontSize = 17; + static const double shadowOpacity = 0.08; +} + +class AuthTextField extends StatefulWidget { + final TextEditingController controller; + final FocusNode focusNode; + final String labelText; + final String? errorText; + final bool obscured; + final Iterable? autofillHints; + final TextInputAction textInputAction; + final ValueChanged? onSubmitted; + + const AuthTextField({ + required this.controller, + required this.focusNode, + required this.labelText, + required this.obscured, + required this.autofillHints, + required this.textInputAction, + this.errorText, + this.onSubmitted, + super.key, + }); + + @override + State createState() => _AuthTextFieldState(); +} + +class _AuthTextFieldState extends State { + bool get _isLabelFloating => + widget.focusNode.hasFocus || + widget.errorText != null || + widget.controller.text.isNotEmpty; + + @override + void initState() { + super.initState(); + + widget.controller.addListener(_handleStateChanged); + widget.focusNode.addListener(_handleStateChanged); + } + + @override + void didUpdateWidget(AuthTextField oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_handleStateChanged); + widget.controller.addListener(_handleStateChanged); + } + + if (oldWidget.focusNode != widget.focusNode) { + oldWidget.focusNode.removeListener(_handleStateChanged); + widget.focusNode.addListener(_handleStateChanged); + } + } + + @override + void dispose() { + widget.controller.removeListener(_handleStateChanged); + widget.focusNode.removeListener(_handleStateChanged); + super.dispose(); + } + + void _handleStateChanged() => setState(() {}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final labelStyle = TextStyle( + fontSize: _FieldMetrics.labelFontSize, + color: theme.hintColor, + ); + + final valueStyle = TextStyle( + fontSize: _FieldMetrics.valueFontSize, + color: theme.colorScheme.onSurface, + ); + + final labelWidth = _measureLabelWidth(context, labelStyle); + final borderColor = _borderColor(theme); + final cursorColor = widget.errorText != null + ? theme.colorScheme.error + : theme.colorScheme.primary; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: _FieldMetrics.boxHeight, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: TweenAnimationBuilder( + tween: Tween(end: _isLabelFloating ? 1 : 0), + duration: _animationDuration, + curve: _animationCurve, + builder: (context, labelProgress, child) => + TweenAnimationBuilder( + tween: ColorTween(end: borderColor), + duration: _animationDuration, + curve: _animationCurve, + builder: (context, animatedColor, child) => _decoration( + context, + labelStyle: labelStyle, + labelProgress: labelProgress, + labelWidth: labelWidth, + borderColor: animatedColor ?? Colors.transparent, + ), + ), + ), + ), + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.focusNode.requestFocus, + ), + ), + Positioned.fill(child: _input(valueStyle, cursorColor)), + ], + ), + ), + AuthErrorText( + text: widget.errorText, + reservedHeight: _FieldMetrics.errorSlotHeight, + contentPadding: + const EdgeInsets.only(left: _FieldMetrics.contentPadding), + ), + ], + ); + } + + Widget _decoration( + BuildContext context, { + required TextStyle labelStyle, + required double labelProgress, + required double labelWidth, + required Color borderColor, + }) { + final label = Text(widget.labelText, style: labelStyle); + + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: CustomPaint( + painter: _FieldBoxPainter( + labelProgress: labelProgress, + labelWidth: labelWidth, + borderColor: borderColor, + fillColor: Theme.of(context).cardColor, + shadowColor: Theme.of(context) + .shadowColor + .withValues(alpha: _FieldMetrics.shadowOpacity), + ), + ), + ), + Positioned.fill( + child: IgnorePointer( + child: Stack( + clipBehavior: Clip.none, + children: [ + Align( + alignment: AlignmentDirectional.topStart, + child: Padding( + padding: const EdgeInsets.only( + left: _FieldMetrics.contentPadding, + ), + child: FractionalTranslation( + translation: const Offset(0, -0.5), + child: Opacity( + opacity: labelProgress, + child: label, + ), + ), + ), + ), + Align( + alignment: AlignmentDirectional.centerStart, + child: Padding( + padding: const EdgeInsets.only( + left: _FieldMetrics.contentPadding, + ), + child: Opacity( + opacity: 1 - labelProgress, + child: label, + ), + ), + ), + ], + ), + ), + ), + ], + ); + } + + Widget _input(TextStyle valueStyle, Color cursorColor) => Align( + alignment: Alignment.center, + child: SizedBox( + width: double.infinity, + child: TextField( + controller: widget.controller, + focusNode: widget.focusNode, + autocorrect: false, + enableSuggestions: false, + obscureText: widget.obscured, + autofillHints: widget.autofillHints, + textInputAction: widget.textInputAction, + onSubmitted: widget.onSubmitted, + cursorColor: cursorColor, + style: valueStyle, + textAlignVertical: TextAlignVertical.center, + decoration: const InputDecoration( + isDense: true, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, + contentPadding: EdgeInsets.only( + left: _FieldMetrics.contentPadding, + right: _FieldMetrics.contentPadding, + ), + ), + ), + ), + ); + + double _measureLabelWidth(BuildContext context, TextStyle labelStyle) { + final effectiveStyle = DefaultTextStyle.of(context).style.merge(labelStyle); + + final painter = TextPainter( + text: TextSpan(text: widget.labelText, style: effectiveStyle), + textDirection: Directionality.of(context), + maxLines: 1, + textScaler: MediaQuery.textScalerOf(context), + )..layout(); + + final width = painter.width; + painter.dispose(); + return width; + } + + Color _borderColor(ThemeData theme) { + if (widget.errorText != null) { + return theme.colorScheme.error; + } + + if (widget.focusNode.hasFocus) { + return theme.colorScheme.primary; + } + + return widget.controller.text.isEmpty + ? Colors.transparent + : theme.colorScheme.outlineVariant; + } + + static const Duration _animationDuration = Duration(milliseconds: 250); + static const Curve _animationCurve = Curves.easeOutCubic; +} + +class _FieldBoxPainter extends CustomPainter { + final double labelProgress; + final double labelWidth; + final Color borderColor; + final Color fillColor; + final Color shadowColor; + + const _FieldBoxPainter({ + required this.labelProgress, + required this.labelWidth, + required this.borderColor, + required this.fillColor, + required this.shadowColor, + }); + + @override + void paint(Canvas canvas, Size size) { + final borderWidth = _FieldMetrics.borderWidth; + final boxRect = RRect.fromRectAndRadius( + (Offset.zero & size).deflate(borderWidth / 2), + Radius.circular(_FieldMetrics.borderRadius - borderWidth / 2), + ); + + if (shadowColor != Colors.transparent) { + canvas.drawRRect( + boxRect.shift(const Offset(0, _FieldMetrics.shadowOffsetY)), + Paint() + ..color = shadowColor + ..maskFilter = const MaskFilter.blur( + BlurStyle.normal, + _FieldMetrics.shadowBlurRadius, + ), + ); + } + + canvas.drawRRect(boxRect, Paint()..color = fillColor); + + if (borderColor == Colors.transparent) { + return; + } + + final borderPath = Path()..addRRect(boxRect); + final borderPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeWidth = borderWidth + ..color = borderColor; + + final gapStart = + _FieldMetrics.contentPadding - _FieldMetrics.notchGap * labelProgress; + final gapEnd = _FieldMetrics.contentPadding + + labelWidth * labelProgress + + _FieldMetrics.notchGap * labelProgress; + + if (gapEnd > gapStart) { + final clipPath = Path.combine( + PathOperation.difference, + Path()..addRect(Offset.zero & size), + Path() + ..addRect(Rect.fromLTRB(gapStart, -borderWidth, gapEnd, borderWidth)), + ); + + canvas + ..save() + ..clipPath(clipPath) + ..drawPath(borderPath, borderPaint) + ..restore(); + } else { + canvas.drawPath(borderPath, borderPaint); + } + } + + @override + bool shouldRepaint(_FieldBoxPainter oldDelegate) => + oldDelegate.labelProgress != labelProgress || + oldDelegate.labelWidth != labelWidth || + oldDelegate.borderColor != borderColor || + oldDelegate.fillColor != fillColor || + oldDelegate.shadowColor != shadowColor; +} diff --git a/lib/ui/widgets/wide_button.dart b/lib/ui/widgets/wide_button.dart index c8437881..b6b359a8 100644 --- a/lib/ui/widgets/wide_button.dart +++ b/lib/ui/widgets/wide_button.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:unn_mobile/ui/unn_mobile_colors.dart'; class WideButton extends StatelessWidget { final FutureOr Function()? onPressed; @@ -16,6 +17,7 @@ class WideButton extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final extraColors = theme.unnMobileColors!; return SizedBox( width: double.infinity, child: DecoratedBox( @@ -24,8 +26,8 @@ class WideButton extends StatelessWidget { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - theme.colorScheme.primaryFixedDim, theme.primaryColor, + extraColors.wideButtonBottomColor!, ], ), borderRadius: BorderRadius.circular(50.0), @@ -34,6 +36,7 @@ class WideButton extends StatelessWidget { style: ElevatedButton.styleFrom( backgroundColor: Colors.transparent, shadowColor: Colors.transparent, + disabledBackgroundColor: theme.colorScheme.secondaryFixedDim, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(50.0), ), diff --git a/pubspec.yaml b/pubspec.yaml index bb2562de..68473cd4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: unn_mobile description: A mobile application for UNN Portal website publish_to: 'none' -version: 0.6.1+428 +version: 0.6.1+429 environment: sdk: '>=3.1.2 <4.0.0'