• 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

71.83
/lib/core/helpers.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 'dart:async';
20

21
import 'package:flutter_riverpod/flutter_riverpod.dart';
22
import 'package:flutter_riverpod/misc.dart' show ProviderListenable;
23
import 'package:http/http.dart' as http;
24
import 'package:logging/logging.dart';
25

26
/// Awaits the first value of `provider` by explicitly subscribing via
27
/// [Ref.listen]. Use this in notifier methods (outside of `build()`)
28
/// instead of `ref.read(streamProvider.future)`, which can hang
29
/// indefinitely if no other consumer is `ref.watch`-ing the provider:
30
/// Riverpod's internal subscription for `.future` doesn't always trigger
31
/// the underlying stream's first emission in that scenario.
32
///
33
/// Completes with the first non-loading value, or rejects with the first
34
/// error. Closes the subscription as soon as the future resolves.
35
extension AwaitFirstValue on Ref {
36
  Future<T> awaitFirstValue<T>(ProviderListenable<AsyncValue<T>> provider) {
4✔
37
    final completer = Completer<T>();
4✔
38

39
    // Nullable (not `late`) on purpose: with `fireImmediately: true` the
40
    // listener can fire synchronously during the `listen(...)` call
41
    // itself, before the assignment to `sub` runs.
42
    ProviderSubscription<AsyncValue<T>>? sub;
43
    sub = listen<AsyncValue<T>>(provider, (_, next) {
8✔
44
      if (completer.isCompleted) {
4✔
45
        return;
46
      }
47
      if (next.hasValue) {
4✔
48
        completer.complete(next.value as T);
8✔
49
        sub?.close();
4✔
50
      } else if (next.hasError) {
4✔
51
        completer.completeError(next.error!, next.stackTrace);
×
52
        sub?.close();
×
53
      }
54
    }, fireImmediately: true);
55
    if (completer.isCompleted) {
4✔
56
      // Listener fired synchronously; close now that we have the handle.
57
      sub.close();
3✔
58
    }
59
    return completer.future;
4✔
60
  }
61
}
62

63
/// Helper function to make a URL.
64
Uri makeUri(
8✔
65
  String serverUrl,
66
  String path, {
67
  int? id,
68
  String? objectMethod,
69
  Map<String, dynamic>? query,
70
  bool trailingSlash = true,
71
}) {
72
  final Uri uriServer = Uri.parse(serverUrl);
8✔
73

74
  final pathList = [uriServer.path, 'api', 'v2', path];
16✔
75
  if (id != null) {
76
    pathList.add(id.toString());
2✔
77
  }
78
  if (objectMethod != null) {
79
    pathList.add(objectMethod);
×
80
  }
81

82
  final uri = Uri(
8✔
83
    scheme: uriServer.scheme,
8✔
84
    host: uriServer.host,
8✔
85
    port: uriServer.port,
8✔
86
    path: '${pathList.join('/')}${trailingSlash ? '/' : ''}',
16✔
87
    queryParameters: query,
88
  );
89

90
  return uri;
91
}
92

93
/// Builds a URL for the `allauth.headless` `app` client API at
94
/// `/allauth/app/v1/<path>`. Used by the auth notifier for login,
95
/// signup, MFA, refresh, etc. The headless API does not use a trailing
96
/// slash and lives on a separate URL prefix from the DRF data API.
97
Uri makeHeadlessUri(String serverUrl, String path) {
4✔
98
  final Uri uriServer = Uri.parse(serverUrl);
4✔
99
  return Uri(
4✔
100
    scheme: uriServer.scheme,
4✔
101
    host: uriServer.host,
4✔
102
    port: uriServer.port,
4✔
103
    path: [uriServer.path, 'allauth', 'app', 'v1', path].join('/'),
12✔
104
  );
105
}
106

107
final _probeLogger = Logger('powersync-probe');
9✔
108

109
/// Builds `<serverUrl>/ps/`, the default path under which wger's reverse
110
/// proxy exposes the PowerSync service.
111
String _defaultPowerSyncUrl(String serverUrl) {
6✔
112
  final server = Uri.parse(serverUrl);
6✔
113
  final basePath = server.path.endsWith('/')
12✔
114
      ? server.path.substring(0, server.path.length - 1)
5✔
115
      : server.path;
6✔
116
  return Uri(
6✔
117
    scheme: server.scheme,
6✔
118
    host: server.host,
6✔
119
    port: server.port,
6✔
120
    path: '$basePath/ps/',
6✔
121
  ).toString();
6✔
122
}
123

