Skip to content

Commit 1046369

Browse files
authored
Implement retry logic (#69)
# 🔄 Add Manual Retry Functionality with Exponential Backoff ## 📋 Summary This PR adds a manual retry mechanism to the `OfflineBuilder` widget, allowing users to trigger connectivity checks with exponential backoff. This enhancement provides better control over network status verification, particularly useful when automatic connectivity detection may be delayed or when users want to force a re-check. ## ✨ Key Features - **Manual Retry Method**: New `retry()` method allows manual triggering of connectivity checks - **Exponential Backoff**: Uses 2^n seconds strategy (1s, 2s, 4s, 8s, 16s) with configurable max retries (default: 5) - **Smart Protection**: Cooldown between retries (default: 2 seconds) prevents spam - **State Management**: New `onBuilderReady` callback provides access to retry state and methods - **Custom Callbacks**: Optional `onRetry` callback for custom logic during retry attempts - **State Properties**: Exposes `canRetry`, `isRetrying`, and `retryCount` for UI control - **Auto Reset**: Retry counter automatically resets when connection is restored ## 🔧 API Changes Added optional parameters to `OfflineBuilder`: - `maxRetries` - Maximum retry attempts (default: 5) - `retryCooldown` - Cooldown duration between retries (default: 2 seconds) - `onRetry` - Custom callback executed during retry - `onBuilderReady` - Callback providing access to `OfflineBuilderState` Added public properties to `OfflineBuilderState`: - `canRetry` - Boolean indicating if retry is available - `isRetrying` - Boolean indicating if retry is in progress - `retryCount` - Current number of retry attempts - `retry()` - Method to trigger manual retry ## 🧪 Testing - ✅ All 29 tests pass (23 existing + 6 new) - ✅ New tests cover retry parameters, callbacks, max limits, and counter behaviour - ✅ Fully backward compatible with existing implementations ## 🔄 Backward Compatibility ✅ **100% backward compatible** - all new parameters are optional with sensible defaults. No breaking changes to existing code. ## 📚 Documentation - Updated example app with Demo 1 showcasing retry functionality with visual feedback - Added inline documentation for all new APIs - Included retry button states, progress indicators, and status messages ## 🎯 Use Cases - Apps needing explicit user control over connectivity checks - Scenarios where automatic detection is unreliable (VPNs, proxies) - User feedback during network troubleshooting - Manual "refresh" functionality in offline-first apps ## 📊 Changes - `lib/src/main.dart` - Added retry logic and state management - `test/flutter_offline_test.dart` - Added 6 comprehensive retry tests - `example/lib/widgets/demo_1.dart` - Enhanced demo with retry UI
1 parent 30c008f commit 1046369

8 files changed

Lines changed: 707 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
## [6.1.0]
2+
3+
Added manual retry functionality with exponential backoff:
4+
- Introduced `OfflineRetryController` extending `ChangeNotifier` for retry state management
5+
- Configurable retry limits (default: 5) and cooldown periods (default: 2s)
6+
- Exponential backoff retry strategy (2^n seconds: 1, 2, 4, 8, 16...)
7+
- Template method pattern with overridable `onRetry()` and `onRetryError()` methods
8+
- Automatic retry counter reset when connection is restored
9+
- Added `clock` package dependency for testable time operations
10+
- Enhanced demo with retry button and real-time status indicators
11+
- Comprehensive test coverage (32 tests, 100% coverage)
12+
113
## [6.0.0]
214

315
Bump `package:connectivity_plus` to `^7.0.0`

README.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,90 @@ class DemoPage extends StatelessWidget {
8484

8585
For more info, please, refer to the `main.dart` in the example.
8686

87+
## 🔄 Retry Functionality
88+
89+
Manually retry connectivity checks with exponential backoff:
90+
91+
```dart
92+
class MyWidget extends StatefulWidget {
93+
@override
94+
State<MyWidget> createState() => _MyWidgetState();
95+
}
96+
97+
class _MyWidgetState extends State<MyWidget> {
98+
late final OfflineRetryController _retryController;
99+
100+
@override
101+
void initState() {
102+
super.initState();
103+
_retryController = OfflineRetryController(
104+
maxRetries: 5,
105+
retryCooldown: const Duration(seconds: 2),
106+
);
107+
_retryController.addListener(() => setState(() {}));
108+
}
109+
110+
@override
111+
void dispose() {
112+
_retryController.dispose();
113+
super.dispose();
114+
}
115+
116+
@override
117+
Widget build(BuildContext context) {
118+
return OfflineBuilder(
119+
retryController: _retryController,
120+
connectivityBuilder: (context, connectivity, child) {
121+
final connected = !connectivity.contains(ConnectivityResult.none);
122+
return Column(
123+
children: [
124+
Text(connected ? 'ONLINE' : 'OFFLINE'),
125+
if (!connected)
126+
ElevatedButton(
127+
onPressed: _retryController.canRetry ? _retryController.retry : null,
128+
child: Text('Retry (${_retryController.retryCount}/5)'),
129+
),
130+
],
131+
);
132+
},
133+
);
134+
}
135+
}
136+
```
137+
138+
### Custom Retry Logic
139+
140+
Override `onRetry()` or `onRetryError()` for custom behavior:
141+
142+
```dart
143+
class CustomRetryController extends OfflineRetryController {
144+
CustomRetryController() : super(maxRetries: 3);
145+
146+
@override
147+
Future<void> onRetry() async {
148+
print('Retrying connection...');
149+
}
150+
151+
@override
152+
void onRetryError(Object error, StackTrace stackTrace) {
153+
print('Retry failed: $error');
154+
}
155+
}
156+
```
157+
158+
**Features:**
159+
- Exponential backoff (1s, 2s, 4s, 8s, 16s)
160+
- Configurable retry limits and cooldown
161+
- Auto-reset on reconnection
162+
- Extends `ChangeNotifier` for reactive UI updates
163+
164+
## 🧪 Testing
165+
166+
Run tests with:
167+
```bash
168+
flutter test
169+
```
170+
87171
## 📷 Screenshots
88172

89173
<table>

example/lib/widgets/demo_1.dart

Lines changed: 132 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,137 @@
1+
import 'dart:async';
12
import 'package:flutter/material.dart';
23
import 'package:flutter_offline/flutter_offline.dart';
34

4-
class Demo1 extends StatelessWidget {
5+
class Demo1 extends StatefulWidget {
56
const Demo1({Key? key}) : super(key: key);
67

8+
@override
9+
State<Demo1> createState() => _Demo1State();
10+
}
11+
12+
class _Demo1State extends State<Demo1> {
13+
late final OfflineRetryController _retryController;
14+
Timer? _cooldownTimer;
15+
16+
@override
17+
void initState() {
18+
super.initState();
19+
_retryController = OfflineRetryController(
20+
maxRetries: 5,
21+
retryCooldown: const Duration(seconds: 2),
22+
);
23+
24+
// Listen to controller changes to update UI
25+
_retryController.addListener(() {
26+
if (mounted) {
27+
setState(() {});
28+
}
29+
});
30+
}
31+
32+
@override
33+
void dispose() {
34+
_cooldownTimer?.cancel();
35+
_retryController.dispose();
36+
super.dispose();
37+
}
38+
39+
void _startCooldownTimer() {
40+
_cooldownTimer?.cancel();
41+
_cooldownTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) {
42+
if (mounted) {
43+
setState(() {}); // Refresh UI to update button state
44+
45+
// Stop timer if we can retry again (cooldown ended)
46+
if (_retryController.canRetry) {
47+
timer.cancel();
48+
}
49+
} else {
50+
timer.cancel();
51+
}
52+
});
53+
}
54+
755
@override
856
Widget build(BuildContext context) {
957
return OfflineBuilder(
58+
retryController: _retryController,
1059
connectivityBuilder: (
1160
BuildContext context,
1261
List<ConnectivityResult> connectivity,
1362
Widget child,
1463
) {
1564
final connected = !connectivity.contains(ConnectivityResult.none);
65+
1666
return Stack(
1767
fit: StackFit.expand,
1868
children: [
19-
child,
69+
Column(
70+
mainAxisAlignment: MainAxisAlignment.center,
71+
crossAxisAlignment: CrossAxisAlignment.center,
72+
children: <Widget>[
73+
const Text(
74+
'Enhanced Demo with Retry Functionality',
75+
textAlign: TextAlign.center,
76+
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
77+
),
78+
const SizedBox(height: 16),
79+
const Text(
80+
'Turn off your internet to see the offline state.',
81+
),
82+
const SizedBox(height: 24),
83+
Column(
84+
children: [
85+
ElevatedButton.icon(
86+
onPressed: !connected && _retryController.canRetry
87+
? () async {
88+
await _retryController.retry();
89+
_startCooldownTimer(); // Start timer to refresh UI
90+
}
91+
: null,
92+
icon: _retryController.isRetrying
93+
? const SizedBox(
94+
width: 16,
95+
height: 16,
96+
child: CircularProgressIndicator(strokeWidth: 2),
97+
)
98+
: const Icon(Icons.refresh),
99+
label: Text(_retryController.isRetrying ? 'Retrying...' : 'Retry Connection'),
100+
),
101+
const SizedBox(height: 8),
102+
Text(
103+
'Retry attempts: ${_retryController.retryCount}/5',
104+
style: const TextStyle(fontSize: 12, color: Colors.grey),
105+
),
106+
const SizedBox(height: 4),
107+
if (!connected) ...[
108+
if (_retryController.isRetrying)
109+
Text(
110+
'Retrying with ${1 << _retryController.retryCount}s exponential backoff...',
111+
style: const TextStyle(fontSize: 12, color: Colors.blue),
112+
)
113+
else if (_retryController.retryCount == 5)
114+
const Text(
115+
'Max retries reached (5/5)',
116+
style: TextStyle(fontSize: 12, color: Colors.red),
117+
)
118+
else if (!_retryController.canRetry &&
119+
_retryController.retryCount > 0 &&
120+
!_retryController.isRetrying)
121+
const Text(
122+
'Cooldown active - wait 2s before next retry',
123+
style: TextStyle(fontSize: 12, color: Colors.orange),
124+
)
125+
else
126+
const Text(
127+
'Ready to retry',
128+
style: TextStyle(fontSize: 12, color: Colors.green),
129+
),
130+
],
131+
],
132+
),
133+
],
134+
),
20135
Positioned(
21136
height: 32.0,
22137
left: 0.0,
@@ -28,19 +143,24 @@ class Demo1 extends StatelessWidget {
28143
duration: const Duration(milliseconds: 350),
29144
child: connected
30145
? const Text('ONLINE')
31-
: const Row(
146+
: Row(
32147
mainAxisAlignment: MainAxisAlignment.center,
33148
children: <Widget>[
34-
Text('OFFLINE'),
35-
SizedBox(width: 8.0),
36-
SizedBox(
37-
width: 12.0,
38-
height: 12.0,
39-
child: CircularProgressIndicator(
40-
strokeWidth: 2.0,
41-
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
149+
const Text('OFFLINE'),
150+
const SizedBox(width: 8.0),
151+
if (_retryController.isRetrying) ...[
152+
const SizedBox(width: 8.0),
153+
const Text('RETRYING...'),
154+
] else ...[
155+
const SizedBox(
156+
width: 12.0,
157+
height: 12.0,
158+
child: CircularProgressIndicator(
159+
strokeWidth: 2.0,
160+
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
161+
),
42162
),
43-
),
163+
],
44164
],
45165
),
46166
),
@@ -49,17 +169,6 @@ class Demo1 extends StatelessWidget {
49169
],
50170
);
51171
},
52-
child: const Column(
53-
mainAxisAlignment: MainAxisAlignment.center,
54-
children: <Widget>[
55-
Text(
56-
'There are no bottons to push :)',
57-
),
58-
Text(
59-
'Just turn off your internet.',
60-
),
61-
],
62-
),
63172
);
64173
}
65174
}

0 commit comments

Comments
 (0)