summaryrefslogtreecommitdiff
path: root/player-android/lib/utils/error_mappers.dart
blob: 6e6f1181c02d03b41090c65240564b8636db6e33 (plain)
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
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
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.getSharedMediaPage] to a UI string.
///
/// Adds human-readable messages for the status codes the share-viewer endpoint
/// can return:
///   - 404: the share token does not exist (never created, or already deleted).
///   - 410: the share has expired (server-side expiry or max-uses exceeded).
///
/// These two cases are shown with distinct messages so the viewer knows whether
/// the link was invalid from the start or whether it was valid but has since
/// expired.  All other failures fall back to [dioConnectionErrorMessage].
///
/// Kept as a separate top-level function (Open-Closed, DRY) so it can evolve
/// independently of other error mappers.
String shareViewerErrorMessage(Object error) {
  if (error is DioException) {
    if (error.response?.statusCode == 404) {
      return 'This share link is invalid or has been revoked.';
    }
    if (error.response?.statusCode == 410) {
      return 'This share link has expired.';
    }
    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.listUsers],
/// [PlayerApiClient.createUser], or [PlayerApiClient.deleteUser] to a UI string.
///
/// Adds human-readable messages for the common failure modes:
///   - 400: the request body is invalid (e.g. password too short, empty fields).
///     Delegates to [dioErrorMessage] which already prefers the server's JSON
///     body message, avoiding duplicated body-parsing logic.
///   - 403: the caller is not an admin.
///   - 409: a user with the same username already exists.
///
/// Kept as a separate top-level function (Open-Closed, DRY) so it can evolve
/// independently of the other mappers.
String adminUserErrorMessage(Object error) {
  if (error is DioException) {
    if (error.response?.statusCode == 403) {
      return 'You do not have permission to manage users.';
    }
    if (error.response?.statusCode == 409) {
      return 'A user with that username already exists.';
    }
    if (error.response?.statusCode == 400) {
      // Delegate to dioErrorMessage which already prefers the server's JSON
      // body message (e.g. "password too short") over a generic fallback,
      // eliminating duplicated body-parsing logic.
      final serverMsg = dioErrorMessage(error);
      // dioErrorMessage returns a generic "Server error (400)" string when
      // there is no body message; replace that with a more actionable hint.
      if (!serverMsg.startsWith('Server error')) return serverMsg;
      return 'Invalid request. Check the username and password and try again.';
    }
    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);
  }
  // Action-specific fallback: gives the user more context than a generic
  // "Unexpected error" message when the toggle mutation fails for an unknown
  // reason (e.g. an exception type that is not a DioException).
  return 'Could not update episode. Please try again.';
}

/// Maps any thrown object from admin permission API calls to a UI string.
///
/// Adds human-readable messages for the failure modes specific to granting or
/// revoking set permissions:
///   - 403: the caller is not an admin.
///   - 404: the user or set no longer exists.
///
/// Kept as a separate top-level function (Open-Closed, DRY) so it can evolve
/// independently of the other admin mappers.
String adminPermissionErrorMessage(Object error) {
  if (error is DioException) {
    if (error.response?.statusCode == 403) {
      return 'You do not have permission to manage access.';
    }
    if (error.response?.statusCode == 404) {
      return 'User or set not found. Please refresh and try again.';
    }
    return dioConnectionErrorMessage(error);
  }
  return 'Unexpected error. Please try again.';
}

/// Maps any thrown object from [PlayerApiClient.triggerRescan] or
/// [PlayerApiClient.getScanProgress] to a UI string.
///
/// Adds a 403-specific message (admin-only) on top of the generic
/// connection-error fallback so [AdminRescanScreen] surfaces actionable guidance.
/// Kept as a separate top-level function (Open-Closed, DRY).
String adminRescanErrorMessage(Object error) {
  if (error is DioException) {
    if (error.response?.statusCode == 403) {
      return 'You do not have permission to trigger a rescan.';
    }
    return dioConnectionErrorMessage(error);
  }
  return 'Unexpected error. Please try again.';
}

/// Maps any thrown object from admin trash API calls to a UI string.
///
/// Adds human-readable messages for the failure modes specific to restoring
/// or hard-deleting trashed media items:
///   - 403: the caller is not an admin.
///   - 404: the media item no longer exists in trash.
///
/// Kept as a separate top-level function (Open-Closed, DRY) so it can evolve
/// independently of the other admin mappers.
String adminTrashErrorMessage(Object error) {
  if (error is DioException) {
    if (error.response?.statusCode == 403) {
      return 'You do not have permission to manage the trash.';
    }
    if (error.response?.statusCode == 404) {
      return 'Item not found. It may have already been deleted or restored.';
    }
    return dioConnectionErrorMessage(error);
  }
  return 'Unexpected error. Please try again.';
}

/// Maps any thrown object from [PlayerApiClient.downloadEpisode] to a UI string.
///
/// Adds human-readable messages for the failure modes specific to triggering a
/// server-side episode download:
///   - 404: the episode no longer exists on the server.
///   - 403: the user lacks the required permission.
///   - 409: the episode has already been downloaded (concurrent request).
///
/// All other failures fall back to [dioConnectionErrorMessage].  Kept as a
/// separate top-level function (Open-Closed, DRY) so it can evolve
/// independently of the toggle and list mappers.
String episodeDownloadErrorMessage(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 download this episode.';
    }
    if (error.response?.statusCode == 409) {
      return 'Episode is already downloaded.';
    }
    return dioConnectionErrorMessage(error);
  }
  // Action-specific fallback: gives the user more context than a generic
  // "Unexpected error" message when the download mutation fails for an unknown
  // reason (e.g. an exception type that is not a DioException).
  return 'Could not download episode. Please try again.';
}