1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
|
import 'package:dio/dio.dart';
// ---------------------------------------------------------------------------
// Shared Dio error-mapping utilities
// ---------------------------------------------------------------------------
//
// These top-level functions centralise the conversion of [DioException]
// values into human-readable UI strings, eliminating duplicate
// _dioErrorMessage implementations that previously existed in
// bootstrap_screen.dart, login_screen.dart, and home_screen.dart (DRY/DIP).
//
// All functions are pure data-transformations: no widget state, no Riverpod
// reads, no BuildContext — making them easy to unit-test in isolation.
/// Maps an exception thrown by any API call to a human-readable UI string.
///
/// Prefers messages extracted from the [DioException] response body; falls
/// back to status-code–specific text; finally uses a generic connectivity
/// message. Pass [statusFallbacks] to supply caller-specific status-code
/// messages (e.g. 401 → "Invalid username or password." for login).
String dioErrorMessage(
DioException e, {
Map<int, String> statusFallbacks = const {},
}) {
// Prefer a human-readable message from the server's JSON response body.
final body = e.response?.data;
if (body is Map<String, dynamic>) {
final msg = body['message'] as String? ?? body['error'] as String?;
if (msg != null && msg.isNotEmpty) return msg;
}
// Apply caller-specific status-code fallbacks (e.g. auth screens).
final statusCode = e.response?.statusCode;
if (statusCode != null) {
final fallback = statusFallbacks[statusCode];
if (fallback != null) return fallback;
}
// Generic status-code fallback.
if (statusCode != null) {
return 'Server error ($statusCode). Please try again.';
}
// No HTTP response: connectivity or DNS failure.
return 'Could not reach the server. Check your network connection.';
}
/// Maps a [DioException] using connection-type heuristics instead of status
/// codes — suited for read-only data-fetching calls (e.g. listing sets)
/// where there is no login-specific 401/403 semantics.
///
/// Distinguishes between connectivity/timeout failures and server-side HTTP
/// errors so the user knows whether to check their network or contact support.
String dioConnectionErrorMessage(DioException e) {
switch (e.type) {
case DioExceptionType.connectionError:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
case DioExceptionType.connectionTimeout:
return 'Could not reach the server. Check your connection and try again.';
case DioExceptionType.badResponse:
final code = e.response?.statusCode ?? 0;
if (code == 401) return 'Session expired. Please log in again.';
return 'Server error ($code). Please try again.';
default:
return 'Unexpected error. Please try again.';
}
}
/// Maps any thrown object from [PlayerApiClient.listSets] to a UI string.
///
/// Delegates to [dioConnectionErrorMessage] for [DioException]; returns a
/// generic fallback for all other exception types.
String setsErrorMessage(Object error) {
if (error is DioException) {
return dioConnectionErrorMessage(error);
}
return 'Unexpected error. Please try again.';
}
/// Maps any thrown object from [PlayerApiClient.listMedia] to a UI string.
///
/// Identical delegation strategy to [setsErrorMessage]: DioExceptions are
/// mapped by [dioConnectionErrorMessage]; all other exceptions fall back to a
/// generic message. Having a separate function preserves the option to add
/// media-specific status-code overrides (e.g. 403 permission errors) later
/// without altering the sets helper (Open-Closed Principle).
String mediaErrorMessage(Object error) {
if (error is DioException) {
return dioConnectionErrorMessage(error);
}
return 'Unexpected error. Please try again.';
}
/// Maps any thrown object from [PlayerApiClient.getMedia] to a UI string.
///
/// Adds a 404-specific message ("Media not found") on top of the generic
/// connection-error mapping so the detail screen can distinguish between a
/// missing item and a network/server failure (Open-Closed: isolated from the
/// list-media helper so either can evolve independently).
String mediaDetailErrorMessage(Object error) {
if (error is DioException) {
// Surface a friendly "not found" message for 404 so users know the item
// no longer exists rather than seeing a generic server-error message.
if (error.response?.statusCode == 404) {
return 'Media not found. It may have been deleted.';
}
return dioConnectionErrorMessage(error);
}
return 'Unexpected error. Please try again.';
}
/// Maps any thrown object from [PlayerApiClient.createShare] to a UI string.
///
/// Adds a 404-specific message (media not found) and a 403 message
/// (permission denied) on top of the generic connection-error fallback, so
/// the share dialog can surface actionable guidance rather than a raw code.
/// Kept as a separate function (Open-Closed) so it can evolve independently
/// of the other mappers.
String createShareErrorMessage(Object error) {
if (error is DioException) {
if (error.response?.statusCode == 404) {
return 'Media not found. It may have been deleted.';
}
if (error.response?.statusCode == 403) {
return 'You do not have permission to share this item.';
}
return dioConnectionErrorMessage(error);
}
return 'Unexpected error. Please try again.';
}
/// Maps any thrown object from [PlayerApiClient.listSets] (used by
/// [PodcastListScreen]) to a UI string.
///
/// Identical delegation strategy to [setsErrorMessage]: DioExceptions are
/// mapped by [dioConnectionErrorMessage]; all other exceptions fall back to a
/// generic message. Having a separate function preserves the option to add
/// podcast-specific status-code overrides later without altering the sets
/// helper (Open-Closed Principle).
String podcastListErrorMessage(Object error) {
if (error is DioException) {
return dioConnectionErrorMessage(error);
}
return 'Unexpected error. Please try again.';
}
/// Maps any thrown object from [PlayerApiClient.listInProgress] to a UI string.
///
/// Delegates to [dioConnectionErrorMessage] for [DioException]; returns a
/// generic fallback for all other exception types. Kept as a separate function
/// (Open-Closed) so it can evolve independently — for example, adding a 401
/// message if session refresh is needed in a future iteration.
String continueWatchingErrorMessage(Object error) {
if (error is DioException) {
return dioConnectionErrorMessage(error);
}
return 'Unexpected error. Please try again.';
}
/// Maps any thrown object from [PlayerApiClient.addTag] or
/// [PlayerApiClient.removeTag] to a UI string.
///
/// Adds human-readable messages for the common failure modes:
/// - 400: the tag name is invalid (empty, too long, etc.).
/// - 404: the media item no longer exists.
///
/// Kept as a separate top-level function (Open-Closed, DRY) so it can evolve
/// independently of the other mappers without touching unrelated screens.
String tagErrorMessage(Object error) {
if (error is DioException) {
if (error.response?.statusCode == 404) {
return 'Media not found. It may have been deleted.';
}
if (error.response?.statusCode == 400) {
return 'Invalid tag name. Please try a different tag.';
}
return dioConnectionErrorMessage(error);
}
return 'Unexpected error. Please try again.';
}
/// Maps any thrown object from [PlayerApiClient.subscribePodcast] to a UI string.
///
/// Adds human-readable messages for the common failure modes:
/// - 400: the feed URL is malformed or the server could not parse the feed.
/// - 403: the user is not an admin (subscribe requires admin privileges).
/// - 409/500: generic server-side failure (duplicate subscription, etc.).
///
/// Kept as a separate top-level function (Open-Closed, DRY) so it can evolve
/// independently of the share and sets mappers.
String podcastErrorMessage(Object error) {
if (error is DioException) {
if (error.response?.statusCode == 400) {
return 'Invalid feed URL or the feed could not be parsed. Check the URL and try again.';
}
if (error.response?.statusCode == 403) {
return 'Only administrators can subscribe to podcast feeds.';
}
return dioConnectionErrorMessage(error);
}
return 'Unexpected error. Please try again.';
}
/// Maps any thrown object from [PlayerApiClient.getNote], [upsertNote], or
/// [deleteNote] to a human-readable UI string.
///
/// Adds a 404-specific message (media not found) so the notes editor can
/// surface actionable guidance rather than a raw server-error code. Kept as
/// a separate top-level function (Open-Closed, DRY) so it can evolve
/// independently of the other mappers.
String notesErrorMessage(Object error) {
if (error is DioException) {
if (error.response?.statusCode == 404) {
return 'Media not found. It may have been deleted.';
}
return dioConnectionErrorMessage(error);
}
return 'Unexpected error. Please try again.';
}
/// Maps any thrown object from [PlayerApiClient.listMyShares] or
/// [PlayerApiClient.revokeShare] to a human-readable UI string.
///
/// Adds a 404-specific message (share no longer exists) and a 403 message
/// (permission denied) so MySharesScreen can surface actionable guidance.
/// Kept as a separate top-level function (Open-Closed, DRY) so it can evolve
/// independently of the other mappers.
String sharesErrorMessage(Object error) {
if (error is DioException) {
if (error.response?.statusCode == 404) {
return 'Share not found. It may have already been revoked.';
}
if (error.response?.statusCode == 403) {
return 'You do not have permission to manage this share.';
}
return dioConnectionErrorMessage(error);
}
return 'Unexpected error. Please try again.';
}
/// Maps any thrown object from [PlayerApiClient.browseSet] to a UI string.
///
/// Adds a 403-specific message (permission denied) and a 404 message (set not
/// found) on top of the generic connection-error fallback, so
/// FolderBrowserScreen can surface actionable guidance rather than a raw code.
/// Kept as a separate top-level function (Open-Closed, DRY) so it can evolve
/// independently of the other mappers.
String folderErrorMessage(Object error) {
if (error is DioException) {
if (error.response?.statusCode == 404) {
return 'Folder not found. It may have been removed.';
}
if (error.response?.statusCode == 403) {
return 'You do not have permission to browse this folder.';
}
return dioConnectionErrorMessage(error);
}
return 'Unexpected error. Please try again.';
}
/// Maps any thrown object from [PlayerApiClient.listEpisodes] to a UI string.
///
/// Adds a 404-specific message (podcast set not found) on top of the generic
/// connection-error fallback so [PodcastEpisodesScreen] can surface actionable
/// guidance. Kept as a separate top-level function (Open-Closed, DRY) so it
/// can evolve independently of the other mappers.
String episodeListErrorMessage(Object error) {
if (error is DioException) {
if (error.response?.statusCode == 404) {
return 'Podcast not found. It may have been removed.';
}
return dioConnectionErrorMessage(error);
}
return 'Unexpected error. Please try again.';
}
/// Maps any thrown object from [PlayerApiClient.toggleEpisodeComplete] to a
/// UI string.
///
/// The toggle is a best-effort action: 404 means the episode no longer exists,
/// 403 means the user lacks permission. All other errors fall back to a
/// generic connectivity message. Kept as a separate top-level function
/// (Open-Closed, DRY) so it can evolve independently.
String episodeToggleErrorMessage(Object error) {
if (error is DioException) {
if (error.response?.statusCode == 404) {
return 'Episode not found. It may have been removed.';
}
if (error.response?.statusCode == 403) {
return 'You do not have permission to update this episode.';
}
return dioConnectionErrorMessage(error);
}
return 'Could not update episode. Please try again.';
}
|