• 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

49.47
/lib/core/app_settings_notifier.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:convert';
20

21
import 'package:collection/collection.dart';
22
import 'package:flutter/material.dart';
23
import 'package:flutter_riverpod/flutter_riverpod.dart' show Provider;
24
import 'package:freezed_annotation/freezed_annotation.dart';
25
import 'package:riverpod_annotation/riverpod_annotation.dart';
26
import 'package:shared_preferences/shared_preferences.dart';
27
import 'package:wger/core/consts.dart';
28
import 'package:wger/core/http_overrides.dart';
29
import 'package:wger/core/locale.dart';
30
import 'package:wger/core/shared_preferences.dart';
31
import 'package:wger/l10n/generated/app_localizations.dart';
32

33
part 'app_settings_notifier.freezed.dart';
34
part 'app_settings_notifier.g.dart';
35

36
const PREFS_DASHBOARD_CONFIG = 'dashboardConfig';
37

38
enum DashboardWidget {
39
  trophies('trophies'),
40
  routines('routines'),
41
  nutrition('nutrition'),
42
  weight('weight'),
43
  measurements('measurements'),
44
  calendar('calendar');
45

46
  final String value;
47
  const DashboardWidget(this.value);
48

49
  static DashboardWidget? fromString(String s) {
×
50
    for (final e in DashboardWidget.values) {
×
51
      if (e.value == s) {
×
52
        return e;
53
      }
54
    }
55
    return null;
56
  }
57
}
58

59
class DashboardItem {
60
  final DashboardWidget widget;
61
  final bool isVisible;
62

63
  const DashboardItem(this.widget, {this.isVisible = true});
5✔
64

65
  DashboardItem copyWith({bool? isVisible}) =>
×
66
      DashboardItem(widget, isVisible: isVisible ?? this.isVisible);
×
67

68
  Map<String, dynamic> toJson() => {
2✔
69
    'widget': widget.value,
2✔
70
    'visible': isVisible,
1✔
71
  };
72
}
73

74
@freezed
75
sealed class AppSettings with _$AppSettings {
76
  const factory AppSettings({
77
    @Default(ThemeMode.system) ThemeMode themeMode,
78
    @Default([]) List<DashboardItem> dashboardItems,
79

80
    /// Locale override. Null means the app follows the system locale.
81
    Locale? userLocale,
82

83
    /// When true, a manual logout keeps the local database on disk instead
84
    /// of wiping it, so the same user signing back in resumes incrementally.
85
    @Default(KEEP_DATA_ON_LOGOUT_DEFAULT) bool keepDataOnLogout,
86

87
    /// When true, an invalid TLS certificate is accepted from the self-hosted
88
    /// server the app is configured for. Never applies to the official servers.
89
    @Default(ALLOW_SELF_SIGNED_CERTS_DEFAULT) bool allowSelfSignedCerts,
90
  }) = _AppSettings;
91
}
92

93
/// SharedPreferences accessor for local settings. Override in tests.
94
final appSettingsPrefsProvider = Provider<SharedPreferencesAsync>(
36✔
95
  (ref) => PreferenceHelper.asyncPref,
16✔
96
);
97

