Line data Source code
1 : import 'package:flutter/material.dart';
2 :
3 : import 'custom_action_button.dart';
4 :
5 : /// A customizable button that ensures the [onPressed] callback is invoked
6 : /// only once per press. It prevents multiple invocations during a single press,
7 : /// making it ideal for handling actions that shouldn't be executed multiple times
8 : /// concurrently, such as network requests.
9 : ///
10 : /// The [SinglePressButton] provides options to display a loading indicator
11 : /// while processing, customize its appearance, and handle processing states
12 : /// with callbacks.
13 : ///
14 : /// Example usage:
15 : /// ```dart
16 : /// SinglePressButton(
17 : /// onPressed: () async {
18 : /// await performNetworkRequest();
19 : /// },
20 : /// child: Text('Submit'),
21 : /// showLoadingIndicator: true,
22 : /// backgroundColor: Colors.blue,
23 : /// disabledColor: Colors.blueAccent,
24 : /// borderRadius: 12.0,
25 : /// padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
26 : /// width: 200,
27 : /// height: 50,
28 : /// onStartProcessing: () {
29 : /// // Optional: Actions to perform when processing starts.
30 : /// },
31 : /// onFinishProcessing: () {
32 : /// // Optional: Actions to perform when processing finishes.
33 : /// },
34 : /// onError: (error) {
35 : /// // Optional: Handle errors during processing.
36 : /// },
37 : /// );
38 : /// ```
39 : class SinglePressButton extends StatefulWidget {
40 : /// The widget below this widget in the tree.
41 : ///
42 : /// Typically, this is the content of the button, such as text or an icon.
43 : final Widget child;
44 :
45 : /// The callback that is called when the button is tapped.
46 : ///
47 : /// This callback can be asynchronous and is invoked only once per press.
48 : /// It is responsible for handling the primary action of the button.
49 : final Future<void> Function() onPressed;
50 :
51 : /// The amount of space to surround the child inside the button.
52 : ///
53 : /// If not specified, default padding is applied.
54 : final EdgeInsetsGeometry? padding;
55 :
56 : /// The external margin around the button.
57 : ///
58 : /// This margin wraps the button, providing space between it and other widgets.
59 : final EdgeInsetsGeometry? margin;
60 :
61 : /// The background color of the button when enabled.
62 : ///
63 : /// If not specified, the button uses the theme's primary color.
64 : final Color? backgroundColor;
65 :
66 : /// The background color of the button when disabled.
67 : ///
68 : /// This color is displayed when the button is in a processing state.
69 : /// If not specified, it defaults to the theme's disabled color.
70 : final Color? disabledColor;
71 :
72 : /// The border radius of the button.
73 : ///
74 : /// Controls the roundness of the button's corners.
75 : /// Defaults to 8.0.
76 : final double borderRadius;
77 :
78 : /// The text style for the button's label.
79 : ///
80 : /// If not specified, it inherits the theme's text style.
81 : final TextStyle? textStyle;
82 :
83 : /// The elevation of the button.
84 : ///
85 : /// Controls the shadow depth of the button.
86 : /// If not specified, it defaults to the theme's elevated button elevation.
87 : final double? elevation;
88 :
89 : /// The shape of the button's material.
90 : ///
91 : /// Allows for customizing the button's outline and borders.
92 : /// If not specified, a rounded rectangle is used.
93 : final OutlinedBorder? shape;
94 :
95 : /// Whether to show a loading indicator while processing.
96 : ///
97 : /// If set to `true`, a [CircularProgressIndicator] is displayed on top of the button's child.
98 : /// Defaults to `false`.
99 : final bool showLoadingIndicator;
100 :
101 : /// The color of the loading indicator.
102 : ///
103 : /// If not specified, it defaults to the theme's [ColorScheme.onPrimary].
104 : final Color? loadingIndicatorColor;
105 :
106 : /// The width of the button.
107 : ///
108 : /// If not specified, the button will size itself based on its child's size constraints.
109 : final double? width;
110 :
111 : /// The height of the button.
112 : ///
113 : /// If not specified, the button will size itself based on its child's size constraints.
114 : final double? height;
115 :
116 : /// Callback invoked when the button starts processing.
117 : ///
118 : /// Useful for triggering actions like disabling other UI elements.
119 : final VoidCallback? onStartProcessing;
120 :
121 : /// Callback invoked when the button finishes processing.
122 : ///
123 : /// Useful for resetting states or triggering subsequent actions.
124 : final VoidCallback? onFinishProcessing;
125 :
126 : /// Callback invoked when an error occurs during processing.
127 : ///
128 : /// Provides a way to handle exceptions thrown by the [onPressed] callback.
129 : final void Function(Object error)? onError;
130 :
131 : /// Creates a [SinglePressButton].
132 : ///
133 : /// The [child] and [onPressed] parameters are required.
134 : /// The [borderRadius] defaults to 8.0, and [showLoadingIndicator] defaults to `false`.
135 1 : const SinglePressButton({
136 : super.key,
137 : required this.child,
138 : required this.onPressed,
139 : this.padding,
140 : this.margin,
141 : this.backgroundColor,
142 : this.disabledColor,
143 : this.borderRadius = 8.0,
144 : this.textStyle,
145 : this.elevation,
146 : this.shape,
147 : this.showLoadingIndicator = false,
148 : this.loadingIndicatorColor,
149 : this.width,
150 : this.height,
151 : this.onStartProcessing,
152 : this.onFinishProcessing,
153 : this.onError,
154 : });
155 :
156 1 : @override
157 1 : State<SinglePressButton> createState() => _SinglePressButtonState();
158 : }
159 :
160 : class _SinglePressButtonState extends State<SinglePressButton> {
161 : /// Indicates whether the button is currently processing an action.
162 : ///
163 : /// When `true`, the button is disabled, and a loading indicator is shown if enabled.
164 : bool _isProcessing = false;
165 :
166 : /// Handles the button press by invoking [widget.onPressed].
167 : ///
168 : /// Ensures that the callback is invoked only once per press.
169 : /// Manages the processing state and handles optional callbacks for processing events.
170 1 : Future<void> _handlePress() async {
171 1 : if (_isProcessing) return;
172 :
173 2 : setState(() {
174 1 : _isProcessing = true;
175 : });
176 :
177 : // Invoke onStartProcessing callback if provided.
178 3 : widget.onStartProcessing?.call();
179 :
180 : try {
181 3 : await widget.onPressed();
182 : } catch (error) {
183 : // Invoke onError callback if provided.
184 2 : if (widget.onError != null) {
185 3 : widget.onError!(error);
186 : } else {
187 : // If no onError is provided, rethrow the exception.
188 : rethrow;
189 : }
190 : } finally {
191 1 : if (mounted) {
192 2 : setState(() {
193 1 : _isProcessing = false;
194 : });
195 :
196 : // Invoke onFinishProcessing callback if provided.
197 3 : widget.onFinishProcessing?.call();
198 : }
199 : }
200 : }
201 :
202 1 : @override
203 : Widget build(BuildContext context) {
204 : // Determine the button's background color based on its state.
205 1 : final Color backgroundColor = _isProcessing
206 4 : ? (widget.disabledColor ?? Theme.of(context).disabledColor)
207 4 : : (widget.backgroundColor ?? Theme.of(context).primaryColor);
208 :
209 : // Determine the text style, merging with provided [textStyle] if any.
210 2 : final TextStyle effectiveTextStyle = widget.textStyle ??
211 4 : Theme.of(context).textTheme.labelLarge!.copyWith(
212 2 : color: widget.backgroundColor != null
213 0 : ? Theme.of(context).colorScheme.onPrimary
214 4 : : Theme.of(context).textTheme.labelLarge!.color,
215 : );
216 :
217 1 : return Container(
218 2 : margin: widget.margin, // Apply the external margin here
219 1 : child: CustomActionButton(
220 2 : onPressed: _isProcessing ? null : _handlePress,
221 2 : padding: widget.padding,
222 : backgroundColor: backgroundColor,
223 2 : borderRadius: widget.borderRadius,
224 2 : elevation: widget.elevation,
225 2 : shape: widget.shape,
226 2 : width: widget.width,
227 2 : height: widget.height,
228 1 : child: Stack(
229 : alignment: Alignment.center,
230 1 : children: [
231 : // Original child
232 1 : DefaultTextStyle(
233 : style: effectiveTextStyle,
234 2 : child: widget.child,
235 : ),
236 :
237 : // Loading indicator overlay
238 3 : if (_isProcessing && widget.showLoadingIndicator)
239 1 : SizedBox(
240 : width: 20,
241 : height: 20,
242 1 : child: CircularProgressIndicator(
243 1 : valueColor: AlwaysStoppedAnimation<Color>(
244 2 : widget.loadingIndicatorColor ??
245 3 : Theme.of(context).colorScheme.onPrimary,
246 : ),
247 : strokeWidth: 2.5,
248 : ),
249 : ),
250 : ],
251 : ),
252 : ),
253 : );
254 : }
255 : }
|