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

wger-project / flutter / 30829513672

03 Aug 2026 03:52PM UTC coverage: 56.092% (+3.0%) from 53.103%
30829513672

Pull #1301

github

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

81 of 91 new or added lines in 20 files covered. (89.01%)

2 existing lines in 2 files now uncovered.

12725 of 22686 relevant lines covered (56.09%)

6.9 hits per line

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

31.77
/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(fontSize: 12.0, color: Colors.grey[700]),
×
183
                      ),
184
                    ),
185
                  ),
186
                  CopyToClipboardButton(
×
187
                    text:
×
188
                        'Error Title: $issueTitle\n'
189
                        'Error Message: $issueErrorMessage\n\n'
190
                        'Stack Trace:\n$fullStackTrace',
191
                  ),
192
                  const SizedBox(height: 8),
193
                  Text(i18n.applicationLogs, style: const TextStyle(fontWeight: FontWeight.bold)),
×
194
                  Container(
×
195
                    alignment: Alignment.topLeft,
196
                    padding: const EdgeInsets.symmetric(vertical: 8.0),
197
                    constraints: const BoxConstraints(maxHeight: 250),
198
                    child: SingleChildScrollView(
×
199
                      child: Column(
×
200
                        children: [
×
201
                          ...applicationLogs.map(
×
202
                            (entry) => Text(
×
203
                              entry,
204
                              style: TextStyle(fontSize: 12.0, color: Colors.grey[700]),
×
205
                            ),
206
                          ),
207
                        ],
208
                      ),
209
                    ),
210
                  ),
211
                  CopyToClipboardButton(text: applicationLogs.join('\n')),
×
212
                ],
213
              ),
214
            ],
215
          ),
216
        ),
217
        actions: [
×
218
          TextButton(
×
219
            child: const Text('Report issue'),
220
            onPressed: () async {
×
221
              final githubIssueUrl = buildGithubIssueUrl(
×
222
                issueTitle: issueTitle,
223
                issueErrorMessage: issueErrorMessage,
224
                stackTrace: fullStackTrace,
225
                applicationLogs: applicationLogs,
226
                syncDiagnostics: await collectSyncDiagnostics(),
×
227
              );
228
              final Uri reportUri = Uri.parse(githubIssueUrl);
×
229

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

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

276
/// Shows a brief, non-blocking snackbar telling the user about a (hopefully)
277
/// transient error such as network problems, etc.
278
void showTransientErrorSnackbar() {
×
279
  final messenger = scaffoldMessengerKey.currentState;
×
280
  final context = navigatorKey.currentContext;
×
281

282
  if (messenger == null || context == null) {
283
    if (kDebugMode) {
284
      Logger(
×
285
        'showNetworkErrorSnackbar',
286
      ).warning('Could not show snackbar: no messenger or context available.');
×
287
    }
288
    return;
289
  }
290

291
  messenger
292
    ..clearSnackBars()
×
293
    ..showSnackBar(
×
294
      SnackBar(content: Text(AppLocalizations.of(context).errorCouldNotConnectToServer)),
×
295
    );
296
}
297

298
/// Shows a brief, non-blocking snackbar telling the user their session is no
299
/// longer valid and they need to log in again.
300
void showSessionExpiredSnackbar() {
2✔
301
  final messenger = scaffoldMessengerKey.currentState;
4✔
302
  final context = navigatorKey.currentContext;
4✔
303

304
  if (messenger == null || context == null) {
305
    return;
306
  }
307

308
  messenger
309
    ..clearSnackBars()
×
310
    ..showSnackBar(
×
311
      SnackBar(content: Text(AppLocalizations.of(context).sessionExpired)),
×
312
    );
313
}
314

315
/// A widget to render HTML errors returned by the server
316
///
317
/// This is a simple wrapper around the `Html` Widget, with some light changes
318
/// to the style.
319
class ServerHtmlError extends StatelessWidget {
320
  final logger = Logger('ServerHtml');
321
  final String data;
322

323
  ServerHtmlError({required this.data, super.key});
2✔
324

325
  @override
2✔
326
  Widget build(BuildContext context) {
327
    final theme = Theme.of(context);
2✔
328

329
    return Html(
2✔
330
      data: data,
2✔
331
      style: {
2✔
332
        'h1': Style(fontSize: FontSize(theme.textTheme.bodyLarge?.fontSize ?? 15)),
10✔
333
        'h2': Style(fontSize: FontSize(theme.textTheme.bodyMedium?.fontSize ?? 15)),
10✔
334
      },
335
      doNotRenderTheseTags: const {'a'},
336
    );
337
  }
338
}
339

340
class CopyToClipboardButton extends StatelessWidget {
341
  final logger = Logger('CopyToClipboardButton');
342
  final String text;
343

344
  CopyToClipboardButton({required this.text, super.key});
×
345

346
  @override
×
347
  Widget build(BuildContext context) {
348
    final i18n = AppLocalizations.of(context);
×
349

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

369
              if (context.mounted) {
×
370
                ScaffoldMessenger.of(
×
371
                  context,
372
                ).showSnackBar(const SnackBar(content: Text('Could not copy details.')));
×
373
              }
374
            });
375
      },
376
    );
377
  }
