• 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

69.83
/lib/features/auth/widgets/auth_card.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/foundation.dart';
20
import 'package:flutter/material.dart';
21
import 'package:flutter_riverpod/flutter_riverpod.dart';
22
import 'package:url_launcher/url_launcher.dart';
23
import 'package:wger/core/app_link_router.dart';
24
import 'package:wger/core/app_settings_notifier.dart';
25
import 'package:wger/core/consts.dart';
26
import 'package:wger/core/error_dialogs.dart';
27
import 'package:wger/core/errors.dart';
28
import 'package:wger/core/exceptions/http_exception.dart';
29
import 'package:wger/core/exceptions/mfa_required_exception.dart';
30
import 'package:wger/core/network/auth_notifier.dart';
31
import 'package:wger/core/network/auth_state.dart';
32
import 'package:wger/core/network/network_provider.dart';
33
import 'package:wger/core/widgets/server_config_warning_dialog.dart';
34
import 'package:wger/features/auth/screens/mfa_challenge_screen.dart';
35
import 'package:wger/features/auth/widgets/advanced_sheet.dart';
36
import 'package:wger/l10n/generated/app_localizations.dart';
37

38
import 'advanced_footer.dart';
39
import 'auth_mode_switch_link.dart';
40
import 'confirm_password_field.dart';
41
import 'email_field.dart';
42
import 'password_field.dart';
43
import 'refresh_token_field.dart';
44
import 'username_field.dart';
45
import 'web_handoff_link.dart';
46

47
enum AuthMode {
48
  register,
49
  login,
50
}
51

52
class AuthCard extends ConsumerStatefulWidget {
53
  const AuthCard();
1✔
54

55
  @override
1✔
56
  _AuthCardState createState() => _AuthCardState();
1✔
57
}
58

