text_input_widget.dart 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  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. final bool popNavAfterSubmission;
  28. final bool shouldSurfaceExecutionStates;
  29. const TextInputWidget({
  30. required this.onSubmit,
  31. this.label,
  32. this.message,
  33. this.hintText,
  34. this.prefixIcon,
  35. this.initialValue,
  36. this.alignMessage,
  37. this.autoFocus,
  38. this.maxLength,
  39. this.submitNotifier,
  40. this.alwaysShowSuccessState = false,
  41. this.showOnlyLoadingState = false,
  42. this.popNavAfterSubmission = false,
  43. this.shouldSurfaceExecutionStates = true,
  44. super.key,
  45. });
  46. @override
  47. State<TextInputWidget> createState() => _TextInputWidgetState();
  48. }
  49. class _TextInputWidgetState extends State<TextInputWidget> {
  50. ExecutionState executionState = ExecutionState.idle;
  51. final _textController = TextEditingController();
  52. final _debouncer = Debouncer(const Duration(milliseconds: 300));
  53. @override
  54. void initState() {
  55. widget.submitNotifier?.addListener(() {
  56. _onSubmit();
  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. super.dispose();
  70. }
  71. @override
  72. Widget build(BuildContext context) {
  73. if (executionState == ExecutionState.successful) {
  74. Future.delayed(Duration(seconds: widget.popNavAfterSubmission ? 1 : 2),
  75. () {
  76. setState(() {
  77. executionState = ExecutionState.idle;
  78. });
  79. });
  80. }
  81. final colorScheme = getEnteColorScheme(context);
  82. final textTheme = getEnteTextTheme(context);
  83. var textInputChildren = <Widget>[];
  84. if (widget.label != null) textInputChildren.add(Text(widget.label!));
  85. textInputChildren.add(
  86. ClipRRect(
  87. borderRadius: const BorderRadius.all(Radius.circular(8)),
  88. child: Material(
  89. child: TextFormField(
  90. autofocus: widget.autoFocus ?? false,
  91. controller: _textController,
  92. inputFormatters: widget.maxLength != null
  93. ? [LengthLimitingTextInputFormatter(50)]
  94. : null,
  95. decoration: InputDecoration(
  96. hintText: widget.hintText,
  97. hintStyle: textTheme.body.copyWith(color: colorScheme.textMuted),
  98. filled: true,
  99. fillColor: colorScheme.fillFaint,
  100. contentPadding: const EdgeInsets.fromLTRB(
  101. 12,
  102. 12,
  103. 0,
  104. 12,
  105. ),
  106. border: const UnderlineInputBorder(
  107. borderSide: BorderSide.none,
  108. ),
  109. focusedBorder: OutlineInputBorder(
  110. borderSide: BorderSide(color: colorScheme.strokeMuted),
  111. borderRadius: BorderRadius.circular(8),
  112. ),
  113. suffixIcon: Padding(
  114. padding: const EdgeInsets.symmetric(horizontal: 12),
  115. child: AnimatedSwitcher(
  116. duration: const Duration(milliseconds: 175),
  117. switchInCurve: Curves.easeInExpo,
  118. switchOutCurve: Curves.easeOutExpo,
  119. child: SuffixIconWidget(
  120. key: ValueKey(executionState),
  121. executionState: executionState,
  122. shouldSurfaceExecutionStates:
  123. widget.shouldSurfaceExecutionStates,
  124. ),
  125. ),
  126. ),
  127. prefixIconConstraints: const BoxConstraints(
  128. maxHeight: 44,
  129. maxWidth: 44,
  130. minHeight: 44,
  131. minWidth: 44,
  132. ),
  133. suffixIconConstraints: const BoxConstraints(
  134. maxHeight: 24,
  135. maxWidth: 48,
  136. minHeight: 24,
  137. minWidth: 48,
  138. ),
  139. prefixIcon: widget.prefixIcon != null
  140. ? Icon(
  141. widget.prefixIcon,
  142. color: colorScheme.strokeMuted,
  143. )
  144. : null,
  145. ),
  146. onEditingComplete: () {},
  147. ),
  148. ),
  149. ),
  150. );
  151. if (widget.message != null) {
  152. textInputChildren.add(
  153. Padding(
  154. padding: const EdgeInsets.symmetric(horizontal: 8),
  155. child: Align(
  156. alignment: widget.alignMessage ?? Alignment.centerLeft,
  157. child: Text(
  158. widget.message!,
  159. style: textTheme.small.copyWith(color: colorScheme.textMuted),
  160. ),
  161. ),
  162. ),
  163. );
  164. }
  165. textInputChildren =
  166. addSeparators(textInputChildren, const SizedBox(height: 4));
  167. return Column(
  168. mainAxisSize: MainAxisSize.min,
  169. crossAxisAlignment: CrossAxisAlignment.start,
  170. children: textInputChildren,
  171. );
  172. }
  173. void _onSubmit() async {
  174. _debouncer.run(
  175. () => Future(() {
  176. setState(() {
  177. executionState = ExecutionState.inProgress;
  178. });
  179. }),
  180. );
  181. await widget.onSubmit!
  182. .call(_textController.text)
  183. .onError((error, stackTrace) {
  184. executionState = ExecutionState.error;
  185. _debouncer.cancelDebounce();
  186. });
  187. widget.alwaysShowSuccessState && _debouncer.isActive()
  188. ? executionState = ExecutionState.successful
  189. : null;
  190. _debouncer.cancelDebounce();
  191. if (executionState == ExecutionState.successful) {
  192. setState(() {});
  193. }
  194. // when the time taken by widget.onSubmit is approximately equal to the debounce
  195. // time, the callback is getting executed when/after the if condition
  196. // below is executing/executed which results in execution state stuck at
  197. // idle state. This Future is for delaying the execution of the if
  198. // condition so that the calback in the debouncer finishes execution before.
  199. await Future.delayed(const Duration(milliseconds: 5));
  200. if (executionState == ExecutionState.inProgress ||
  201. executionState == ExecutionState.error) {
  202. if (executionState == ExecutionState.inProgress) {
  203. if (mounted) {
  204. setState(() {
  205. executionState = ExecutionState.successful;
  206. Future.delayed(
  207. Duration(
  208. seconds: widget.shouldSurfaceExecutionStates
  209. ? (widget.popNavAfterSubmission ? 1 : 2)
  210. : 0,
  211. ), () {
  212. widget.popNavAfterSubmission ? _popNavigatorStack(context) : null;
  213. if (mounted) {
  214. setState(() {
  215. executionState = ExecutionState.idle;
  216. });
  217. }
  218. });
  219. });
  220. }
  221. }
  222. if (executionState == ExecutionState.error) {
  223. setState(() {
  224. executionState = ExecutionState.idle;
  225. widget.popNavAfterSubmission
  226. ? Future.delayed(
  227. const Duration(seconds: 0),
  228. () => _popNavigatorStack(context),
  229. )
  230. : null;
  231. });
  232. }
  233. } else {
  234. if (widget.popNavAfterSubmission) {
  235. Future.delayed(
  236. Duration(seconds: widget.alwaysShowSuccessState ? 1 : 0),
  237. () => _popNavigatorStack(context),
  238. );
  239. }
  240. }
  241. }
  242. void _popNavigatorStack(BuildContext context) {
  243. Navigator.of(context).canPop() ? Navigator.of(context).pop() : null;
  244. }
  245. }
  246. class SuffixIconWidget extends StatelessWidget {
  247. final ExecutionState executionState;
  248. final bool shouldSurfaceExecutionStates;
  249. const SuffixIconWidget({
  250. required this.executionState,
  251. required this.shouldSurfaceExecutionStates,
  252. super.key,
  253. });
  254. @override
  255. Widget build(BuildContext context) {
  256. final Widget trailingWidget;
  257. final colorScheme = getEnteColorScheme(context);
  258. if (executionState == ExecutionState.idle ||
  259. !shouldSurfaceExecutionStates) {
  260. trailingWidget = const SizedBox.shrink();
  261. } else if (executionState == ExecutionState.inProgress) {
  262. trailingWidget = EnteLoadingWidget(
  263. color: colorScheme.strokeMuted,
  264. );
  265. } else if (executionState == ExecutionState.successful) {
  266. trailingWidget = Icon(
  267. Icons.check_outlined,
  268. size: 22,
  269. color: colorScheme.primary500,
  270. );
  271. } else {
  272. trailingWidget = const SizedBox.shrink();
  273. }
  274. return trailingWidget;
  275. }
  276. }