378
}
379

380
/// Processes the error messages from the server and returns a list of widgets
381
List<Widget> formatApiErrors(List<ApiError> errors, {Color? color}) {
9✔
382
  final logger = Logger('formatApiErrors');
9✔
383

384
  final List<Widget> errorList = [];
9✔
385

386
  for (final error in errors) {
18✔
387
    errorList.add(
9✔
388
      Text(
9✔
389
        error.key,
9✔
390
        style: TextStyle(fontWeight: FontWeight.bold, color: color),
9✔
391
      ),
392
    );
393

394
    logger.warning(error.errorMessages);
18✔
395
    for (final message in error.errorMessages) {
18✔
396
      errorList.add(Text(message, style: TextStyle(color: color)));
27✔
397
    }
398
    errorList.add(const SizedBox(height: 8));
9✔
399
  }
400

401
  return errorList;
402
}
403

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

408
  // A null color inherits the surrounding text style (readable in dark mode).
409
  if (title != null) {
410
    errorList.add(
×
411
      Text(
×
412
        title,
413
        style: TextStyle(fontWeight: FontWeight.bold, color: color),
×
414
      ),
415
    );
416
  }
417

418
  for (final message in errors) {
×
419
    errorList.add(Text(message, style: TextStyle(color: color)));
×
420
  }
421
  errorList.add(const SizedBox(height: 8));
×
422

423
  return errorList;
424
}
425

426
class FormHttpErrorsWidget extends StatelessWidget {
427
  final WgerHttpException exception;
428

429
  const FormHttpErrorsWidget(this.exception, {super.key});
7✔
430

431
  @override
7✔
432
  Widget build(BuildContext context) {
433
    final theme = Theme.of(context);
7✔
434

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

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

475
/// Extracts the `<title>` of an HTML error body (e.g. a proxy or bot-wall page
476
/// such as Cloudflare or Anubis), with the few HTML entities that show up in
477
/// such titles unescaped. Returns null when there is no usable title.
478
String? htmlErrorTitle(String html) {
3✔
479
  final match = RegExp(
3✔
480
    r'<title[^>]*>(.*?)</title>',
481
    caseSensitive: false,
482
    dotAll: true,
483
  ).firstMatch(html);
3✔
484
  final raw = match?.group(1)?.trim();
6✔
485
  if (raw == null || raw.isEmpty) {
3✔
486
    return null;
487
  }
488
  return raw
489
      .replaceAll('&#39;', "'")
2✔
490
      .replaceAll('&#x27;', "'")
2✔
491
      .replaceAll('&quot;', '"')
2✔
492
      .replaceAll('&lt;', '<')
2✔
493
      .replaceAll('&gt;', '>')
2✔
494
      .replaceAll('&amp;', '&');
2✔
495
}
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