• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

wger-project / flutter / 30901196754

04 Aug 2026 10:34AM UTC coverage: 51.289% (-1.8%) from 53.103%
30901196754

Pull #1301

github

web-flow
Merge 5c77a99bf into e3affdd4f
Pull Request #1301: feat : implemented dynamic colour support

98 of 138 new or added lines in 24 files covered. (71.01%)

1088 existing lines in 87 files now uncovered.

11659 of 22732 relevant lines covered (51.29%)

5.17 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

31.44
/lib/core/error_dialogs.dart
1
/*
2
 * This file is part of wger Workout Manager <https://github.com/wger-project>.
3
 * Copyright (c) 2020 - 2026 wger Team
4
 *
5
 * wger Workout Manager is free software: you can redistribute it and/or modify
6
 * it under the terms of the GNU Affero General Public License as published by
7
 * the Free Software Foundation, either version 3 of the License, or
8
 * (at your option) any later version.
9
 *
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13
 * GNU Affero General Public License for more details.
14
 *
15
 * You should have received a copy of the GNU Affero General Public License
16
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
17
 */
18

19
import 'package:flutter/foundation.dart';
20
import 'package:flutter/material.dart';
21
import 'package:flutter/services.dart';
22
import 'package:flutter_html/flutter_html.dart';
23
import 'package:json_annotation/json_annotation.dart';
24
import 'package:logging/logging.dart';
25
import 'package:url_launcher/url_launcher.dart';
26
import 'package:wger/core/errors.dart';
27
import 'package:wger/core/exceptions/http_exception.dart';
28
import 'package:wger/core/keys.dart';
29
import 'package:wger/core/logs.dart';
30
import 'package:wger/l10n/generated/app_localizations.dart';
31
import 'package:wger/powersync/sync_diagnostics.dart' show collectSyncDiagnostics;
32

33
/// Whether an error dialog is currently on screen.
34
///
35
/// Errors can fire in quick succession; this guards against stacking several
36
/// modal dialogs on top of each other.
37
bool _errorDialogVisible = false;
38

39
void showHttpExceptionErrorDialog(WgerHttpException exception, {BuildContext? context}) {
×
40
  final logger = Logger('showHttpExceptionErrorDialog');
×
41

42
  // Attempt to get the BuildContext from our global navigatorKey.
43
  // This allows us to show a dialog even if the error occurs outside
44
  // of a widget's build method.
45
  final BuildContext? dialogContext = context ?? navigatorKey.currentContext;
×
46

47
  if (dialogContext == null) {
48
    if (kDebugMode) {
49
      logger.warning('Error: Could not error show http error dialog because the context is null.');
×
50
    }
51
    return;
52
  }
53

54
  if (_errorDialogVisible) {
55
    logger.info('Suppressing error dialog, one is already visible: $exception');
×
56
    return;
57
  }
58
  _errorDialogVisible = true;
59

60
  showDialog(
×
61
    context: dialogContext,
62
    builder: (ctx) => AlertDialog(
×
63
      title: Text(AppLocalizations.of(ctx).anErrorOccurred),
×
64
      content: SingleChildScrollView(
×
65
        child: Column(
×
66
          crossAxisAlignment: CrossAxisAlignment.start,
67
          mainAxisSize: MainAxisSize.min,
68
          children: [
×
69
            if (exception.type == ErrorType.html)
×
70
              ServerHtmlError(data: exception.htmlError)
×
71
            else
72
              ...formatApiErrors(extractErrors(exception.errors)),
×
73
          ],
74
        ),
75
      ),
76
      actions: [
×
77
        TextButton(
×
78
          child: Text(MaterialLocalizations.of(ctx).closeButtonLabel),
×
79
          onPressed: () {
×
80
            Navigator.of(ctx).pop();
×
81
          },
82
        ),
83
      ],
84
    ),
85
  ).whenComplete(() => _errorDialogVisible = false);
×
86
}
87