98
@Riverpod(keepAlive: true)
99
class AppSettingsNotifier extends _$AppSettingsNotifier {
100
  late SharedPreferencesAsync _prefs;
101

102
  @override
5✔
103
  Future<AppSettings> build() async {
104
    _prefs = ref.read(appSettingsPrefsProvider);
20✔
105
    final themeMode = await _loadThemeMode();
5✔
106
    final userLocale = await _loadUserLocale();
5✔
107
    final items = await _loadDashboardItems();
5✔
108
    final keepDataOnLogout = await _loadKeepDataOnLogout();
5✔
109
    final allowSelfSignedCerts = await _loadAllowSelfSignedCerts();
5✔
110
    return AppSettings(
5✔
111
      themeMode: themeMode,
112
      userLocale: userLocale,
113
      dashboardItems: items,
114
      keepDataOnLogout: keepDataOnLogout,
115
      allowSelfSignedCerts: allowSelfSignedCerts,
116
    );
117
  }
118

119
  //
120
  // Theme mode
121
  //
122

123
  Future<ThemeMode> _loadThemeMode() async {
5✔
124
    final dark = await _prefs.getBool(PREFS_USER_DARK_THEME);
10✔
125
    if (dark == null) {
126
      return ThemeMode.system;
127
    }
128
    return dark ? ThemeMode.dark : ThemeMode.light;
129
  }
130

131
  Future<void> setThemeMode(ThemeMode mode) async {
×
132
    final current = state.asData?.value ?? const AppSettings();
×
133
    state = AsyncData(current.copyWith(themeMode: mode));
×
134

135
    if (mode == ThemeMode.system) {
×
136
      await _prefs.remove(PREFS_USER_DARK_THEME);
×
137
    } else {
138
      await _prefs.setBool(PREFS_USER_DARK_THEME, mode == ThemeMode.dark);
×
139
    }
140
  }
141

142
  //
143
  // Locale override
144
  //
145

146
  Future<Locale?> _loadUserLocale() async {
5✔
147
    final raw = await _prefs.getString(PREFS_USER_LOCALE);
10✔
148
    return _matchSupportedLocale(raw);
5✔
149
  }
150

151
  /// Match a stored locale tag (`languageCode` or `languageCode_subtag`)
152
  /// against [AppLocalizations.supportedLocales]. Returns the exact supported
153
  /// instance to keep dropdown identity stable, or null when no match is found.
154
  static Locale? _matchSupportedLocale(String? raw) {
5✔
155
    if (raw == null || raw.isEmpty) {
×
156
      return null;
157
    }
158
    for (final locale in AppLocalizations.supportedLocales) {
×
159
      if (encodeLocale(locale) == raw) {
×
160
        return locale;
161
      }
162
    }
163
    // Fallback: match by language only (e.g. stored "pl" picks the only pl).
164
    final lang = raw.split('_').first;
×
165
    for (final locale in AppLocalizations.supportedLocales) {
×
166
      if (locale.languageCode == lang &&
×
167
          (locale.countryCode == null || locale.countryCode!.isEmpty) &&
×
168
          (locale.scriptCode == null || locale.scriptCode!.isEmpty)) {
×
169
        return locale;
170
      }
171
    }
172
    return null;
173
  }
174

175
  /// Override the app locale. Passing `null` clears the override and falls
176
  /// back to the system locale.
177
  Future<void> setUserLocale(Locale? locale) async {
×
178
    final current = state.asData?.value ?? const AppSettings();
×
179
    state = AsyncData(current.copyWith(userLocale: locale));
×
180

181
    if (locale == null) {
182
      await _prefs.remove(PREFS_USER_LOCALE);
×
183
    } else {
184
      await _prefs.setString(PREFS_USER_LOCALE, encodeLocale(locale));
×
185
    }
186
  }
187

188
  //
189
  // Keep local data on logout
190
  //
191

192
  Future<bool> _loadKeepDataOnLogout() async =>
5✔
193
      (await _prefs.getBool(PREFS_KEEP_DATA_ON_LOGOUT)) ?? KEEP_DATA_ON_LOGOUT_DEFAULT;
10✔
194

195
  Future<void> setKeepDataOnLogout(bool value) async {
1✔
196
    final current = state.asData?.value ?? const AppSettings();
3✔
197
    state = AsyncData(current.copyWith(keepDataOnLogout: value));
4✔
198
    await _prefs.setBool(PREFS_KEEP_DATA_ON_LOGOUT, value);
2✔
199
  }
200

201
  //
202
  // Allow self-signed certificates
203
  //
204

205
  Future<bool> _loadAllowSelfSignedCerts() async {
5✔
206
    final value =
207
        (await _prefs.getBool(PREFS_ALLOW_SELF_SIGNED_CERTS)) ?? ALLOW_SELF_SIGNED_CERTS_DEFAULT;
10✔
208
    // The override reads a static, so mirror the setting on both load and write
209
    // and it can never drift from this provider.
210
    WgerHttpOverrides.allowSelfSignedCerts = value;
211
    return value;
212
  }
213

214
  Future<void> setAllowSelfSignedCerts(bool value) async {
1✔
215
    final current = state.asData?.value ?? const AppSettings();
3✔
216
    state = AsyncData(current.copyWith(allowSelfSignedCerts: value));
4✔
217
    WgerHttpOverrides.allowSelfSignedCerts = value;
218
    await _prefs.setBool(PREFS_ALLOW_SELF_SIGNED_CERTS, value);
2✔
219
  }
220

221
  //
222
  // Dashboard config
223
  //
224

225
  Future<List<DashboardItem>> _loadDashboardItems() async {
5✔
226
    final jsonString = await _prefs.getString(PREFS_DASHBOARD_CONFIG);
10✔
227
    if (jsonString == null) {
228
      return DashboardWidget.values.map((w) => DashboardItem(w)).toList();
20✔
229
    }
230

231
    try {
232
      final List<dynamic> decoded = jsonDecode(jsonString);
×
233
      final List<DashboardItem> loaded = [];
×
234

235
      for (final item in decoded) {
×
236
        final widget = DashboardWidget.fromString(item['widget']);
×
237
        if (widget != null) {
238
          loaded.add(DashboardItem(widget, isVisible: item['visible'] as bool));
×
239
        }
240
      }
241

242
      // Add any missing widgets (e.g. newly added features)
243
      for (final widget in DashboardWidget.values) {
×
244
        if (!loaded.any((item) => item.widget == widget)) {
×
245
          var index = DashboardWidget.values.indexOf(widget);
×
246
          if (index > loaded.length) {
×
247
            index = loaded.length;
×
248
          }
249
          loaded.insert(index, DashboardItem(widget));
×
250
        }
251
      }
252

253
      return loaded;
254
    } catch (_) {
255
      return DashboardWidget.values.map((w) => DashboardItem(w)).toList();
×
256
    }
257
  }
258

259
  Future<void> _persistDashboard(List<DashboardItem> items) async {
1✔
260
    final serializable = items.map((e) => e.toJson()).toList();
4✔
261
    await _prefs.setString(PREFS_DASHBOARD_CONFIG, jsonEncode(serializable));
3✔
262
  }
263

264
  Future<void> setWidgetVisible(DashboardWidget key, bool visible) async {
×
265
    final current = state.asData?.value ?? const AppSettings();
×
266
    final updated = current.dashboardItems.map((item) {
×
267
      if (item.widget == key) {
×
268
        return item.copyWith(isVisible: visible);
×
269
      }
270
      return item;
271
    }).toList();
×
272

273
    state = AsyncData(current.copyWith(dashboardItems: updated));
×
274
    await _persistDashboard(updated);
×
275
  }
276

277
  Future<void> setDashboardOrder(int oldIndex, int newIndex) async {
1✔
278
    final current = state.asData?.value ?? const AppSettings();
3✔
279
    final items = List<DashboardItem>.of(current.dashboardItems);
2✔
280
    final item = items.removeAt(oldIndex);
1✔
281
    items.insert(newIndex, item);
1✔
282

283
    state = AsyncData(current.copyWith(dashboardItems: items));
4✔
284
    await _persistDashboard(items);
1✔
285
  }
286
}
287

288
extension DashboardConfigQuery on List<DashboardItem> {
289
  /// List of visible dashboard widgets, in the configured order.
290
  List<DashboardWidget> get visibleWidgets =>
1✔
291
      where((w) => w.isVisible).map((w) => w.widget).toList();
7✔
292

293
  /// All dashboard widgets, in the configured order (including hidden).
294
  List<DashboardWidget> get allWidgets => map((w) => w.widget).toList();
×
295

296
  bool isWidgetVisible(DashboardWidget key) {
×
297
    final item = firstWhereOrNull((e) => e.widget == key);
×
298
    return item == null || item.isVisible;
×
299
  }
300
}
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