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

wger-project / flutter / 30854254088

03 Aug 2026 09:21PM UTC coverage: 40.895% (-15.0%) from 55.93%
30854254088

Pull #1260

github

web-flow
Merge d93794de5 into e3affdd4f
Pull Request #1260: WIP: health sync

9980 of 24404 relevant lines covered (40.89%)

1.72 hits per line

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

52.38
/lib/core/widgets/sync_status_dialog.dart
1
/*
2
 * This file is part of wger Workout Manager <https://github.com/wger-project>.
3
 * Copyright (c)  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/material.dart';
20
import 'package:flutter_riverpod/flutter_riverpod.dart';
21
import 'package:logging/logging.dart';
22
import 'package:powersync/powersync.dart' show SyncStatus;
23
import 'package:url_launcher/url_launcher.dart';
24
import 'package:wger/core/errors.dart' show buildGithubIssueUrl;
25
import 'package:wger/core/formatting/formatting.dart';
26
import 'package:wger/core/logs.dart';
27
import 'package:wger/core/widgets/log_overview.dart' show LogOverviewPage;
28
import 'package:wger/database/powersync/powersync.dart'
29
    show pendingUploadCountProvider, syncStatus, syncWatchdogProvider;
30
import 'package:wger/l10n/generated/app_localizations.dart';
31
import 'package:wger/powersync/sync_diagnostics.dart';
32

33
final _logger = Logger('SyncStatusDialog');
×
34

35
({IconData icon, String label}) syncStatusIconAndLabel(
1✔
36
  SyncStatus status,
37
  AppLocalizations i18n,
38
) {
39
  if (status.anyError != null) {
1✔
40
    return (
41
      icon: status.connected ? Icons.sync_problem : Icons.cloud_off,
1✔
42
      label: i18n.syncStatusError,
1✔
43
    );
44
  } else if (status.connecting) {
×
45
    // Distinct from the active-sync icon below: "queue" reads as
46
    // "trying to establish a connection", not "transferring data".
47
    return (icon: Icons.cloud_queue, label: i18n.syncStatusConnecting);
×
48
  } else if (!status.connected) {
×
49
    return (icon: Icons.cloud_off, label: i18n.syncStatusDisconnected);
×
50
  } else if (status.uploading && status.downloading) {
×
51
    // The status changes often between downloading, uploading and both,
52
    // so we use the same icon for all three
53
    return (icon: Icons.cloud_sync_outlined, label: i18n.syncStatusSyncing);
×
54
  } else if (status.uploading) {
×
55
    return (icon: Icons.cloud_upload_outlined, label: i18n.syncStatusUploading);
×
56
  } else if (status.downloading) {
×
57
    return (icon: Icons.cloud_download_outlined, label: i18n.syncStatusDownloading);
×
58
  } else {
59
    return (icon: Icons.cloud_done_outlined, label: i18n.syncStatusConnected);
×
60
  }
61
}
62

63
/// Shows the current powersync status. Watches the status, the upload queue
64
/// and the watchdog, so the content keeps updating while the dialog is open.
65
class SyncStatusDialog extends ConsumerWidget {
66
  /// Drops the current sync connection and opens a fresh one. When null,
67
  /// the reconnect action is not shown.
68
  final VoidCallback? onReconnect;
69

70
  /// Server this app is syncing against. Hidden when null.
71
  final String? serverUrl;
72

73
  const SyncStatusDialog({this.onReconnect, this.serverUrl, super.key});
1✔
74

75
  @override
1✔
76
  Widget build(BuildContext context, WidgetRef ref) {
77
    final i18n = AppLocalizations.of(context);
1✔
78
    final theme = Theme.of(context);
1✔
79
    final syncState = ref.watch(syncStatus);
2✔
80
    // The queue count loads async for a moment; treat that as an empty queue
81
    final pendingUploads = ref.watch(pendingUploadCountProvider).value ?? 0;
3✔
82
    final status = syncStatusIconAndLabel(syncState, i18n);
1✔
83
    final lastSynced = syncState.lastSyncedAt;
1✔
84
    final errorCategory = syncState.anyError == null
1✔
85
        ? null
86
        : categoriseSyncError(syncState.anyError!);
2✔
87

88
    // stalled: the sync stream keeps reconnecting without ever receiving
89
    // data (see SyncStreamWatchdog). There is no error to show in that
90
    // case, so the dialog adds a hint about likely network-side blockers.
91
    return ValueListenableBuilder<bool>(
1✔
92
      valueListenable: ref.watch(syncWatchdogProvider).stalled,
3✔
93
      builder: (context, stalled, _) => AlertDialog(
2✔
94
        title: Text(i18n.syncStatusDialogTitle),
2✔
95
        content: Column(
1✔
96
          mainAxisSize: MainAxisSize.min,
97
          crossAxisAlignment: CrossAxisAlignment.start,
98
          children: [
1✔
99
            Row(
1✔
100
              crossAxisAlignment: CrossAxisAlignment.center,
101
              children: [
1✔
102
                Icon(status.icon, size: 28),
1✔
103
                const SizedBox(width: 12),
104
                Expanded(
1✔
105
                  child: Column(
1✔
106
                    mainAxisSize: MainAxisSize.min,
107
                    crossAxisAlignment: CrossAxisAlignment.start,
108
                    children: [
1✔
109
                      Text(status.label, style: theme.textTheme.titleMedium),
3✔
110
                      if (errorCategory != null)
111
                        Text(
1✔
112
                          errorCategory,
113
                          style: theme.textTheme.bodySmall?.copyWith(
3✔
114
                            color: theme.colorScheme.onSurfaceVariant,
2✔
115
                          ),
116
                        ),
117
                    ],
118
                  ),
119
                ),
120
              ],
121
            ),
122

123
            // Stalled or errored sync
124
            if (stalled && syncState.anyError == null) ...[
×
125
              const SizedBox(height: 8),
126
              Text(
×
127
                i18n.syncStatusStalledHint,
×
128
                style: theme.textTheme.bodySmall?.copyWith(
×
129
                  color: theme.colorScheme.onSurfaceVariant,
×
130
                ),
131
              ),
132
            ],
133

134
            // Progress through the running download, so a long initial sync
135
            // is visibly moving instead of sitting on a static label.
136
            if (syncState.downloadProgress case final progress?) ...[
1✔
137
              const SizedBox(height: 12),
138
              LinearProgressIndicator(value: progress.downloadedFraction),
×
139
              const SizedBox(height: 4),
140
              Text(
×
141
                '${progress.downloadedOperations} / ${progress.totalOperations}',
×
142
                style: theme.textTheme.bodySmall?.copyWith(
×
143
                  color: theme.colorScheme.onSurfaceVariant,
×
144
                ),
145
              ),
146
            ],
147
            if (pendingUploads > 0) ...[
1✔
148
              const SizedBox(height: 8),
149
              Text(
×
150
                i18n.syncStatusPendingUploads(pendingUploads),
×
151
                style: theme.textTheme.bodySmall?.copyWith(
×
152
                  color: theme.colorScheme.onSurfaceVariant,
×
153
                ),
154
              ),
155
            ],
156
            const SizedBox(height: 16),
1✔
157

158
            // Last sync timestamp if available
159
            Text(i18n.syncStatusLastSynced, style: theme.textTheme.labelMedium),
4✔
160
            const SizedBox(height: 4),
1✔
161
            Text(
1✔
162
              lastSynced != null
163
                  ? localizedDate(context).add_Hms().format(lastSynced.toLocal())
×
164
                  : syncState.hasSynced == false
2✔
165
                  ? i18n.syncStatusNeverSynced
1✔
166
                  : '-/-',
167
            ),
168
            if (serverUrl != null) ...[
1✔
169
              const SizedBox(height: 16),
170
              Text(i18n.serverSectionLabel, style: theme.textTheme.labelMedium),
×
171
              const SizedBox(height: 4),
172
              Text(serverUrl!),
×
173
            ],
174

175
            // Raw error in an expandable section. Only shown when an error
176
            // actually exists; otherwise we don't render the tile at all,
177
            if (syncState.anyError != null) ...[
2✔
178
              const SizedBox(height: 8),
179
              Theme(
1✔
180
                // ExpansionTile draws its own dividers; suppress the default
181
                // divider colour so the tile blends with the dialog content.
182
                data: theme.copyWith(dividerColor: Colors.transparent),
1✔
183
                child: ExpansionTile(
1✔
184
                  tilePadding: EdgeInsets.zero,
185
                  childrenPadding: const EdgeInsets.only(bottom: 8),
186
                  title: Text(i18n.syncStatusErrorDetails),
2✔
187
                  children: [
1✔
188
                    SelectableText(
1✔
189
                      syncState.anyError!.toString(),
2✔
190
                      style: theme.textTheme.bodySmall?.copyWith(
3✔
191
                        fontFamily: 'monospace',
192
                        color: theme.colorScheme.error,
2✔
193
                      ),
194
                    ),
195
                  ],
196
                ),
197
              ),
198
            ],
199
          ],
200
        ),
201
        actions: [
1✔
202
          // The stalled hint and the WARNING land in the application logs;
203
          // give the user a direct path to them.
204
          if (stalled && syncState.anyError == null)
×
205
            TextButton(
×
206
              onPressed: () {
×
207
                final navigator = Navigator.of(context);
×
208
                navigator.pop();
×
209
                navigator.pushNamed(LogOverviewPage.routeName);
×
210
              },
211
              child: Text(i18n.applicationLogs),
×
212
            ),
213
          // Pre-filled bug report including the sync snapshot. Sync problems
214
          // usually never raise the fatal error dialog, so this is their
215
          // report path.
216
          if (stalled || syncState.anyError != null)
1✔
217
            TextButton(
1✔
218
              onPressed: () async {
×
219
                final url = buildGithubIssueUrl(
×
220
                  issueTitle: 'Sync error',
221
                  issueErrorMessage:
222
                      syncState.anyError?.toString() ??
×
223
                      'Sync stream stalled (connects but receives no data)',
224
                  applicationLogs: InMemoryLogStore().getFormattedLogs(),
×
225
                  syncDiagnostics: formatSyncDiagnostics(
×
226
                    syncState,
227
                    pendingUploads: pendingUploads,
228
                    server: serverCategory(serverUrl),
×
229
                  ),
230
                );
231
                try {
232
                  await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
×
233
                } catch (e, s) {
234
                  _logger.warning('Error opening issue tracker', e, s);
×
235
                }
236
              },
237
              child: const Text('Report issue'),
238
            ),
239
          // A stuck stream (firewall, VPN, flaky DNS) often recovers on a
240
          // fresh connection and can look healthy here while hanging, so the
241
          // action is not gated on an error. Absent only while offline, where
242
          // reconnecting would just spin against an unreachable backend.
243
          if (onReconnect != null)
1✔
244
            TextButton(
×
245
              onPressed: () {
×
246
                Navigator.of(context).pop();
×
247
                onReconnect!();
×
248
              },
249
              child: Text(i18n.syncStatusReconnect),
×
250
            ),
251
          TextButton(
1✔
252
            onPressed: () => Navigator.of(context).pop(),
×
253
            child: Text(MaterialLocalizations.of(context).closeButtonLabel),
3✔
254
          ),
255
        ],
256
      ),
257
    );
258
  }
259
}
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