88
/// Flattens a [WgerHttpException]'s context and error map into a readable
89
/// multi-line string for the diagnostic dialog and bug report.
90
String _formatHttpExceptionDetail(WgerHttpException error) {
×
91
  String lines(Map<String, dynamic> m) => m.entries.map((e) => '${e.key}: ${e.value}').join('\n');
×
92
  final ctx = error.context == null || error.context!.isEmpty ? '' : '${lines(error.context!)}\n\n';
×
93
  return '$ctx${lines(error.errors)}';
×
94
}
95

96
void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext? context}) {
1✔
97
  // Attempt to get the BuildContext from our global navigatorKey.
98
  // This allows us to show a dialog even if the error occurs outside
99
  // of a widget's build method.
100
  final BuildContext? dialogContext = context ?? navigatorKey.currentContext;
2✔
101

102
  final logger = Logger('showGeneralErrorDialog');
1✔
103

104
  if (dialogContext == null) {
105
    if (kDebugMode) {
106
      logger.warning('Error: Could not error show dialog because the context is null.');
1✔
107
    }
108
    return;
109
  }
110

111
  if (_errorDialogVisible) {
112
    logger.info('Suppressing error dialog, one is already visible: $error');
×
113
    return;
114
  }
115
  _errorDialogVisible = true;
116

117
  final i18n = AppLocalizations.of(dialogContext);
×
118

119
  // If possible, determine the issue title and message based on the error type.
120
  // (Note that issue titles and error messages are not localized)
121
  String issueTitle = 'An error occurred';
122
  String issueErrorMessage = error.toString();
×
123

124
  if (error is FlutterErrorDetails) {
×
125
    issueTitle = 'Application Error';
126
    issueErrorMessage = error.exceptionAsString();
×
127
  } else if (error is MissingRequiredKeysException) {
×
128
    issueTitle = 'Missing Required Key';
129
  } else if (error is WgerHttpException) {
×
130
    final status = error.statusCode == null ? '' : ' (HTTP ${error.statusCode})';
×
131
    issueTitle = switch (error.source) {
×
132
      ExceptionSource.powersync => 'Sync upload rejected$status',
×
133
      ExceptionSource.flutter => 'Application error$status',
×
134
      ExceptionSource.api => 'Server error$status',
×
135
    };
136
    issueErrorMessage = _formatHttpExceptionDetail(error);
×
137
  }
138

139
  final String fullStackTrace = stackTrace?.toString() ?? 'No stack trace available.';
×
140
  final applicationLogs = InMemoryLogStore().getFormattedLogs();
×
141

142
  showDialog(
×
143
    context: dialogContext,
144
    barrierDismissible: false,
145
    builder: (BuildContext context) {
×
146
      return AlertDialog(
×
147
        title: Row(
×
148
          spacing: 8,
149
          mainAxisAlignment: MainAxisAlignment.center,
150
          children: [
×
151
            Icon(Icons.error, color: Theme.of(context).colorScheme.error),
×
152
            Expanded(
×
153
              child: Text(
×
154
                i18n.anErrorOccurred,
×
155
                style: TextStyle(color: Theme.of(context).colorScheme.error),
×
156
              ),
157
            ),
158
          ],
159
        ),
160
        content: SingleChildScrollView(
×
161
          child: ListBody(
×
162
            children: [
×
163
              Text(i18n.errorInfoDescription),
×
164
              const SizedBox(height: 8),
165
              Text(i18n.errorInfoDescription2),
×
166
              const SizedBox(height: 10),
167
              ExpansionTile(
×
168
                tilePadding: EdgeInsets.zero,
169
                title: Text(i18n.errorViewDetails),
×
170
                children: [
×
171
                  Text(
×
172
                    issueErrorMessage,
173
                    style: const TextStyle(fontWeight: FontWeight.bold),
174
                  ),
175
                  Container(
×
176
                    alignment: Alignment.topLeft,
177
                    padding: const EdgeInsets.symmetric(vertical: 8.0),
178
                    constraints: const BoxConstraints(maxHeight: 250),
179
                    child: SingleChildScrollView(
×
180
                      child: Text(
×
181
                        fullStackTrace,
NEW
182
                        style: TextStyle(
×
183
                          fontSize: 12.0,
NEW
184
                          color: Theme.of(context).colorScheme.onSurfaceVariant,
×
185
                        ),
186
                      ),
187
                    ),
188
                  ),
189
                  CopyToClipboardButton(
×
190
                    text:
×
191
                        'Error Title: $issueTitle\n'
192
                        'Error Message: $issueErrorMessage\n\n'
193
                        'Stack Trace:\n$fullStackTrace',
194
                  ),
195
                  const SizedBox(height: 8),
196
                  Text(i18n.applicationLogs, style: const TextStyle(fontWeight: FontWeight.bold)),
×
197
                  Container(
×
198
                    alignment: Alignment.topLeft,
199
                    padding: const EdgeInsets.symmetric(vertical: 8.0),
200
                    constraints: const BoxConstraints(maxHeight: 250),
201
                    child: SingleChildScrollView(
×
202
                      child: Column(
×
203
                        children: [
×
204
                          ...applicationLogs.map(
×
205
                            (entry) => Text(
×
206
                              entry,
NEW
207
                              style: TextStyle(
×
208
                                fontSize: 12.0,
NEW
209
                                color: Theme.of(context).colorScheme.onSurfaceVariant,
×
210
                              ),
211
                            ),
212
                          ),
213
                        ],
214
                      ),
215
                    ),
216
                  ),
217
                  CopyToClipboardButton(text: applicationLogs.join('\n')),
×
218
                ],
219
              ),
220
            ],
221
          ),
222
        ),
223
        actions: [
×
224
          TextButton(
×
225
            child: const Text('Report issue'),
226
            onPressed: () async {
×
227
              final githubIssueUrl = buildGithubIssueUrl(
×
228
                issueTitle: issueTitle,
229
                issueErrorMessage: issueErrorMessage,
230
                stackTrace: fullStackTrace,
231
                applicationLogs: applicationLogs,
232
                syncDiagnostics: await collectSyncDiagnostics(),
×
233
              );
234
              final Uri reportUri = Uri.parse(githubIssueUrl);
×
235

236
              try {
237
                await launchUrl(reportUri, mode: LaunchMode.externalApplication);
×
238
              } catch (e) {
239
                if (kDebugMode) {
240
                  logger.warning('Error launching URL: $e');
×
241
                }
242
                if (context.mounted) {
×
243
                  ScaffoldMessenger.of(
×
244
                    context,
245
                  ).showSnackBar(SnackBar(content: Text('Error opening issue tracker: $e')));
×
246
                }
247
              }
248
            },
249
          ),
250
          FilledButton(
×
251
            child: Text(MaterialLocalizations.of(context).okButtonLabel),
×
252
            onPressed: () {
×
253
              Navigator.of(context).pop();
×
254
            },
255
          ),
256
        ],
257
      );
258
    },
259
  ).whenComplete(() => _errorDialogVisible = false);
×
260
}
261

