diff --git a/lib/ui/components/buttons/button_widget.dart b/lib/ui/components/buttons/button_widget.dart new file mode 100644 index 000000000..36f79a364 --- /dev/null +++ b/lib/ui/components/buttons/button_widget.dart @@ -0,0 +1,526 @@ +import 'package:ente_auth/models/execution_states.dart'; +import 'package:ente_auth/models/typedefs.dart'; +import 'package:ente_auth/theme/colors.dart'; +import 'package:ente_auth/theme/ente_theme.dart'; +import 'package:ente_auth/theme/text_style.dart'; +import 'package:ente_auth/ui/common/loading_widget.dart'; +import 'package:ente_auth/ui/components/models/button_result.dart'; +import 'package:ente_auth/ui/components/models/button_type.dart'; +import 'package:ente_auth/ui/components/models/custom_button_style.dart'; +import 'package:ente_auth/utils/debouncer.dart'; +import "package:ente_auth/utils/dialog_util.dart"; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +enum ButtonSize { small, large } + +enum ButtonAction { first, second, third, fourth, cancel, error } + +class ButtonWidget extends StatelessWidget { + final IconData? icon; + final String? labelText; + final ButtonType buttonType; + final FutureVoidCallback? onTap; + final bool isDisabled; + final ButtonSize buttonSize; + + ///Setting this flag to true will show a success confirmation as a 'check' + ///icon once the onTap(). This is expected to be used only if time taken to + ///execute onTap() takes less than debouce time. + final bool shouldShowSuccessConfirmation; + + ///Setting this flag to false will restrict the loading and success states of + ///the button from surfacing on the UI. The ExecutionState of the button will + ///change irrespective of the value of this flag. Only that it won't be + ///surfaced on the UI + final bool shouldSurfaceExecutionStates; + + /// iconColor should only be specified when we do not want to honor the default + /// iconColor based on buttonType. Most of the items, default iconColor is what + /// we need unless we want to pop out the icon in a non-primary button type + final Color? iconColor; + + ///Button action will only work if isInAlert is true + final ButtonAction? buttonAction; + + ///setting this flag to true will make the button appear like how it would + ///on dark theme irrespective of the app's theme. + final bool shouldStickToDarkTheme; + + ///isInAlert is to dismiss the alert if the action on the button is completed. + ///This should be set to true if the alert which uses this button needs to + ///return the Button's action. + final bool isInAlert; + + /// progressStatus can be used to display information about the action + /// progress when ExecutionState is in Progress. + final ValueNotifier? progressStatus; + + const ButtonWidget({ + Key? key, + required this.buttonType, + this.buttonSize = ButtonSize.large, + this.icon, + this.labelText, + this.onTap, + this.shouldStickToDarkTheme = false, + this.isDisabled = false, + this.buttonAction, + this.isInAlert = false, + this.iconColor, + this.shouldSurfaceExecutionStates = true, + this.progressStatus, + this.shouldShowSuccessConfirmation = false, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final colorScheme = + shouldStickToDarkTheme ? darkScheme : getEnteColorScheme(context); + final inverseColorScheme = shouldStickToDarkTheme + ? lightScheme + : getEnteColorScheme(context, inverse: true); + final textTheme = + shouldStickToDarkTheme ? darkTextTheme : getEnteTextTheme(context); + final inverseTextTheme = shouldStickToDarkTheme + ? lightTextTheme + : getEnteTextTheme(context, inverse: true); + final buttonStyle = CustomButtonStyle( + //Dummy default values since we need to keep these properties non-nullable + defaultButtonColor: Colors.transparent, + defaultBorderColor: Colors.transparent, + defaultIconColor: Colors.transparent, + defaultLabelStyle: textTheme.body, + ); + buttonStyle.defaultButtonColor = buttonType.defaultButtonColor(colorScheme); + buttonStyle.pressedButtonColor = buttonType.pressedButtonColor(colorScheme); + buttonStyle.disabledButtonColor = + buttonType.disabledButtonColor(colorScheme, buttonSize); + buttonStyle.defaultBorderColor = + buttonType.defaultBorderColor(colorScheme, buttonSize); + buttonStyle.pressedBorderColor = buttonType.pressedBorderColor( + colorScheme: colorScheme, + buttonSize: buttonSize, + ); + buttonStyle.disabledBorderColor = + buttonType.disabledBorderColor(colorScheme, buttonSize); + buttonStyle.defaultIconColor = iconColor ?? + buttonType.defaultIconColor( + colorScheme: colorScheme, + inverseColorScheme: inverseColorScheme, + ); + buttonStyle.pressedIconColor = + buttonType.pressedIconColor(colorScheme, buttonSize); + buttonStyle.disabledIconColor = + buttonType.disabledIconColor(colorScheme, buttonSize); + buttonStyle.defaultLabelStyle = buttonType.defaultLabelStyle( + textTheme: textTheme, + inverseTextTheme: inverseTextTheme, + ); + buttonStyle.pressedLabelStyle = + buttonType.pressedLabelStyle(textTheme, colorScheme, buttonSize); + buttonStyle.disabledLabelStyle = + buttonType.disabledLabelStyle(textTheme, colorScheme); + buttonStyle.checkIconColor = buttonType.checkIconColor(colorScheme); + + return ButtonChildWidget( + buttonStyle: buttonStyle, + buttonType: buttonType, + isDisabled: isDisabled, + buttonSize: buttonSize, + isInAlert: isInAlert, + onTap: onTap, + labelText: labelText, + icon: icon, + buttonAction: buttonAction, + shouldSurfaceExecutionStates: shouldSurfaceExecutionStates, + progressStatus: progressStatus, + shouldShowSuccessConfirmation: shouldShowSuccessConfirmation, + ); + } +} + +class ButtonChildWidget extends StatefulWidget { + final CustomButtonStyle buttonStyle; + final FutureVoidCallback? onTap; + final ButtonType buttonType; + final String? labelText; + final IconData? icon; + final bool isDisabled; + final ButtonSize buttonSize; + final ButtonAction? buttonAction; + final bool isInAlert; + final bool shouldSurfaceExecutionStates; + final ValueNotifier? progressStatus; + final bool shouldShowSuccessConfirmation; + + const ButtonChildWidget({ + Key? key, + required this.buttonStyle, + required this.buttonType, + required this.isDisabled, + required this.buttonSize, + required this.isInAlert, + required this.shouldSurfaceExecutionStates, + required this.shouldShowSuccessConfirmation, + this.progressStatus, + this.onTap, + this.labelText, + this.icon, + this.buttonAction, + }) : super(key: key); + + @override + State createState() => _ButtonChildWidgetState(); +} + +class _ButtonChildWidgetState extends State { + late Color buttonColor; + late Color borderColor; + late Color iconColor; + late TextStyle labelStyle; + late Color checkIconColor; + late Color loadingIconColor; + ValueNotifier? progressStatus; + + ///This is used to store the width of the button in idle state (small button) + ///to be used as width for the button when the loading/succes states comes. + double? widthOfButton; + final _debouncer = Debouncer(const Duration(milliseconds: 300)); + ExecutionState executionState = ExecutionState.idle; + Exception? _exception; + + @override + void initState() { + _setButtonTheme(); + super.initState(); + } + + @override + void didUpdateWidget(covariant ButtonChildWidget oldWidget) { + _setButtonTheme(); + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + if (executionState == ExecutionState.successful) { + Future.delayed(Duration(seconds: widget.isInAlert ? 1 : 2), () { + setState(() { + executionState = ExecutionState.idle; + }); + }); + } + return GestureDetector( + onTap: _shouldRegisterGestures ? _onTap : null, + onTapDown: _shouldRegisterGestures ? _onTapDown : null, + onTapUp: _shouldRegisterGestures ? _onTapUp : null, + onTapCancel: _shouldRegisterGestures ? _onTapCancel : null, + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(4)), + border: widget.buttonType == ButtonType.tertiaryCritical + ? Border.all(color: borderColor) + : null, + ), + child: AnimatedContainer( + duration: const Duration(milliseconds: 16), + width: widget.buttonSize == ButtonSize.large ? double.infinity : null, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(4)), + color: buttonColor, + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 175), + switchInCurve: Curves.easeInOutExpo, + switchOutCurve: Curves.easeInOutExpo, + child: executionState == ExecutionState.idle || + !widget.shouldSurfaceExecutionStates + ? widget.buttonType.hasTrailingIcon + ? Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + widget.labelText == null + ? const SizedBox.shrink() + : Flexible( + child: Padding( + padding: widget.icon == null + ? const EdgeInsets.symmetric( + horizontal: 8, + ) + : const EdgeInsets.only(right: 16), + child: Text( + widget.labelText!, + overflow: TextOverflow.ellipsis, + maxLines: 2, + style: labelStyle, + ), + ), + ), + widget.icon == null + ? const SizedBox.shrink() + : Icon( + widget.icon, + size: 20, + color: iconColor, + ), + ], + ) + : Builder( + builder: (context) { + SchedulerBinding.instance.addPostFrameCallback( + (timeStamp) { + final box = + context.findRenderObject() as RenderBox; + widthOfButton = box.size.width; + }, + ); + return Row( + mainAxisSize: + widget.buttonSize == ButtonSize.large + ? MainAxisSize.max + : MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + widget.icon == null + ? const SizedBox.shrink() + : Icon( + widget.icon, + size: 20, + color: iconColor, + ), + widget.icon == null || widget.labelText == null + ? const SizedBox.shrink() + : const SizedBox(width: 8), + widget.labelText == null + ? const SizedBox.shrink() + : Flexible( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + ), + child: Text( + widget.labelText!, + style: labelStyle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ) + ], + ); + }, + ) + : executionState == ExecutionState.inProgress + ? SizedBox( + width: widthOfButton, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + progressStatus == null + ? const SizedBox.shrink() + : ValueListenableBuilder( + valueListenable: progressStatus!, + builder: ( + BuildContext context, + String value, + Widget? child, + ) { + return Padding( + padding: + const EdgeInsets.only(right: 8.0), + child: Text( + value, + style: lightTextTheme.smallBold, + ), + ); + }, + ), + EnteLoadingWidget( + padding: 3, + color: loadingIconColor, + ), + ], + ), + ) + : executionState == ExecutionState.successful + ? SizedBox( + width: widthOfButton, + child: Icon( + Icons.check_outlined, + size: 20, + color: checkIconColor, + ), + ) + : const SizedBox.shrink(), //fallback + ), + ), + ), + ), + ); + } + + void _setButtonTheme() { + progressStatus = widget.progressStatus; + checkIconColor = widget.buttonStyle.checkIconColor ?? + widget.buttonStyle.defaultIconColor; + loadingIconColor = widget.buttonStyle.defaultIconColor; + if (widget.isDisabled) { + buttonColor = widget.buttonStyle.disabledButtonColor ?? + widget.buttonStyle.defaultButtonColor; + borderColor = widget.buttonStyle.disabledBorderColor ?? + widget.buttonStyle.defaultBorderColor; + iconColor = widget.buttonStyle.disabledIconColor ?? + widget.buttonStyle.defaultIconColor; + labelStyle = widget.buttonStyle.disabledLabelStyle ?? + widget.buttonStyle.defaultLabelStyle; + } else { + buttonColor = widget.buttonStyle.defaultButtonColor; + borderColor = widget.buttonStyle.defaultBorderColor; + iconColor = widget.buttonStyle.defaultIconColor; + labelStyle = widget.buttonStyle.defaultLabelStyle; + } + } + + bool get _shouldRegisterGestures => + !widget.isDisabled && executionState == ExecutionState.idle; + + void _onTap() async { + if (widget.onTap != null) { + _debouncer.run( + () => Future(() { + setState(() { + executionState = ExecutionState.inProgress; + }); + }), + ); + await widget.onTap!.call().then( + (value) { + _exception = null; + }, + onError: (error, stackTrace) { + executionState = ExecutionState.error; + _exception = error as Exception; + _debouncer.cancelDebounce(); + }, + ); + widget.shouldShowSuccessConfirmation && _debouncer.isActive() + ? executionState = ExecutionState.successful + : null; + _debouncer.cancelDebounce(); + if (executionState == ExecutionState.successful) { + setState(() {}); + } + + // when the time taken by widget.onTap is approximately equal to the debounce + // time, the callback is getting executed when/after the if condition + // below is executing/executed which results in execution state stuck at + // idle state. This Future is for delaying the execution of the if + // condition so that the calback in the debouncer finishes execution before. + await Future.delayed(const Duration(milliseconds: 5)); + } + if (executionState == ExecutionState.inProgress || + executionState == ExecutionState.error) { + if (executionState == ExecutionState.inProgress) { + if (mounted) { + setState(() { + executionState = ExecutionState.successful; + Future.delayed( + Duration( + seconds: widget.shouldSurfaceExecutionStates + ? (widget.isInAlert ? 1 : 2) + : 0, + ), () { + widget.isInAlert + ? _popWithButtonAction( + context, + buttonAction: widget.buttonAction, + ) + : null; + if (mounted) { + setState(() { + executionState = ExecutionState.idle; + }); + } + }); + }); + } + } + if (executionState == ExecutionState.error) { + setState(() { + executionState = ExecutionState.idle; + widget.isInAlert + ? Future.delayed( + const Duration(seconds: 0), + () => _popWithButtonAction( + context, + buttonAction: ButtonAction.error, + exception: _exception, + ), + ) + : null; + }); + } + } else { + if (widget.isInAlert) { + Future.delayed( + Duration(seconds: widget.shouldShowSuccessConfirmation ? 1 : 0), + () => + _popWithButtonAction(context, buttonAction: widget.buttonAction), + ); + } + } + } + + void _popWithButtonAction( + BuildContext context, { + required ButtonAction? buttonAction, + Exception? exception, + }) { + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(ButtonResult(widget.buttonAction, exception)); + } else if (exception != null) { + //This is to show the execution was unsuccessful if the dialog is manually + //closed before the execution completes. + showGenericErrorDialog(context: context); + } + } + + void _onTapDown(details) { + setState(() { + buttonColor = widget.buttonStyle.pressedButtonColor ?? + widget.buttonStyle.defaultButtonColor; + borderColor = widget.buttonStyle.pressedBorderColor ?? + widget.buttonStyle.defaultBorderColor; + iconColor = widget.buttonStyle.pressedIconColor ?? + widget.buttonStyle.defaultIconColor; + labelStyle = widget.buttonStyle.pressedLabelStyle ?? + widget.buttonStyle.defaultLabelStyle; + }); + } + + void _onTapUp(details) { + Future.delayed( + const Duration(milliseconds: 84), + () => setState(() { + setAllStylesToDefault(); + }), + ); + } + + void _onTapCancel() { + setState(() { + setAllStylesToDefault(); + }); + } + + void setAllStylesToDefault() { + buttonColor = widget.buttonStyle.defaultButtonColor; + borderColor = widget.buttonStyle.defaultBorderColor; + iconColor = widget.buttonStyle.defaultIconColor; + labelStyle = widget.buttonStyle.defaultLabelStyle; + } +} diff --git a/lib/ui/components/models/button_result.dart b/lib/ui/components/models/button_result.dart new file mode 100644 index 000000000..3d6666e58 --- /dev/null +++ b/lib/ui/components/models/button_result.dart @@ -0,0 +1,11 @@ +import 'package:ente_auth/ui/components/buttons/button_widget.dart'; + +class ButtonResult { + ///action can be null when action for the button that is returned when popping + ///the widget (dialog, actionSheet) which uses a ButtonWidget isn't + ///relevant/useful and so is not assigned a value when an instance of + ///ButtonWidget is created. + final ButtonAction? action; + final Exception? exception; + ButtonResult([this.action, this.exception]); +} diff --git a/lib/ui/components/models/button_type.dart b/lib/ui/components/models/button_type.dart new file mode 100644 index 000000000..8b9647c07 --- /dev/null +++ b/lib/ui/components/models/button_type.dart @@ -0,0 +1,205 @@ +import 'package:ente_auth/theme/colors.dart'; +import 'package:ente_auth/theme/text_style.dart'; +import 'package:ente_auth/ui/components/buttons/button_widget.dart'; +import 'package:flutter/material.dart'; + +enum ButtonType { + primary, + secondary, + neutral, + trailingIcon, + critical, + tertiaryCritical, + trailingIconPrimary, + trailingIconSecondary, + tertiary; + + bool get isPrimary => + this == ButtonType.primary || this == ButtonType.trailingIconPrimary; + + bool get hasTrailingIcon => + this == ButtonType.trailingIcon || + this == ButtonType.trailingIconPrimary || + this == ButtonType.trailingIconSecondary; + + bool get isSecondary => + this == ButtonType.secondary || this == ButtonType.trailingIconSecondary; + + bool get isCritical => + this == ButtonType.critical || this == ButtonType.tertiaryCritical; + + bool get isNeutral => + this == ButtonType.neutral || this == ButtonType.trailingIcon; + + Color defaultButtonColor(EnteColorScheme colorScheme) { + if (isPrimary) { + return colorScheme.primary500; + } + if (isSecondary) { + return colorScheme.fillFaint; + } + if (this == ButtonType.neutral || this == ButtonType.trailingIcon) { + return colorScheme.fillBase; + } + if (this == ButtonType.critical) { + return colorScheme.warning700; + } + if (this == ButtonType.tertiaryCritical) { + return Colors.transparent; + } + return Colors.transparent; + } + + //Returning null to fallback to default color + Color? pressedButtonColor(EnteColorScheme colorScheme) { + if (isPrimary) { + return colorScheme.primary700; + } + if (isSecondary) { + return colorScheme.fillFaintPressed; + } + if (isNeutral) { + return colorScheme.fillBasePressed; + } + if (this == ButtonType.critical) { + return colorScheme.warning800; + } + return null; + } + + //Returning null to fallback to default color + Color? disabledButtonColor( + EnteColorScheme colorScheme, + ButtonSize buttonSize, + ) { + if (buttonSize == ButtonSize.small && + (this == ButtonType.primary || + this == ButtonType.neutral || + this == ButtonType.critical)) { + return colorScheme.fillMuted; + } + if (isPrimary || this == ButtonType.critical || isNeutral) { + return colorScheme.fillFaint; + } + return null; + } + + Color defaultBorderColor(EnteColorScheme colorScheme, ButtonSize buttonSize) { + if (this == ButtonType.tertiaryCritical && buttonSize == ButtonSize.large) { + return colorScheme.warning700; + } + return Colors.transparent; + } + + //Returning null to fallback to default color + Color? pressedBorderColor({ + required EnteColorScheme colorScheme, + required ButtonSize buttonSize, + }) { + if (this == ButtonType.tertiaryCritical && buttonSize == ButtonSize.large) { + return colorScheme.warning700; + } + return null; + } + + //Returning null to fallback to default color + Color? disabledBorderColor( + EnteColorScheme colorScheme, + ButtonSize buttonSize, + ) { + if (this == ButtonType.tertiaryCritical && buttonSize == ButtonSize.large) { + return colorScheme.strokeMuted; + } + return null; + } + + Color defaultIconColor({ + required EnteColorScheme colorScheme, + required EnteColorScheme inverseColorScheme, + }) { + if (isPrimary || this == ButtonType.critical) { + return strokeBaseDark; + } + if (this == ButtonType.neutral || this == ButtonType.trailingIcon) { + return inverseColorScheme.strokeBase; + } + if (this == ButtonType.tertiaryCritical) { + return colorScheme.warning500; + } + //fallback + return colorScheme.strokeBase; + } + + //Returning null to fallback to default color + Color? pressedIconColor(EnteColorScheme colorScheme, ButtonSize buttonSize) { + if (this == ButtonType.tertiaryCritical) { + return colorScheme.warning700; + } + if (this == ButtonType.tertiary && buttonSize == ButtonSize.small) { + return colorScheme.fillBasePressed; + } + return null; + } + + //Returning null to fallback to default color + Color? disabledIconColor(EnteColorScheme colorScheme, ButtonSize buttonSize) { + if (isPrimary || + isSecondary || + isNeutral || + buttonSize == ButtonSize.small) { + return colorScheme.strokeMuted; + } + if (isCritical) { + return colorScheme.strokeFaint; + } + return null; + } + + TextStyle defaultLabelStyle({ + required EnteTextTheme textTheme, + required EnteTextTheme inverseTextTheme, + }) { + if (isPrimary || this == ButtonType.critical) { + return textTheme.bodyBold.copyWith(color: textBaseDark); + } + if (this == ButtonType.neutral || this == ButtonType.trailingIcon) { + return inverseTextTheme.bodyBold; + } + if (this == ButtonType.tertiaryCritical) { + return textTheme.bodyBold.copyWith(color: warning500); + } + //fallback + return textTheme.bodyBold; + } + + //Returning null to fallback to default color + TextStyle? pressedLabelStyle( + EnteTextTheme textTheme, + EnteColorScheme colorScheme, + ButtonSize buttonSize, + ) { + if (this == ButtonType.tertiaryCritical) { + return textTheme.bodyBold.copyWith(color: colorScheme.warning700); + } + if (this == ButtonType.tertiary && buttonSize == ButtonSize.small) { + return textTheme.bodyBold.copyWith(color: colorScheme.fillBasePressed); + } + return null; + } + + //Returning null to fallback to default color + TextStyle? disabledLabelStyle( + EnteTextTheme textTheme, + EnteColorScheme colorScheme, + ) { + return textTheme.bodyBold.copyWith(color: colorScheme.textFaint); + } + + //Returning null to fallback to default color + Color? checkIconColor(EnteColorScheme colorScheme) { + if (isSecondary) { + return colorScheme.primary500; + } + return null; + } +} diff --git a/lib/ui/components/models/custom_button_style.dart b/lib/ui/components/models/custom_button_style.dart new file mode 100644 index 000000000..e30504ab3 --- /dev/null +++ b/lib/ui/components/models/custom_button_style.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +class CustomButtonStyle { + Color defaultButtonColor; + Color? pressedButtonColor; + Color? disabledButtonColor; + Color defaultBorderColor; + Color? pressedBorderColor; + Color? disabledBorderColor; + Color defaultIconColor; + Color? pressedIconColor; + Color? disabledIconColor; + TextStyle defaultLabelStyle; + TextStyle? pressedLabelStyle; + TextStyle? disabledLabelStyle; + Color? checkIconColor; + + CustomButtonStyle({ + required this.defaultButtonColor, + this.pressedButtonColor, + this.disabledButtonColor, + required this.defaultBorderColor, + this.pressedBorderColor, + this.disabledBorderColor, + required this.defaultIconColor, + this.pressedIconColor, + this.disabledIconColor, + required this.defaultLabelStyle, + this.pressedLabelStyle, + this.disabledLabelStyle, + this.checkIconColor, + }); +}