59
class _AuthCardState extends ConsumerState<AuthCard> {
60
  WgerHttpException? _httpError;
61
  final GlobalKey<FormState> _formKey = GlobalKey();
62

63
  AuthMode _authMode = AuthMode.login;
64

65
  bool _showNetworkError = false;
66
  // Live validation is suppressed until the user taps submit at least once.
67
  // Otherwise programmatic controller writes (debug prefill, _resetTextfields
68
  // on mode switch) trip the FormFields' "interacted by user" flag and
69
  // immediately surface error messages on a form the user hasn't touched.
70
  bool _autoValidate = false;
71
  bool _hideCustomServer = true;
72
  bool _useUsernameAndPassword = true;
73
  var _isLoading = false;
74

75
  final _usernameController = TextEditingController();
76
  final _passwordController = TextEditingController();
77
  final _password2Controller = TextEditingController();
78
  final _emailController = TextEditingController();
79
  final _serverUrlController = TextEditingController(
80
    text: kDebugMode ? DEFAULT_SERVER_TEST : DEFAULT_SERVER_PROD,
81
  );
82
  final _refreshTokenController = TextEditingController();
83

84
  @override
1✔
85
  void dispose() {
86
    _usernameController.dispose();
2✔
87
    _passwordController.dispose();
2✔
88
    _password2Controller.dispose();
2✔
89
    _emailController.dispose();
2✔
90
    _serverUrlController.dispose();
2✔
91
    _refreshTokenController.dispose();
2✔
92
    super.dispose();
1✔
93
  }
94

95
  @override
1✔
96
  void initState() {
97
    super.initState();
1✔
98
    AuthNotifier.getServerUrlFromPrefs().then((value) {
3✔
99
      if (mounted) {
1✔
100
        setState(() {
2✔
101
          _serverUrlController.text = value;
2✔
102
          // Reflect the actual server in the option selection: anything other
103
          // than the official server counts as a self-hosted instance.
104
          _hideCustomServer = value == DEFAULT_SERVER_PROD;
2✔
105
        });
106
      }
107
    });
108

109
    _preFillTextFields();
1✔
110
  }
111

112
  /// Opens the server's web-handoff page in the system browser. The user
113
  /// authenticates there (password, social, SSO, …) and the server redirects
114
  /// back via `wger://app-auth#token=…`, which the app_link_router picks up
115
  /// and feeds into the existing refresh-token login path.
116
  Future<void> _launchWebHandoff() async {
×
117
    var serverUrl = _serverUrlController.text.trim();
×
118
    if (serverUrl.endsWith('/')) {
×
119
      serverUrl = serverUrl.substring(0, serverUrl.length - 1);
×
120
    }
121
    if (serverUrl.isEmpty) {
×
122
      serverUrl = kDebugMode ? DEFAULT_SERVER_TEST : DEFAULT_SERVER_PROD;
123
    }
124
    final state = await issueAppAuthState(serverUrl);
×
125
    await launchUrl(
×
126
      Uri.parse('$serverUrl/user/app-auth/?state=$state'),
×
127
      mode: LaunchMode.externalApplication,
128
    );
129
  }
130

131
  void _preFillTextFields() {
1✔
132
    if (kDebugMode && _authMode == AuthMode.login) {
2✔
133
      setState(() {
2✔
134
        _usernameController.text = TESTSERVER_USER_NAME;
2✔
135
        _passwordController.text = TESTSERVER_PASSWORD;
2✔
136
      });
137
    }
138
  }
139

140
  void _resetTextFields() {
1✔
141
    _usernameController.clear();
2✔
142
    _passwordController.clear();
2✔
143
    _refreshTokenController.clear();
2✔
144
  }
145

146
  Future<void> _submit(BuildContext context) async {
1✔
147
    // From the first submit attempt on, validators run live as the user fixes
148
    // each field — but not before.
149
    setState(() {
2✔
150
      _autoValidate = true;
1✔
151
    });
152
    if (!_formKey.currentState!.validate()) {
3✔
153
      return;
154
    }
155
    setState(() {
2✔
156
      _isLoading = true;
1✔
157
    });
158

159
    var serverUrl = _serverUrlController.text;
2✔
160
    if (serverUrl.endsWith('/')) {
1✔
161
      serverUrl = serverUrl.substring(0, serverUrl.length - 1);
×
162
    }
163

164
    try {
165
      final authNotifier = ref.read(authProvider.notifier);
4✔
166
      // Login existing user
167
      late LoginActions res;
168
      if (_authMode == AuthMode.login) {
2✔
169
        res = await authNotifier.login(
×
170
          _usernameController.text,
×
171
          _passwordController.text,
×
172
          serverUrl,
173
          _refreshTokenController.text,
×
174
        );
175

176
        // Register new user
177
      } else {
178
        res = await authNotifier.register(
1✔
179
          username: _usernameController.text,
2✔
180
          password: _passwordController.text,
2✔
181
          email: _emailController.text,
2✔
182
          serverUrl: serverUrl,
183
          locale: Localizations.localeOf(context).languageCode,
2✔
184
        );
185
      }
186

187
      // The "update required" screens are handled reactively by main.dart's
188
      // _getHomeScreen, which swaps the home screen on the auth status.
189
      if (context.mounted && res == LoginActions.proceed) {
×
190
        final showWarning = ref.read(authProvider).value?.serverConfigWarning ?? false;
×
191
        if (showWarning && context.mounted) {
×
192
          showServerConfigWarning(context);
×
193
          ref.read(authProvider.notifier).clearServerConfigWarning();
×
194
        }
195
      }
196
    } on MfaRequiredException catch (e) {
1✔
197
      if (context.mounted) {
×
198
        await Navigator.of(context).push(
×
199
          MaterialPageRoute(
×
200
            builder: (_) => MfaChallengeScreen(
×
201
              sessionToken: e.sessionToken,
×
202
              serverUrl: serverUrl,
203
              availableFactors: e.availableFactors,
×
204
            ),
205
          ),
206
        );
207
      }
208
    } on WgerHttpException catch (error) {
1✔
209
      if (context.mounted) {
1✔
210
        setState(() {
2✔
211
          _httpError = error;
1✔
212
          _showNetworkError = false;
1✔
213
        });
214
      }
215
    } catch (error) {
216
      // Login is inherently online, but surface an unreachable server as a
217
      // friendly message instead of crashing to the red error screen.
218
      if (isNetworkError(error) && context.mounted) {
×
219
        setState(() {
×
220
          _showNetworkError = true;
×
221
          _httpError = null;
×
222
        });
223
      } else {
224
        rethrow;
225
      }
226
    } finally {
227
      if (mounted) {
1✔
228
        setState(() => _isLoading = false);
3✔
229
      }
230
    }
231
  }
232

233
  void _switchAuthMode() {
1✔
234
    if (_authMode == AuthMode.login) {
2✔
235
      setState(() {
2✔
236
        _authMode = AuthMode.register;
1✔
237
        _useUsernameAndPassword = true;
1✔
238
        _autoValidate = false;
1✔
239
      });
240
      _resetTextFields();
1✔
241
    } else {
242
      setState(() {
2✔
243
        _authMode = AuthMode.login;
1✔
244
        _autoValidate = false;
1✔
245
      });
246
      _preFillTextFields();
1✔
247
    }
248
  }
249

250
  /// Opens the advanced bottom sheet (server + sign-in-method selection).
251
  ///
252
  /// [allowSelfSignedCerts] is the persisted setting, passed in from `build` so
253
  /// the sheet opens on the current value.
254
  void _showAdvancedSheet(bool allowSelfSignedCerts) {
×
255
    showAdvancedSheet(
×
256
      context: context,
×
257
      initialHideCustomServer: _hideCustomServer,
×
258
      initialUsePassword: _useUsernameAndPassword,
×
259
      initialAllowSelfSignedCerts: allowSelfSignedCerts,
260
      loginMode: _authMode == AuthMode.login,
×
261
      serverUrlController: _serverUrlController,
×
262
      onChanged: (hideCustomServer, usePassword, allowSelfSigned) {
×
263
        setState(() {
×
264
          _hideCustomServer = hideCustomServer;
×
265
          _useUsernameAndPassword = usePassword;
×
266
        });
267
        // Persist immediately so the next login request, still made from this
268
        // screen, already trusts the certificate.
269
        ref.read(appSettingsProvider.notifier).setAllowSelfSignedCerts(allowSelfSigned);
×
270
      },
271
    ).then((_) {
×
272
      if (mounted) {
×
273
        setState(() {});
×
274
      }
275
    });
276
  }
277

278
  @override
1✔
279
  Widget build(BuildContext context) {
280
    final i18n = AppLocalizations.of(context);
1✔
281
    final deviceSize = MediaQuery.sizeOf(context);
1✔
282
    // Login/registration both need the server, so disable the action while
283
    // there is no connectivity.
284
    final isOnline = ref.watch(networkStatusProvider);
3✔
285
    final allowSelfSignedCerts = ref.watch(
2✔
286
      appSettingsProvider.select(
2✔
287
        (s) => s.value?.allowSelfSignedCerts ?? ALLOW_SELF_SIGNED_CERTS_DEFAULT,
3✔
288
      ),
289
    );
290

291
    // Involuntary logout (expired/revoked tokens): tell the user why they
292
    // are looking at the login form. The transient snackbar shown at the
293
    // moment of the logout is easy to miss, this hint persists until the
294
    // next login.
295
    final sessionExpired = ref.watch(
2✔
296
      authProvider.select((s) => s.value?.sessionExpired ?? false),
5✔
297
    );
298

299
    Widget errorMessage = const SizedBox.shrink();
300
    if (_httpError != null) {
1✔
301
      errorMessage = FormHttpErrorsWidget(_httpError!);
2✔
302
    } else if (_showNetworkError) {
1✔
303
      errorMessage = Padding(
×
304
        padding: const EdgeInsets.only(bottom: 10),
305
        child: Text(
×
306
          i18n.errorCouldNotConnectToServer,
×
307
          textAlign: TextAlign.center,
308
          style: TextStyle(color: Theme.of(context).colorScheme.error),
×
309
        ),
310
      );
311
    }
312

313
    return Card(
1✔
314
      shape: RoundedRectangleBorder(
1✔
315
        borderRadius: BorderRadius.circular(15.0),
1✔
316
      ),
317
      elevation: 8.0,
318
      child: Container(
1✔
319
        width: deviceSize.width * 0.9,
2✔
320
        padding: EdgeInsets.symmetric(
1✔
321
          horizontal: 15.0,
322
          vertical: 0.025 * deviceSize.height,
2✔
323
        ),
324
        child: Form(
1✔
325
          key: _formKey,
1✔
326
          autovalidateMode: _autoValidate
1✔
327
              ? AutovalidateMode.onUserInteraction
328
              : AutovalidateMode.disabled,
329
          child: SingleChildScrollView(
1✔
330
            child: AutofillGroup(
1✔
331
              child: Column(
1✔
332
                children: [
1✔
333
                  if (sessionExpired && _authMode == AuthMode.login)
×
334
                    Padding(
×
335
                      padding: const EdgeInsets.only(bottom: 10),
336
                      child: Text(
×
337
                        i18n.sessionExpired,
×
338
                        textAlign: TextAlign.center,
339
                        style: TextStyle(color: Theme.of(context).colorScheme.primary),
×
340
                      ),
341
                    ),
342
                  errorMessage,
1✔
343
                  if (_useUsernameAndPassword) UsernameField(controller: _usernameController),
3✔
344
                  if (_authMode == AuthMode.register) EmailField(controller: _emailController),
4✔
345
                  if (_useUsernameAndPassword)
1✔
346
                    PasswordField(
1✔
347
                      controller: _passwordController,
1✔
348
                      enforceMinLength: _authMode == AuthMode.register,
2✔
349
                    ),
350

351
                  if (_authMode == AuthMode.register)
2✔
352
                    ConfirmPasswordField(
1✔
353
                      controller: _password2Controller,
1✔
354
                      passwordController: _passwordController,
1✔
355
                    ),
356

357
                  if (_authMode == AuthMode.login && !_useUsernameAndPassword)
3✔
358
                    RefreshTokenField(controller: _refreshTokenController),
×
359

360
                  if (_authMode == AuthMode.login)
2✔
361
                    WebHandoffLink(
1✔
362
                      onTap: _isLoading ? null : _launchWebHandoff,
2✔
363
                    ),
364

365
                  const SizedBox(height: 20),
1✔
366
                  // Bespoke submit:  the shared FormSubmitButton only surfaces
367
                  // WgerHttpException and has no style override, so it is
368
                  // intentionally not used here.
369
                  SizedBox(
1✔
370
                    width: double.infinity,
371
                    height: 45,
372
                    child: ElevatedButton(
1✔
373
                      key: const Key('actionButton'),
374
                      onPressed: isOnline
375
                          ? () {
1✔
376
                              if (!_isLoading) {
1✔
377
                                _submit(context);
1✔
378
                              }
379
                            }
380
                          : null,
381
                      style: ElevatedButton.styleFrom(
1✔
382
                        backgroundColor: Theme.of(context).colorScheme.primary,
3✔
383
                      ),
384
                      child: _isLoading
1✔
385
                          ? const CircularProgressIndicator(
386
                              valueColor: AlwaysStoppedAnimation(Colors.white),
387
                            )
388
                          : Text(
1✔
389
                              _authMode == AuthMode.register
2✔
390
                                  ? i18n.register
1✔
391
                                  : (_useUsernameAndPassword ? i18n.login : i18n.signInWithToken),
2✔
392
                              style: const TextStyle(
393
                                color: Colors.white,
394
                                fontWeight: FontWeight.w600,
395
                              ),
396
                            ),
397
                    ),
398
                  ),
399

400
                  const SizedBox(height: 12),
1✔
401
                  AuthModeSwitchLink(
1✔
402
                    isLogin: _authMode == AuthMode.login,
2✔
403
                    onTap: _switchAuthMode,
1✔
404
                  ),
405
                  const SizedBox(height: 4),
1✔
406
                  AdvancedFooter(
1✔
407
                    isCustomServer: !_hideCustomServer,
1✔
408
                    isTokenMode: _authMode == AuthMode.login && !_useUsernameAndPassword,
3✔
409
                    serverUrl: _serverUrlController.text,
2✔
410
                    onTap: () => _showAdvancedSheet(allowSelfSignedCerts),
×
411
                  ),
412
                ],
413
              ),
414
            ),
415
          ),
416
        ),
417
      ),
418
    );
419
  }
420
}
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