124
/// Returns the first PowerSync endpoint that answers its liveness probe with
125
/// a 200, or null when none does.
126
///
127
/// The `powersync_url` [provided] by the server's token endpoint is tried
128
/// first, then `<serverUrl>/ps/` as a fallback. Probing instead of trusting
129
/// [provided] rescues servers whose `SITE_URL` doesn't match the URL the app
130
/// reaches them under (e.g. left at its `http://localhost` default): that
131
/// value is unreachable from the device, while the reverse proxy usually
132
/// still exposes the service under the default path.
133
Future<String?> findLivePowerSyncUrl({
6✔
134
  required http.Client client,
135
  required String serverUrl,
136
  required String? provided,
137
  Duration probeTimeout = const Duration(seconds: 5),
138
}) async {
139
  final parsed = provided == null ? null : Uri.tryParse(provided);
6✔
140
  final candidates = {
141
    if (parsed != null && parsed.hasScheme && parsed.host.isNotEmpty) provided!,
24✔
142
    _defaultPowerSyncUrl(serverUrl),
6✔
143
  };
144

145
  for (final candidate in candidates) {
12✔
146
    final probeUri = Uri.parse(
6✔
147
      '$candidate${candidate.endsWith('/') ? '' : '/'}probes/liveness',
12✔
148
    );
149

150
    // Log failed probes
151
    try {
152
      final response = await client.get(probeUri).timeout(probeTimeout);
11✔
153
      if (response.statusCode == 200) {
10✔
154
        return candidate;
155
      }
156
      _probeLogger.info('PowerSync probe: $probeUri returned ${response.statusCode}');
8✔
157
    } on Exception catch (e) {
2✔
158
      // Unreachable or timed out: try the next candidate.
159
      _probeLogger.info('PowerSync probe: $probeUri failed: $e');
6✔
160
    }
161
  }
162
  return null;
163
}
164

165
/// Builds the absolute URL for a server-side media file given its
166
/// [relativePath] (the raw value of a Django `ImageField` / `FileField`
167
/// as stored in the DB, e.g. `ingredients/42/foo.jpg`).
168
///
169
/// Returns `null` if [relativePath] is null or empty. If [relativePath]
170
/// already contains a scheme it is returned as-is, so values that the
171
/// REST API has already absolutised (or that point at an external CDN)
172
/// pass through unchanged.
173
///
174
/// When [absolutePrefix] is provided it is used as-is (e.g. the prefix
175
/// detected once via the REST API), so deployments with a non-default
176
/// `MEDIA_URL` or a CDN in front of the media files work transparently.
177
/// When omitted, the function falls back to the assumption that media
178
/// is served from `<serverUrl>/media/`, Django's default.
179
Uri? mediaUri(String serverUrl, String? relativePath, {String? absolutePrefix}) {
×
180
  if (relativePath == null || relativePath.isEmpty) {
×
181
    return null;
182
  }
183

184
  final parsed = Uri.tryParse(relativePath);
×
185
  if (parsed != null && parsed.hasScheme) {
×
186
    return parsed;
187
  }
188

189
  final cleanRelative = relativePath.startsWith('/') ? relativePath.substring(1) : relativePath;
×
190

191
  // Prefer the probed prefix (full URL ending in `/`) when available.
192
  if (absolutePrefix != null && absolutePrefix.isNotEmpty) {
×
193
    final cleanPrefix = absolutePrefix.endsWith('/') ? absolutePrefix : '$absolutePrefix/';
×
194
    return Uri.parse('$cleanPrefix$cleanRelative');
×
195
  }
196

197
  // Fallback: assume Django's default MEDIA_URL of `/media/` on the same host.
198
  final server = Uri.parse(serverUrl);
×
199
  final basePath = server.path.endsWith('/')
×
200
      ? server.path.substring(0, server.path.length - 1)
×
201
      : server.path;
×
202

203
  return Uri(
×
204
    scheme: server.scheme,
×
205
    host: server.host,
×
206
    port: server.port,
×
207
    path: '$basePath/media/$cleanRelative',
×
208
  );
209
}
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