text_input_widget.dart 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter/services.dart';
  3. import 'package:photos/theme/ente_theme.dart';
  4. import 'package:photos/ui/common/loading_widget.dart';
  5. import 'package:photos/ui/components/dialog_widget.dart';
  6. import 'package:photos/utils/debouncer.dart';
  7. import 'package:photos/utils/separators_util.dart';
  8. enum ExecutionState {
  9. idle,
  10. inProgress,
  11. error,
  12. successful;
  13. }
  14. class TextInputWidget extends StatefulWidget {
  15. final String? label;
  16. final String? message;
  17. final String? hintText;
  18. final IconData? prefixIcon;
  19. final String? initialValue;
  20. final Alignment? alignMessage;
  21. final bool? autoFocus;
  22. final int? maxLength;
  23. final ValueNotifier? submitNotifier;
  24. final bool alwaysShowSuccessState;
  25. final bool showOnlyLoadingState;
  26. final FutureVoidCallbackParamStr onSubmit;
  27. const TextInputWidget({
  28. required this.onSubmit,
  29. this.label,
  30. this.message,
  31. this.hintText,
  32. this.prefixIcon,
  33. this.initialValue,
  34. this.alignMessage,
  35. this.autoFocus,
  36. this.maxLength,
  37. this.submitNotifier,
  38. this.alwaysShowSuccessState = true,
  39. this.showOnlyLoadingState = false,
  40. super.key,
  41. });
  42. @override
  43. State<TextInputWidget> createState() => _TextInputWidgetState();
  44. }
  45. class _TextInputWidgetState extends State<TextInputWidget> {
  46. final _textController = TextEditingController();
  47. final _debouncer = Debouncer(const Duration(milliseconds: 300));
  48. final ValueNotifier<ExecutionState> _executionStateNotifier =
  49. ValueNotifier(ExecutionState.idle);
  50. @override
  51. void initState() {
  52. widget.submitNotifier?.addListener(() {
  53. _onSubmit();
  54. });
  55. _executionStateNotifier.addListener(() {
  56. setState(() {});
  57. });
  58. if (widget.initialValue != null) {
  59. _textController.value = TextEditingValue(
  60. text: widget.initialValue!,
  61. selection: TextSelection.collapsed(offset: widget.initialValue!.length),
  62. );
  63. }
  64. super.initState();
  65. }
  66. @override
  67. void dispose() {
  68. widget.submitNotifier?.dispose();
  69. _executionStateNotifier.dispose();
  70. super.dispose();
  71. }
  72. @override
  73. Widget build(BuildContext context) {
  74. final colorScheme = getEnteColorScheme(context);
  75. final textTheme = getEnteTextTheme(context);
  76. var textInputChildren = <Widget>[];
  77. if (widget.label != null) textInputChildren.add(Text(widget.label!));
  78. textInputChildren.add(
  79. ClipRRect(
  80. borderRadius: const BorderRadius.all(Radius.circular(8)),
  81. child: Material(
  82. child: TextFormField(
  83. autofocus: widget.autoFocus ?? false,
  84. controller: _textController,
  85. inputFormatters: widget.maxLength != null
  86. ? [LengthLimitingTextInputFormatter(50)]
  87. : null,
  88. decoration: InputDecoration(
  89. hintText: widget.hintText,
  90. hintStyle: textTheme.body.copyWith(color: colorScheme.textMuted),
  91. filled: true,
  92. fillColor: colorScheme.fillFaint,
  93. contentPadding: const EdgeInsets.fromLTRB(
  94. 12,
  95. 12,
  96. 0,
  97. 12,
  98. ),
  99. border: const UnderlineInputBorder(
  100. borderSide: BorderSide.none,
  101. ),
  102. focusedBorder: OutlineInputBorder(
  103. borderSide: BorderSide(color: colorScheme.strokeMuted),
  104. borderRadius: BorderRadius.circular(8),
  105. ),
  106. suffixIcon: Padding(
  107. padding: const EdgeInsets.symmetric(horizontal: 12),
  108. child: AnimatedSwitcher(
  109. duration: const Duration(milliseconds: 175),
  110. switchInCurve: Curves.easeInExpo,
  111. switchOutCurve: Curves.easeOutExpo,
  112. child: SuffixIconWidget(
  113. _executionStateNotifier.value,
  114. key: ValueKey(_executionStateNotifier.value),
  115. ),
  116. ),
  117. ),
  118. prefixIconConstraints: const BoxConstraints(
  119. maxHeight: 44,
  120. maxWidth: 44,
  121. minHeight: 44,
  122. minWidth: 44,
  123. ),
  124. suffixIconConstraints: const BoxConstraints(
  125. maxHeight: 24,
  126. maxWidth: 48,
  127. minHeight: 24,
  128. minWidth: 48,
  129. ),
  130. prefixIcon: widget.prefixIcon != null
  131. ? Icon(
  132. widget.prefixIcon,
  133. color: colorScheme.strokeMuted,
  134. )
  135. : null,
  136. ),
  137. onEditingComplete: () {},
  138. ),
  139. ),
  140. ),
  141. );
  142. if (widget.message != null) {
  143. textInputChildren.add(
  144. Padding(
  145. padding: const EdgeInsets.symmetric(horizontal: 8),
  146. child: Align(
  147. alignment: widget.alignMessage ?? Alignment.centerLeft,
  148. child: Text(
  149. widget.message!,
  150. style: textTheme.small.copyWith(color: colorScheme.textMuted),
  151. ),
  152. ),
  153. ),
  154. );
  155. }
  156. textInputChildren =
  157. addSeparators(textInputChildren, const SizedBox(height: 4));
  158. return Column(
  159. mainAxisSize: MainAxisSize.min,
  160. crossAxisAlignment: CrossAxisAlignment.start,
  161. children: textInputChildren,
  162. );
  163. }
  164. Future<void> _onSubmit() async {
  165. _debouncer.run(
  166. () => Future(
  167. () {
  168. _executionStateNotifier.value = ExecutionState.inProgress;
  169. },
  170. ),
  171. );
  172. await widget.onSubmit.call(_textController.text).then(
  173. (value) {
  174. widget.alwaysShowSuccessState
  175. ? _executionStateNotifier.value = ExecutionState.successful
  176. : null;
  177. },
  178. onError: (error, stackTrace) => _debouncer.cancelDebounce(),
  179. );
  180. _debouncer.cancelDebounce();
  181. if (widget.alwaysShowSuccessState) {
  182. Future.delayed(const Duration(seconds: 2), () {
  183. _executionStateNotifier.value = ExecutionState.idle;
  184. });
  185. return;
  186. }
  187. if (_executionStateNotifier.value == ExecutionState.inProgress) {
  188. if (widget.showOnlyLoadingState) {
  189. _executionStateNotifier.value = ExecutionState.idle;
  190. } else {
  191. _executionStateNotifier.value = ExecutionState.successful;
  192. Future.delayed(const Duration(seconds: 2), () {
  193. _executionStateNotifier.value = ExecutionState.idle;
  194. });
  195. }
  196. }
  197. }
  198. }
  199. class SuffixIconWidget extends StatelessWidget {
  200. final ExecutionState executionState;
  201. const SuffixIconWidget(this.executionState, {super.key});
  202. @override
  203. Widget build(BuildContext context) {
  204. final Widget trailingWidget;
  205. final colorScheme = getEnteColorScheme(context);
  206. if (executionState == ExecutionState.idle) {
  207. trailingWidget = const SizedBox.shrink();
  208. } else if (executionState == ExecutionState.inProgress) {
  209. trailingWidget = EnteLoadingWidget(
  210. color: colorScheme.strokeMuted,
  211. );
  212. } else if (executionState == ExecutionState.successful) {
  213. trailingWidget = Icon(
  214. Icons.check_outlined,
  215. size: 22,
  216. color: colorScheme.primary500,
  217. );
  218. } else {
  219. trailingWidget = const SizedBox.shrink();
  220. }
  221. return trailingWidget;
  222. }
  223. }