text_input_widget.dart 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  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. _onSubmit();
  148. },
  149. ),
  150. ),
  151. ),
  152. );
  153. if (widget.message != null) {
  154. textInputChildren.add(
  155. Padding(
  156. padding: const EdgeInsets.symmetric(horizontal: 8),
  157. child: Align(
  158. alignment: widget.alignMessage ?? Alignment.centerLeft,
  159. child: Text(
  160. widget.message!,
  161. style: textTheme.small.copyWith(color: colorScheme.textMuted),
  162. ),
  163. ),
  164. ),
  165. );
  166. }
  167. textInputChildren =
  168. addSeparators(textInputChildren, const SizedBox(height: 4));
  169. return Column(
  170. mainAxisSize: MainAxisSize.min,
  171. crossAxisAlignment: CrossAxisAlignment.start,
  172. children: textInputChildren,
  173. );
  174. }
  175. void _onSubmit() async {
  176. _debouncer.run(
  177. () => Future(() {
  178. setState(() {
  179. executionState = ExecutionState.inProgress;
  180. });
  181. }),
  182. );
  183. await widget.onSubmit!
  184. .call(_textController.text)
  185. .onError((error, stackTrace) {
  186. executionState = ExecutionState.error;
  187. _debouncer.cancelDebounce();
  188. });
  189. widget.alwaysShowSuccessState && _debouncer.isActive()
  190. ? executionState = ExecutionState.successful
  191. : null;
  192. _debouncer.cancelDebounce();
  193. if (executionState == ExecutionState.successful) {
  194. setState(() {});
  195. }
  196. // when the time taken by widget.onSubmit is approximately equal to the debounce
  197. // time, the callback is getting executed when/after the if condition
  198. // below is executing/executed which results in execution state stuck at
  199. // idle state. This Future is for delaying the execution of the if
  200. // condition so that the calback in the debouncer finishes execution before.
  201. await Future.delayed(const Duration(milliseconds: 5));
  202. if (executionState == ExecutionState.inProgress ||
  203. executionState == ExecutionState.error) {
  204. if (executionState == ExecutionState.inProgress) {
  205. if (mounted) {
  206. if (widget.showOnlyLoadingState) {
  207. setState(() {
  208. executionState = ExecutionState.idle;
  209. });
  210. } else {
  211. setState(() {
  212. executionState = ExecutionState.successful;
  213. Future.delayed(
  214. Duration(
  215. seconds: widget.shouldSurfaceExecutionStates
  216. ? (widget.popNavAfterSubmission ? 1 : 2)
  217. : 0,
  218. ), () {
  219. widget.popNavAfterSubmission
  220. ? _popNavigatorStack(context)
  221. : null;
  222. if (mounted) {
  223. setState(() {
  224. executionState = ExecutionState.idle;
  225. });
  226. }
  227. });
  228. });
  229. }
  230. }
  231. }
  232. if (executionState == ExecutionState.error) {
  233. setState(() {
  234. executionState = ExecutionState.idle;
  235. widget.popNavAfterSubmission
  236. ? Future.delayed(
  237. const Duration(seconds: 0),
  238. () => _popNavigatorStack(context),
  239. )
  240. : null;
  241. });
  242. }
  243. } else {
  244. if (widget.popNavAfterSubmission) {
  245. Future.delayed(
  246. Duration(seconds: widget.alwaysShowSuccessState ? 1 : 0),
  247. () => _popNavigatorStack(context),
  248. );
  249. }
  250. }
  251. }
  252. void _popNavigatorStack(BuildContext context) {
  253. Navigator.of(context).canPop() ? Navigator.of(context).pop() : null;
  254. }
  255. }
  256. class SuffixIconWidget extends StatelessWidget {
  257. final ExecutionState executionState;
  258. final bool shouldSurfaceExecutionStates;
  259. const SuffixIconWidget({
  260. required this.executionState,
  261. required this.shouldSurfaceExecutionStates,
  262. super.key,
  263. });
  264. @override
  265. Widget build(BuildContext context) {
  266. final Widget trailingWidget;
  267. final colorScheme = getEnteColorScheme(context);
  268. if (executionState == ExecutionState.idle ||
  269. !shouldSurfaceExecutionStates) {
  270. trailingWidget = const SizedBox.shrink();
  271. } else if (executionState == ExecutionState.inProgress) {
  272. trailingWidget = EnteLoadingWidget(
  273. color: colorScheme.strokeMuted,
  274. );
  275. } else if (executionState == ExecutionState.successful) {
  276. trailingWidget = Icon(
  277. Icons.check_outlined,
  278. size: 22,
  279. color: colorScheme.primary500,
  280. );
  281. } else {
  282. trailingWidget = const SizedBox.shrink();
  283. }
  284. return trailingWidget;
  285. }
  286. }