262
/// Routes [error] to the appropriate UI based on its [ErrorSeverity].
263
///
264
/// The caller is responsible for logging the error beforehand.
265
void handleError(Object? error, StackTrace? stackTrace) {
1✔
266
  switch (classifyError(error)) {
1✔
267
    case ErrorSeverity.cosmetic:
1✔
268
      break;
269
    case ErrorSeverity.transient:
1✔
270
      showTransientErrorSnackbar();
×
271
    case ErrorSeverity.fatal:
1✔
272
      // API errors are the form-validation fallback (clean field errors);
273
      // other sources are diagnostic and get the dialog with logs and report.
274
      if (error is WgerHttpException && error.source == ExceptionSource.api) {
3✔
275
        showHttpExceptionErrorDialog(error);
×
276
      } else {
277
        showGeneralErrorDialog(error, stackTrace);
1✔
278
      }
279
  }
280
}
281

282
/// Shows a brief, non-blocking snackbar telling the user about a (hopefully)
283
/// transient error such as network problems, etc.
284
void showTransientErrorSnackbar() {
×
285
  final messenger = scaffoldMessengerKey.currentState;
×
286
  final context = navigatorKey.currentContext;
×
287

288
  if (messenger == null || context == null) {
289
    if (kDebugMode) {
290
      Logger(
×
291
        'showNetworkErrorSnackbar',
292
      ).warning('Could not show snackbar: no messenger or context available.');
×
293
    }
294
    return;
295
  }
296

297
  messenger
298
    ..clearSnackBars()
×
299
    ..showSnackBar(
×
300
      SnackBar(content: Text(AppLocalizations.of(context).errorCouldNotConnectToServer)),
×
301
    );
302
}
303

