diff --git a/.github/workflows/analyze.yml b/.github/workflows/analyze.yml
index ab3b72537..ca737991a 100644
--- a/.github/workflows/analyze.yml
+++ b/.github/workflows/analyze.yml
@@ -1,6 +1,10 @@
name: Analyze
on:
+ # Direct pushes are only validated on master (post-merge); everything else
+ # is covered by the pull_request trigger, avoiding duplicate runs for
+ # same-repo PR branches.
push:
+ branches: [ master, ]
paths:
- '**.dart'
- 'pubspec.yaml'
@@ -9,7 +13,6 @@ on:
- 'ios/Runner/Info.plist'
- 'android/app/src/main/res/xml/locales_config.xml'
pull_request:
- branches: [ master, ]
paths:
- '**/*.dart'
- 'pubspec.yaml'
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3d6b13e50..6b247b36e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,11 +1,14 @@
-name: Continous Integration
+name: Continuous Integration
on:
+ # Direct pushes are only validated on master (post-merge); everything else
+ # is covered by the pull_request trigger, avoiding duplicate runs for
+ # same-repo PR branches.
push:
+ branches: [ master, ]
paths:
- '**.dart'
- 'pubspec.yaml'
pull_request:
- branches: [ master, ]
paths:
- '**/*.dart'
- 'pubspec.yaml'
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 7fd9ed267..db37fc7a0 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -40,10 +40,14 @@ android {
defaultConfig {
// Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "de.wger.flutter"
- minSdkVersion flutter.minSdkVersion
+ minSdkVersion 26
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
+
+ // Overridden by the debug build type. Set here rather than per build
+ // type so the profile variant has it as well
+ resValue "string", "app_name", "wger"
}
signingConfigs {
@@ -59,7 +63,11 @@ android {
signingConfig signingConfigs.release
}
debug {
+ // Own application id and name, so a debug build can sit next to the
+ // installed release one on the same device. The launcher icon keeps
+ // the wger logo on a different background, see src/debug/res
applicationIdSuffix ".debug"
+ resValue "string", "app_name", "wger - debug"
}
}
}
diff --git a/android/app/src/debug/res/values/colors.xml b/android/app/src/debug/res/values/colors.xml
new file mode 100644
index 000000000..abf0b1a42
--- /dev/null
+++ b/android/app/src/debug/res/values/colors.xml
@@ -0,0 +1,6 @@
+
+
+
+ #e63946
+
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index df240b94f..39d865df8 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -9,11 +9,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/de/wger/flutter/MainActivity.kt b/android/app/src/main/kotlin/de/wger/flutter/MainActivity.kt
index 421496c68..3e9f4cd14 100644
--- a/android/app/src/main/kotlin/de/wger/flutter/MainActivity.kt
+++ b/android/app/src/main/kotlin/de/wger/flutter/MainActivity.kt
@@ -1,6 +1,6 @@
package de.wger.flutter
-import io.flutter.embedding.android.FlutterActivity
+import io.flutter.embedding.android.FlutterFragmentActivity
-class MainActivity: FlutterActivity() {
+class MainActivity: FlutterFragmentActivity() {
}
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index 8384323ad..006be35e8 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -287,7 +287,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -375,7 +375,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -424,7 +424,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 82371b5f1..b0855683b 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -87,6 +87,8 @@
NSCameraUsageDescription
Workout photos
+ NSHealthShareUsageDescription
+ wger reads your health data to automatically import body measurements such as weight, body fat, blood pressure, heart rate and blood oxygen
UIApplicationSceneManifest
UIApplicationSupportsMultipleScenes
diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements
index 903def2af..c77f75e0e 100644
--- a/ios/Runner/Runner.entitlements
+++ b/ios/Runner/Runner.entitlements
@@ -4,5 +4,7 @@
aps-environment
development
+ com.apple.developer.healthkit
+
diff --git a/lib/core/consts.dart b/lib/core/consts.dart
index 65c9c38a6..cf6da6b3b 100644
--- a/lib/core/consts.dart
+++ b/lib/core/consts.dart
@@ -24,7 +24,7 @@ import 'package:intl/intl.dart';
/// Bump this value whenever you depend on API changes that are only available
/// in a newer server release. The check is performed during login and
/// auto-login and mirrors what the server does with MIN_APP_VERSION.
-const MIN_SERVER_VERSION = '2.6';
+const MIN_SERVER_VERSION = '2.7';
/// Size for the "smaller" icons, e.g. when they belong to less important items
/// and we don't want to fill the whole screen
@@ -69,6 +69,9 @@ const PREFS_LAST_SERVER = 'lastServer';
const PREFS_USE_DYNAMIC_COLOR = 'useDynamicColor';
const USE_DYNAMIC_COLOR_DEFAULT = false;
+/// The chart range the measurement screens share, held as `ChartRange.name`
+const PREFS_CHART_RANGE = 'measurementChartRange';
+
/// Headless JWT auth: SharedPreferences keys.
///
/// Read in parallel with the legacy `PREFS_USER` blob during the migration
diff --git a/lib/core/formatting/formatting.dart b/lib/core/formatting/formatting.dart
index 5294e789e..2f7d008cc 100644
--- a/lib/core/formatting/formatting.dart
+++ b/lib/core/formatting/formatting.dart
@@ -1,6 +1,6 @@
/*
* This file is part of wger Workout Manager .
- * Copyright (c) 2026 wger Team
+ * Copyright (c) 2026 - 2026 wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
@@ -16,8 +16,11 @@
* along with this program. If not, see .
*/
+import 'dart:math';
+
import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart';
+import 'package:wger/l10n/generated/app_localizations.dart';
/// A short date format (`DateFormat.yMd`) bound to the current locale's
/// language. Chain further patterns (e.g. `.add_Hm()`) as needed.
@@ -27,3 +30,118 @@ DateFormat localizedDate(BuildContext context) =>
/// A decimal number format bound to the current locale.
NumberFormat localizedNumberFormat(BuildContext context) =>
NumberFormat.decimalPattern(Localizations.localeOf(context).toString());
+
+/// A date in the past as a relative phrase ("today", "3 weeks ago").
+///
+/// Counts calendar days rather than elapsed hours, so an entry from late
+/// yesterday still reads as yesterday this morning. The unit grows with the
+/// distance: days within a week, then weeks, months, years. Matches react's
+/// dateToRelative for past dates, which reaches the same output through Intl.
+///
+/// A date ahead of [now] reads as today: the pickers do not offer one, so it
+/// only ever arrives through clock skew between devices, and the phrases for
+/// it (react has them from Intl) would be four more strings to translate for
+/// a case nobody is looking at.
+String relativeDate(BuildContext context, DateTime date, {DateTime? now}) {
+ final today = now ?? DateTime.now();
+ // Calendar arithmetic in UTC, so a DST day is not 23 or 25 hours long
+ final elapsed = DateTime.utc(
+ today.year,
+ today.month,
+ today.day,
+ ).difference(DateTime.utc(date.year, date.month, date.day)).inDays;
+ final days = max(0, elapsed);
+
+ final i18n = AppLocalizations.of(context);
+ if (days < DateTime.daysPerWeek) {
+ return i18n.relativeDaysAgo(days);
+ }
+ if (days < 31) {
+ return i18n.relativeWeeksAgo((days / 7).round());
+ }
+ if (days < 365) {
+ return i18n.relativeMonthsAgo((days / 30).round());
+ }
+ return i18n.relativeYearsAgo((days / 365).round());
+}
+
+/// The unit a duration is stored in, which is what the health platforms deliver.
+const durationUnit = 'min';
+
+/// A duration in minutes as hours and minutes, e.g. 452 as `7:32`.
+///
+/// Neither intl nor intl4x has a duration formatter, so the parts are put
+/// together the way flutter_localizations does it for a time of day: the
+/// digits come from the locale (`۷:۳۲` in Persian) and the minutes are zero
+/// padded through the number format rather than through the string. The sign
+/// is ours, a duration is only ever negative here as a change between two of
+/// them. The web side reaches the same output through Intl.DurationFormat.
+String hoursAndMinutes(num minutes, String locale) {
+ final rounded = minutes.round();
+ final absolute = rounded.abs();
+
+ return '${rounded < 0 ? '-' : ''}'
+ '${NumberFormat.decimalPattern(locale).format(absolute ~/ 60)}:'
+ '${NumberFormat('00', locale).format(absolute % 60)}';
+}
+
+/// A measured value on its own, formatted the way its unit is read. For the
+/// ends of a range, where only the last one carries the unit.
+///
+/// [decimals] caps the fraction digits, for at-a-glance readings; without it
+/// the locale default (up to three) applies. A duration ignores it, hours and
+/// minutes have no decimals to cap.
+String measurementValue(BuildContext context, num value, String unit, {int? decimals}) =>
+ unit == durationUnit
+ ? hoursAndMinutes(value, Localizations.localeOf(context).toString())
+ : (decimals == null
+ ? localizedNumberFormat(context)
+ : (localizedNumberFormat(context)..maximumFractionDigits = decimals))
+ .format(value);
+
+/// The unit as it is shown. A duration is stored in minutes but read in hours,
+/// and the symbol stays untranslated like the units of the other categories.
+String measurementUnit(String unit) => unit == durationUnit ? 'h' : unit;
+
+/// An already formatted value followed by its unit, or on its own where there
+/// is none: a step count is a bare number, and so may be a free-form category.
+String unitSuffixed(String formatted, String unit) =>
+ unit.isEmpty ? formatted : '$formatted ${measurementUnit(unit)}';
+
+/// A measured value with its unit. [decimals] as in [measurementValue].
+String measurementWithUnit(BuildContext context, num value, String unit, {int? decimals}) =>
+ unitSuffixed(measurementValue(context, value, unit, decimals: decimals), unit);
+
+/// Ticks a duration axis aims for, few enough that the labels stay apart.
+const _DURATION_TICKS = 6;
+
+const _MINUTES_PER_HOUR = 60;
+
+/// Bounds and tick interval of an axis of durations, null for every other
+/// unit, where fl_chart picks them.
+///
+/// A duration is read in hours, so a tick belongs on a whole one: an axis
+/// labelled 6:40, 8:20, 10:00 is arithmetically correct and unreadable. The
+/// interval grows in whole hours until few enough ticks are left, and the
+/// bounds are widened to the hours around the data, because fl_chart counts
+/// the ticks from the lower bound.
+({double min, double max, double interval})? durationAxis(
+ String unit,
+ num min,
+ num max,
+) {
+ if (unit != durationUnit) {
+ return null;
+ }
+
+ final from = (min / _MINUTES_PER_HOUR).floor() * _MINUTES_PER_HOUR;
+ final to = (max / _MINUTES_PER_HOUR).ceil() * _MINUTES_PER_HOUR;
+ final hours = ((to - from) / _MINUTES_PER_HOUR).clamp(1, double.infinity);
+ final interval = (hours / _DURATION_TICKS).ceil() * _MINUTES_PER_HOUR;
+
+ // The top follows the interval rather than the data: a bound that ended
+ // below the last tick would cut the values it was derived from
+ final top = from + ((to - from) / interval).ceil() * interval;
+
+ return (min: from.toDouble(), max: top.toDouble(), interval: interval.toDouble());
+}
diff --git a/lib/core/home_tabs_screen.dart b/lib/core/home_tabs_screen.dart
index f6b5f46ac..0d9654683 100644
--- a/lib/core/home_tabs_screen.dart
+++ b/lib/core/home_tabs_screen.dart
@@ -22,9 +22,10 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:wger/core/dashboard.dart';
import 'package:wger/core/material.dart';
import 'package:wger/features/gallery/screens/gallery_screen.dart';
+import 'package:wger/features/health/providers/health_sync.dart';
+import 'package:wger/features/measurements/screens/measurement_categories_screen.dart';
import 'package:wger/features/nutrition/screens/nutritional_plans_screen.dart';
import 'package:wger/features/routines/screens/routine_list_screen.dart';
-import 'package:wger/features/weight/screens/weight_screen.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
class HomeTabsScreen extends ConsumerStatefulWidget {
@@ -41,6 +42,20 @@ class _HomeTabsScreenState extends ConsumerState
int _selectedIndex = 0;
bool _isWideScreen = false;
+ @override
+ void initState() {
+ super.initState();
+
+ // Pull any new readings from Apple Health / Health Connect once the app is
+ // open. The sync is a no-op unless the user enabled it in the settings.
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted) {
+ return;
+ }
+ ref.read(healthSyncProvider.notifier).sync();
+ });
+ }
+
@override
void didChangeDependencies() {
super.didChangeDependencies();
@@ -59,7 +74,7 @@ class _HomeTabsScreenState extends ConsumerState
const DashboardScreen(),
const RoutineListScreen(),
const NutritionalPlansScreen(),
- const WeightScreen(),
+ const MeasurementCategoriesScreen(),
const GalleryScreen(),
];
@@ -80,7 +95,7 @@ class _HomeTabsScreenState extends ConsumerState
),
NavigationDestination(
icon: const FaIcon(FontAwesomeIcons.weightScale, size: 20),
- label: AppLocalizations.of(context).weight,
+ label: AppLocalizations.of(context).labelBottomNavBody,
),
NavigationDestination(
icon: const Icon(Icons.photo_library),
diff --git a/lib/core/network/auth_notifier.g.dart b/lib/core/network/auth_notifier.g.dart
index 86ce6eeff..7d29a5ac1 100644
--- a/lib/core/network/auth_notifier.g.dart
+++ b/lib/core/network/auth_notifier.g.dart
@@ -32,7 +32,7 @@ final class AuthNotifierProvider extends $AsyncNotifierProvider AuthNotifier();
}
-String _$authNotifierHash() => r'19bf6776a00c5a7374ddc918f55709ee3a193b3f';
+String _$authNotifierHash() => r'2e848f93a2dec9ca9c98ce85e860864c58fa0a2b';
abstract class _$AuthNotifier extends $AsyncNotifier {
FutureOr build();
diff --git a/lib/core/shared_preferences.dart b/lib/core/shared_preferences.dart
index de3ed3b16..a54b58671 100644
--- a/lib/core/shared_preferences.dart
+++ b/lib/core/shared_preferences.dart
@@ -1,3 +1,5 @@
+import 'dart:convert';
+
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences/util/legacy_to_async_migration_util.dart';
import 'package:wger/core/search_options.dart';
@@ -132,4 +134,72 @@ class PreferenceHelper {
orElse: () => fallback.searchMode,
);
}
+
+ // --- Health sync ---
+
+ static const _healthSyncEnabledKey = 'healthSyncEnabled';
+ static const _healthSyncWatermarksKey = 'healthSyncWatermarks';
+ static const _healthSyncReadableTypesKey = 'healthSyncReadableTypes';
+ static const _healthSyncEmptyMetricsKey = 'healthSyncEmptyMetrics';
+
+ Future setHealthSyncEnabled(bool value) async {
+ await PreferenceHelper.asyncPref.setBool(_healthSyncEnabledKey, value);
+ }
+
+ Future getHealthSyncEnabled() async {
+ final value = await PreferenceHelper.asyncPref.getBool(_healthSyncEnabledKey);
+ return value ?? false;
+ }
+
+ /// How far each metric has been imported, keyed by `MetricType.name` and
+ /// held as ISO-8601 timestamps.
+ ///
+ /// Per metric rather than one for all of them, so an import interrupted
+ /// halfway resumes where each metric got to instead of starting over, and
+ /// so that a metric that cannot be imported holds nobody else back.
+ Future setHealthSyncWatermarks(Map value) async {
+ await PreferenceHelper.asyncPref.setString(_healthSyncWatermarksKey, jsonEncode(value));
+ }
+
+ Future