304
/// Shows a brief, non-blocking snackbar telling the user their session is no
305
/// longer valid and they need to log in again.
306
void showSessionExpiredSnackbar() {
1✔
307
  final messenger = scaffoldMessengerKey.currentState;
2✔
308
  final context = navigatorKey.currentContext;
2✔
309

310
  if (messenger == null || context == null) {
311
    return;
312
  }
313

314
  messenger
315
    ..clearSnackBars()
×
316
    ..showSnackBar(
×
317
      SnackBar(content: Text(AppLocalizations.of(context).sessionExpired)),
×
318
    );
319
}
320

321
/// A widget to render HTML errors returned by the server
322
///
323
/// This is a simple wrapper around the `Html` Widget, with some light changes
324
/// to the style.
325
class ServerHtmlError extends StatelessWidget {
326
  final logger = Logger('ServerHtml');
327
  final String data;
328

329
  ServerHtmlError({required this.data, super.key});
2✔
330

331
  @override
2✔
332
  Widget build(BuildContext context) {
333
    final theme = Theme.of(context);
2✔
334

335
    return Html(
2✔
336
      data: data,
2✔
337
      style: {
2✔
338
        'h1': Style(fontSize: FontSize(theme.textTheme.bodyLarge?.fontSize ?? 15)),
10✔
339
        'h2': Style(fontSize: FontSize(theme.textTheme.bodyMedium?.fontSize ?? 15)),
10✔
340
      },
341
      doNotRenderTheseTags: const {'a'},
342
    );
343
  }
344
}
345

346
class CopyToClipboardButton extends StatelessWidget {
347
  final logger = Logger('CopyToClipboardButton');
348
  final String text;
349

350
  CopyToClipboardButton({required this.text, super.key});
×
351

352
  @override
×
353
  Widget build(BuildContext context) {
354
    final i18n = AppLocalizations.of(context);
×
355

356
    return TextButton.icon(
×
357
      icon: const Icon(Icons.copy_all_outlined, size: 18),
358
      label: Text(i18n.copyToClipboard),
×
359
      style: TextButton.styleFrom(
×
360
        padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
361
        tapTargetSize: MaterialTapTargetSize.shrinkWrap,
362
      ),
363
      onPressed: () {
×
364
        Clipboard.setData(ClipboardData(text: text))
×
365
            .then((_) {
×
366
              if (context.mounted) {
×
367
                ScaffoldMessenger.of(
×
368
                  context,
369
                ).showSnackBar(const SnackBar(content: Text('Details copied to clipboard!')));
×
370
              }
371
            })
372
            .catchError((copyError) {
×
373
              logger.warning('Error copying to clipboard: $copyError');
×
374

375
              if (context.mounted) {
×
376
                ScaffoldMessenger.of(
×
377
                  context,
378
                ).showSnackBar(const SnackBar(content: Text('Could not copy details.')));
×
379
              }
380
            });
381
      },
382
    );
383
  }
384
}
385

386
/// Processes the error messages from the server and returns a list of widgets
387
List<Widget> formatApiErrors(List<ApiError> errors, {Color? color}) {
7✔
388
  final logger = Logger('formatApiErrors');
7✔
389

390
  final List<Widget> errorList = [];
7✔
391

392
  for (final error in errors) {
14✔
393
    errorList.add(
7✔
394
      Text(
7✔
395
        error.key,
7✔
396
        style: TextStyle(fontWeight: FontWeight.bold, color: color),
7✔
397
      ),
398
    );
399

400
    logger.warning(error.errorMessages);
14✔
401
    for (final message in error.errorMessages) {
14✔
402
      errorList.add(Text(message, style: TextStyle(color: color)));
21✔
403
    }
404
    errorList.add(const SizedBox(height: 8));
7✔
405
  }
406

407
  return errorList;
408
}
409

410
/// Processes the error messages from the server and returns a list of widgets
411
List<Widget> formatTextErrors(List<String> errors, {String? title, Color? color}) {
×
412
  final List<Widget> errorList = [];
×
413

414
  // A null color inherits the surrounding text style (readable in dark mode).
415
  if (title != null) {
416
    errorList.add(
×
417
      Text(
×
418
        title,
419
        style: TextStyle(fontWeight: FontWeight.bold, color: color),
×
420
      ),
421
    );
422
  }
423

424
  for (final message in errors) {
×
425
    errorList.add(Text(message, style: TextStyle(color: color)));
×
426
  }
427
  errorList.add(const SizedBox(height: 8));
×
428

429
  return errorList;
430
}
431

432
class FormHttpErrorsWidget extends StatelessWidget {
433
  final WgerHttpException exception;
434

435
  const FormHttpErrorsWidget(this.exception, {super.key});
6✔
436

437
  @override
6✔
438
  Widget build(BuildContext context) {
439
    final theme = Theme.of(context);
6✔
440

441
    // A JSON endpoint answering with HTML often means a proxy or bot wall such
442
    // as Anubis intercepted the request. Such pages render near-blank through
443
    // flutter_html since their visible content is JS-driven. In these cases we
444
    // try pull out the <title>, which usually names the culprit
445
    final htmlTitle = exception.type == ErrorType.html ? htmlErrorTitle(exception.htmlError) : null;
18✔
446

447
    return Container(
6✔
448
      constraints: const BoxConstraints(maxHeight: 250),
449
      decoration: BoxDecoration(
6✔
450
        border: Border.all(color: theme.colorScheme.error, width: 1),
18✔
451
        borderRadius: BorderRadius.circular(6),
6✔
452
      ),
453
      padding: const EdgeInsets.all(10),
454
      child: SingleChildScrollView(
6✔
455
        child: Column(
6✔
456
          children: [
6✔
457
            Icon(Icons.error_outline, color: theme.colorScheme.error),
18✔
458
            if (exception.type == ErrorType.html) ...[
18✔
459
              if (htmlTitle != null)
460
                Text(
×
461
                  htmlTitle,
462
                  textAlign: TextAlign.center,
463
                  style: TextStyle(
×
464
                    fontWeight: FontWeight.bold,
465
                    color: theme.colorScheme.error,
×
466
                  ),
467
                ),
468
              ServerHtmlError(data: exception.htmlError),
×
469
            ] else
470
              ...formatApiErrors(
6✔
471
                extractErrors(exception.errors),
18✔
472
                color: theme.colorScheme.error,
12✔
473
              ),
474
          ],
475
        ),
476
      ),
477
    );
478
  }
479
}
480

481
/// Extracts the `<title>` of an HTML error body (e.g. a proxy or bot-wall page
482
/// such as Cloudflare or Anubis), with the few HTML entities that show up in
483
/// such titles unescaped. Returns null when there is no usable title.
484
String? htmlErrorTitle(String html) {
3✔
485
  final match = RegExp(
3✔
486
    r'<title[^>]*>(.*?)</title>',
487
    caseSensitive: false,
488
    dotAll: true,
489
  ).firstMatch(html);
3✔
490
  final raw = match?.group(1)?.trim();
6✔
491
  if (raw == null || raw.isEmpty) {
3✔
492
    return null;
493
  }
494
  return raw
495
      .replaceAll('&#39;', "'")
2✔
496
      .replaceAll('&#x27;', "'")
2✔
497
      .replaceAll('&quot;', '"')
2✔
498
      .replaceAll('&lt;', '<')
2✔
499
      .replaceAll('&gt;', '>')
2✔
500
      .replaceAll('&amp;', '&');
2✔
501
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc