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> getHealthSyncWatermarks() async { + final stored = await PreferenceHelper.asyncPref.getString(_healthSyncWatermarksKey); + if (stored == null) { + return {}; + } + return (jsonDecode(stored) as Map).cast(); + } + + /// The health data types the platform let us read during the last sync. + /// + /// A type that was not readable then has no history in wger, so the sync + /// reads the full window once it becomes readable, instead of starting at + /// the watermark and leaving everything before it missing. + Future setHealthSyncReadableTypes(List value) async { + await PreferenceHelper.asyncPref.setStringList(_healthSyncReadableTypesKey, value); + } + + Future?> getHealthSyncReadableTypes() async { + return PreferenceHelper.asyncPref.getStringList(_healthSyncReadableTypesKey); + } + + /// The metrics the platform had nothing at all for when their full history + /// was last read. + /// + /// Such a metric never gets a category, and a missing category is what sends + /// the sync back to the full window; without this it would do so on every + /// run, for every metric. + Future setHealthSyncEmptyMetrics(List value) async { + await PreferenceHelper.asyncPref.setStringList(_healthSyncEmptyMetricsKey, value); + } + + Future?> getHealthSyncEmptyMetrics() async { + return PreferenceHelper.asyncPref.getStringList(_healthSyncEmptyMetricsKey); + } + + Future clearHealthSyncPreferences() async { + await PreferenceHelper.asyncPref.remove(_healthSyncEnabledKey); + await PreferenceHelper.asyncPref.remove(_healthSyncWatermarksKey); + await PreferenceHelper.asyncPref.remove(_healthSyncReadableTypesKey); + await PreferenceHelper.asyncPref.remove(_healthSyncEmptyMetricsKey); + } } diff --git a/lib/core/widgets/dashboard/calendar.dart b/lib/core/widgets/dashboard/calendar.dart index 719a2a4ae..77afb1696 100644 --- a/lib/core/widgets/dashboard/calendar.dart +++ b/lib/core/widgets/dashboard/calendar.dart @@ -24,14 +24,17 @@ import 'package:wger/core/date.dart'; import 'package:wger/core/formatting/formatting.dart'; import 'package:wger/core/json.dart'; import 'package:wger/core/widgets/progress_indicator.dart'; +import 'package:wger/features/account/providers/user_profile_notifier.dart'; +import 'package:wger/features/measurements/charts/data.dart'; +import 'package:wger/features/measurements/models/measurement_bucket.dart'; import 'package:wger/features/measurements/models/measurement_category.dart'; +import 'package:wger/features/measurements/models/unit_conversion.dart'; import 'package:wger/features/measurements/providers/measurement_notifier.dart'; +import 'package:wger/features/measurements/widgets/charts.dart'; import 'package:wger/features/nutrition/models/nutritional_plan.dart'; import 'package:wger/features/nutrition/providers/nutrition_notifier.dart'; import 'package:wger/features/routines/models/session.dart'; import 'package:wger/features/routines/providers/routines_notifier.dart'; -import 'package:wger/features/weight/models/weight_entry.dart'; -import 'package:wger/features/weight/providers/body_weight_notifier.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/theme/theme.dart'; @@ -78,30 +81,44 @@ class _DashboardCalendarWidgetState extends riverpod.ConsumerState> _buildEvents({ required BuildContext context, - required List entries, + required bool isMetric, required List categories, + required Map> dailyBuckets, required List sessions, required List plans, }) { - final numberFormat = localizedNumberFormat(context); final i18n = AppLocalizations.of(context); final events = >{}; - for (final entry in entries) { - final date = DateFormatLists.format(entry.date); - events.putIfAbsent(date, () => []); - events[date]!.add(Event(EventType.weight, '${numberFormat.format(entry.weight)} kg')); - } - for (final category in categories) { - for (final entry in category.entries) { - final date = DateFormatLists.format(entry.date); + // Body weight entries live in the official category but keep their own + // event type (no category-name prefix) and are shown in the profile's + // display unit; other measurements show in the category unit + final isBodyWeight = category.isOfficialBodyWeight; + final displayUnit = isBodyWeight ? weightDisplayUnit(isMetric) : category.unit; + // the conversion needs the wire unit, the label the localized one + final unitLabel = isBodyWeight ? weightUnit(isMetric, context) : category.unit; + + // One event per day, not per reading: an imported metric writes hundreds + // of samples onto a day, and they describe that day together + final points = chartEntriesForBuckets( + dailyBuckets[category.id] ?? const [], + targetUnit: displayUnit, + categoryUnit: category.unit, + summed: category.metricType.isSummedPerDay, + ); + + for (final point in points) { + final date = DateFormatLists.format(point.date); + final value = unitSuffixed( + measurementValue(context, point.value, displayUnit), + unitLabel, + ); events.putIfAbsent(date, () => []); events[date]!.add( - Event( - EventType.measurement, - '${category.name}: ${numberFormat.format(entry.value)} ${category.unit}', - ), + isBodyWeight + ? Event(EventType.weight, value) + : Event(EventType.measurement, '${category.name}: $value'), ); } } @@ -187,21 +204,27 @@ class _DashboardCalendarWidgetState extends riverpod.ConsumerState>( - value: ref.watch(measurementProvider), + // The categories alone; a card reads its own points, and the latest + // value of a component comes from its own query, see CategoriesCard + value: ref.watch(measurementCategoriesProvider), loggerName: 'DashboardMeasurementWidget', - data: (categoriesList) { + data: (allCategories) { + // Children of multi-value groups are shown inside their parent's card. + // Body weight has its own dashboard widget. + final categoriesList = allCategories + .where((c) => c.parentId == null && !c.isOfficialBodyWeight) + .toList(); + if (categoriesList.isEmpty) { return NothingFound( AppLocalizations.of(context).moreMeasurementEntries, diff --git a/lib/core/widgets/dashboard/widgets/routines.dart b/lib/core/widgets/dashboard/widgets/routines.dart index c326fdd61..871e5bfd1 100644 --- a/lib/core/widgets/dashboard/widgets/routines.dart +++ b/lib/core/widgets/dashboard/widgets/routines.dart @@ -245,6 +245,7 @@ class DetailContentWidget extends StatelessWidget { const Icon(Icons.hotel) else IconButton( + tooltip: AppLocalizations.of(context).gymMode, icon: const Icon(Icons.play_arrow), color: Theme.of(context).colorScheme.primary, onPressed: () { diff --git a/lib/core/widgets/dashboard/widgets/weight.dart b/lib/core/widgets/dashboard/widgets/weight.dart index 976be228a..b3be1d8ef 100644 --- a/lib/core/widgets/dashboard/widgets/weight.dart +++ b/lib/core/widgets/dashboard/widgets/weight.dart @@ -25,12 +25,16 @@ import 'package:wger/core/widgets/error.dart'; import 'package:wger/core/widgets/progress_indicator.dart'; import 'package:wger/features/account/models/user_profile.dart'; import 'package:wger/features/account/providers/user_profile_notifier.dart'; +import 'package:wger/features/measurements/charts/data.dart'; +import 'package:wger/features/measurements/charts/range.dart'; +import 'package:wger/features/measurements/charts/series.dart'; +import 'package:wger/features/measurements/models/measurement_category.dart'; +import 'package:wger/features/measurements/models/unit_conversion.dart'; +import 'package:wger/features/measurements/providers/body_weight_provider.dart'; +import 'package:wger/features/measurements/screens/weight_screen.dart'; import 'package:wger/features/measurements/widgets/charts.dart'; import 'package:wger/features/measurements/widgets/helpers.dart'; -import 'package:wger/features/weight/models/weight_entry.dart'; -import 'package:wger/features/weight/providers/body_weight_notifier.dart'; -import 'package:wger/features/weight/screens/weight_screen.dart'; -import 'package:wger/features/weight/widgets/forms.dart'; +import 'package:wger/features/measurements/widgets/weight_form.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; class DashboardWeightWidget extends ConsumerWidget { @@ -59,21 +63,21 @@ class DashboardWeightWidget extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final entriesAsync = ref.watch(weightEntryProvider); + final categoryAsync = ref.watch(bodyWeightCategoryOnlyProvider); final profileAsync = ref.watch(userProfileProvider); // Composite loading / error / data resolution. We need both providers - // ready before we can render the chart (entries → series, profile → + // ready before we can render the chart (category → series, profile → // unit). Treating them independently with a nested .when() is what gave // us the eternal-spinner bug when fetchProfile() returned null, so we // funnel everything through a single decision tree here. - if (entriesAsync.isLoading || profileAsync.isLoading) { + if (categoryAsync.isLoading || profileAsync.isLoading) { return _shell(context, const BoxedProgressIndicator()); } - if (entriesAsync.hasError) { + if (categoryAsync.hasError) { return _shell( context, - StreamErrorIndicator(entriesAsync.error!, stacktrace: entriesAsync.stackTrace), + StreamErrorIndicator(categoryAsync.error!, stacktrace: categoryAsync.stackTrace), ); } if (profileAsync.hasError) { @@ -91,41 +95,63 @@ class DashboardWeightWidget extends ConsumerWidget { // permanent-looking error; the widget rebuilds once the row lands. return _shell(context, const BoxedProgressIndicator()); } + final category = categoryAsync.value; + if (category == null) { + // The official body weight category is created by the server; it is + // missing only while the initial sync is still running. + return _shell(context, const BoxedProgressIndicator()); + } + + // The chart is drawn from the aggregated query, so the card condenses the + // history in SQL rather than reading an object per entry. Mixed-unit + // entries (kg/lb) are normalized to the profile's display unit + final pointsAsync = chartPointsFor( + ref, + category, + ChartRange.all, + targetUnit: weightDisplayUnit(profile.isMetric), + ); + final points = pointsAsync.value; + if (points == null) { + return _shell(context, const BoxedProgressIndicator()); + } - return _shell(context, _buildContent(context, entriesAsync.value!, profile)); + return _shell(context, _buildContent(context, category, profile, points)); } Widget _buildContent( BuildContext context, - List entriesList, + MeasurementCategory category, UserProfile profile, + List points, ) { - if (entriesList.isEmpty) { + if (points.isEmpty) { return NothingFound( AppLocalizations.of(context).noWeightEntries, AppLocalizations.of(context).newEntry, - WeightForm(), + WeightForm(category), ); } - final (entriesAll, entries7dAvg) = sensibleRange( - entriesList.map((e) => MeasurementChartEntry(e.weight, e.date)).toList(), + final (entriesAll, average) = sensibleRange( + points, + averageDays: category.chartSettings.averageWindow, ); return Column( children: [ SizedBox( height: 200, - child: MeasurementChartWidgetFl( + child: MeasurementChartWidgetFl.singleMeasurement( entriesAll, weightUnit(profile.isMetric, context), - avgs: entries7dAvg, + avgs: average, ), ), - if (entries7dAvg.isNotEmpty) + if (average.isNotEmpty) MeasurementOverallChangeWidget( - entries7dAvg.first, - entries7dAvg.last, + average.first, + average.last, weightUnit(profile.isMetric, context), ), LayoutBuilder( @@ -154,7 +180,7 @@ class DashboardWeightWidget extends ConsumerWidget { FormScreen.routeName, arguments: FormScreenArguments( AppLocalizations.of(context).newEntry, - WeightForm(), + WeightForm(category), ), ); }, diff --git a/lib/core/widgets/datetime_input.dart b/lib/core/widgets/datetime_input.dart index 1dab063dc..e2f3aa745 100644 --- a/lib/core/widgets/datetime_input.dart +++ b/lib/core/widgets/datetime_input.dart @@ -18,6 +18,7 @@ import 'package:flutter/material.dart'; import 'package:wger/core/formatting/formatting.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; /// Read-only field that opens a time picker on tap. /// @@ -221,3 +222,72 @@ class _DateInputWidgetState extends State { ); } } + +/// The two halves of one [DateTime]: a date field above a time field, each +/// reporting the whole moment through [onChanged]. +/// +/// For the entries that are stamped with a single point in time. Where date +/// and time are stored apart (a meal has a time and no date), the two fields +/// are used on their own instead. +class DateTimeInputWidget extends StatefulWidget { + const DateTimeInputWidget({ + required this.value, + required this.onChanged, + this.firstDate, + this.lastDate, + super.key, + }); + + /// The moment both fields show, and the one edits are applied to. + final DateTime value; + + /// Called with the full moment after either half was picked. + final ValueChanged onChanged; + + /// Earliest selectable date, ten years back by default. + final DateTime? firstDate; + + /// Latest selectable date, today by default. + final DateTime? lastDate; + + @override + State createState() => _DateTimeInputWidgetState(); +} + +class _DateTimeInputWidgetState extends State { + /// The moment as far as it has been edited. Kept here because the callers + /// collect what they build without rebuilding, so the value passed in would + /// still be the one the form started with when the second half is picked. + late DateTime _value = widget.value; + + void _report(DateTime value) { + setState(() => _value = value); + widget.onChanged(value); + } + + @override + Widget build(BuildContext context) { + final i18n = AppLocalizations.of(context); + + return Column( + children: [ + DateInputWidget( + value: _value, + labelText: i18n.date, + firstDate: widget.firstDate ?? DateTime(DateTime.now().year - 10), + lastDate: widget.lastDate ?? DateTime.now(), + onChanged: (date) => _report( + _value.copyWith(year: date.year, month: date.month, day: date.day), + ), + ), + TimeInputWidget( + value: TimeOfDay.fromDateTime(_value), + labelText: i18n.time, + onChanged: (time) => _report( + _value.copyWith(hour: time.hour, minute: time.minute, second: 0), + ), + ), + ], + ); + } +} diff --git a/lib/core/widgets/decimal_input.dart b/lib/core/widgets/decimal_input.dart index 625806b08..6a144c2b9 100644 --- a/lib/core/widgets/decimal_input.dart +++ b/lib/core/widgets/decimal_input.dart @@ -17,6 +17,7 @@ */ import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:wger/core/consts.dart'; import 'package:wger/core/formatting/formatting.dart'; import 'package:wger/core/number_input.dart'; @@ -28,7 +29,10 @@ import 'package:wger/l10n/generated/app_localizations.dart'; /// through [onChanged] as a parsed [num], or null when the field is empty. /// Display and parsing always go through the same NumberFormat, so a value can /// never be mis-read because of a decimal-separator mismatch between locales. -class DecimalInputWidget extends StatelessWidget { +/// +/// [value] seeds the field, later changes to it are ignored: what the user +/// typed is what stands. +class DecimalInputWidget extends StatefulWidget { const DecimalInputWidget({ required this.value, required this.onChanged, @@ -37,6 +41,7 @@ class DecimalInputWidget extends StatelessWidget { this.isRequired = false, this.min, this.max, + this.steppers = const [], super.key, }); @@ -61,31 +66,120 @@ class DecimalInputWidget extends StatelessWidget { /// Optional inclusive upper bound. See [min]. final num? max; + /// Step sizes for the quick +/- buttons around the field, biggest first. + /// Empty for a plain field. + final List steppers; + + @override + State createState() => _DecimalInputWidgetState(); +} + +class _DecimalInputWidgetState extends State { + /// Controller rather than `initialValue`, because the steppers write into + /// the field. Seeded in didChangeDependencies, which is where the locale + /// that formats the value is available. + final _controller = TextEditingController(); + bool _seeded = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_seeded) { + _seeded = true; + final value = widget.value; + if (value != null) { + _controller.text = localizedNumberFormat(context).format(value); + } + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + /// Adds [delta] to what the field currently holds, unless that leaves the + /// valid range or the field holds nothing to add to. + void _step(num delta) { + final numberFormat = localizedNumberFormat(context); + final parsed = numberFormat.tryParse(_controller.text); + if (parsed == null) { + return; + } + + final stepped = parsed + delta; + if ((widget.min != null && stepped < widget.min!) || + (widget.max != null && stepped > widget.max!)) { + return; + } + + _controller.text = numberFormat.format(stepped); + // Setting the text does not run the field's onChanged + widget.onChanged(stepped); + } + + /// The quick-change buttons of one side, biggest step outermost. The biggest + /// one is drawn as a circled icon, so the two sizes stay apart at a glance. + List _stepperButtons({required bool plus}) => [ + for (final (index, step) in widget.steppers.indexed) + IconButton( + key: Key('stepper-${plus ? 'plus' : 'minus'}-$index'), + icon: FaIcon( + switch ((index, plus)) { + (0, false) => FontAwesomeIcons.circleMinus, + (0, true) => FontAwesomeIcons.circlePlus, + (_, false) => FontAwesomeIcons.minus, + (_, true) => FontAwesomeIcons.plus, + }, + ), + onPressed: () => _step(plus ? step : -step), + ), + ]; + @override Widget build(BuildContext context) { final i18n = AppLocalizations.of(context); final numberFormat = localizedNumberFormat(context); + final hasSteppers = widget.steppers.isNotEmpty; return TextFormField( - initialValue: value == null ? '' : numberFormat.format(value), - decoration: InputDecoration(labelText: labelText, suffixText: suffixText), + controller: _controller, + decoration: InputDecoration( + labelText: widget.labelText, + suffixText: widget.suffixText, + prefix: hasSteppers + ? Row( + mainAxisSize: MainAxisSize.min, + children: _stepperButtons(plus: false), + ) + : null, + suffix: hasSteppers + ? Row( + mainAxisSize: MainAxisSize.min, + children: _stepperButtons(plus: true).reversed.toList(), + ) + : null, + ), keyboardType: textInputTypeDecimal, inputFormatters: [LocalizedDecimalInputFormatter(numberFormat.symbols.DECIMAL_SEP)], onChanged: (text) { final trimmed = text.trim(); - onChanged(trimmed.isEmpty ? null : numberFormat.tryParse(trimmed)); + widget.onChanged(trimmed.isEmpty ? null : numberFormat.tryParse(trimmed)); }, validator: (text) { final trimmed = text?.trim() ?? ''; if (trimmed.isEmpty) { - return isRequired ? i18n.enterValue : null; + return widget.isRequired ? i18n.enterValue : null; } final parsed = numberFormat.tryParse(trimmed); if (parsed == null) { return i18n.enterValidNumber; } - if (min != null && max != null && (parsed < min! || parsed > max!)) { - return i18n.formMinMaxValues(min!.toInt(), max!.toInt()); + if (widget.min != null && + widget.max != null && + (parsed < widget.min! || parsed > widget.max!)) { + return i18n.formMinMaxValues(widget.min!.toInt(), widget.max!.toInt()); } return null; }, diff --git a/lib/database/converters/json_map_converter.dart b/lib/database/converters/json_map_converter.dart new file mode 100644 index 000000000..e76112efd --- /dev/null +++ b/lib/database/converters/json_map_converter.dart @@ -0,0 +1,46 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'dart:convert'; + +import 'package:drift/drift.dart'; + +/// Maps a JSON object column to a Dart map. +/// +/// PowerSync stores server-side JSON fields as text, so the SQL value is the +/// serialized JSON string. Anything that is not a JSON object (empty string, +/// array, scalar) reads as an empty map. +class JsonMapConverter extends TypeConverter, String> { + const JsonMapConverter(); + + @override + Map fromSql(String fromDb) { + if (fromDb.isEmpty) { + return const {}; + } + try { + final decoded = json.decode(fromDb); + return decoded is Map ? decoded : const {}; + } on FormatException { + return const {}; + } + } + + @override + String toSql(Map value) => json.encode(value); +} diff --git a/lib/database/converters/measurement_chart_type_converter.dart b/lib/database/converters/measurement_chart_type_converter.dart new file mode 100644 index 000000000..d947f4975 --- /dev/null +++ b/lib/database/converters/measurement_chart_type_converter.dart @@ -0,0 +1,35 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2020 - 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:drift/drift.dart'; +import 'package:wger/features/measurements/models/measurement_category.dart'; + +/// Maps a [ChartType] to and from the SQLite string format. +/// +/// The column is nullable while the model field is not: NULL is the server's +/// "no override", which is [ChartType.auto] here. Rows that were synced before +/// the column existed read NULL as well and arrive at the same default. +class MeasurementChartTypeConverter extends TypeConverter { + const MeasurementChartTypeConverter(); + + @override + ChartType fromSql(String? fromDb) => ChartType.fromWire(fromDb); + + @override + String? toSql(ChartType value) => value.wireValue; +} diff --git a/lib/database/powersync/tables/weight.dart b/lib/database/converters/measurement_metric_type_converter.dart similarity index 55% rename from lib/database/powersync/tables/weight.dart rename to lib/database/converters/measurement_metric_type_converter.dart index 82f54ed85..da716261c 100644 --- a/lib/database/powersync/tables/weight.dart +++ b/lib/database/converters/measurement_metric_type_converter.dart @@ -1,40 +1,31 @@ -/* - * This file is part of wger Workout Manager . - * Copyright (c) 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 - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import 'package:drift/drift.dart'; -import 'package:powersync/powersync.dart' as ps; -import 'package:wger/database/converters/utc_datetime_converter.dart'; -import 'package:wger/features/weight/models/weight_entry.dart'; - -@UseRowClass(WeightEntry) -class WeightEntryTable extends Table { - @override - String get tableName => 'weight_weightentry'; - - TextColumn get id => text().clientDefault(() => ps.uuid.v7())(); - RealColumn get weight => real()(); - DateTimeColumn get date => dateTime().nullable().map(const UtcDateTimeConverter())(); -} - -const PowersyncWeightEntryTable = ps.Table( - 'weight_weightentry', - [ - ps.Column.real('weight'), - ps.Column.text('date'), - ], -); +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2020 - 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:drift/drift.dart'; +import 'package:wger/features/measurements/models/measurement_category.dart'; + +/// Maps a [MetricType] to and from the SQLite string format. +class MeasurementMetricTypeConverter extends TypeConverter { + const MeasurementMetricTypeConverter(); + + @override + MetricType fromSql(String fromDb) => MetricType.fromWire(fromDb); + + @override + String toSql(MetricType value) => value.wireValue; +} diff --git a/lib/database/powersync/database.dart b/lib/database/powersync/database.dart index 46f25cf54..5cf338c0a 100644 --- a/lib/database/powersync/database.dart +++ b/lib/database/powersync/database.dart @@ -25,6 +25,9 @@ import 'package:wger/core/language.dart'; import 'package:wger/core/license.dart'; import 'package:wger/database/converters/date_only_text_converter.dart'; import 'package:wger/database/converters/exercise_image_style_converter.dart'; +import 'package:wger/database/converters/json_map_converter.dart'; +import 'package:wger/database/converters/measurement_chart_type_converter.dart'; +import 'package:wger/database/converters/measurement_metric_type_converter.dart'; import 'package:wger/database/converters/time_of_day_converter.dart'; import 'package:wger/database/converters/utc_datetime_converter.dart'; import 'package:wger/database/converters/workout_impression_converter.dart'; @@ -51,7 +54,6 @@ import 'package:wger/features/routines/models/repetition_unit.dart'; import 'package:wger/features/routines/models/routine.dart'; import 'package:wger/features/routines/models/session.dart'; import 'package:wger/features/routines/models/weight_unit.dart'; -import 'package:wger/features/weight/models/weight_entry.dart'; import 'powersync.dart'; import 'tables/exercise.dart'; @@ -63,7 +65,6 @@ import 'tables/measurements.dart'; import 'tables/nutrition.dart'; import 'tables/routines.dart'; import 'tables/user_profile.dart'; -import 'tables/weight.dart'; part 'database.g.dart'; @@ -88,9 +89,6 @@ part 'database.g.dart'; ExerciseImageTable, ExerciseVideoTable, - // Body weight - WeightEntryTable, - // Measurements MeasurementCategoryTable, MeasurementEntryTable, diff --git a/lib/database/powersync/database.g.dart b/lib/database/powersync/database.g.dart index 18dbe7b23..2f706ad44 100644 --- a/lib/database/powersync/database.g.dart +++ b/lib/database/powersync/database.g.dart @@ -4674,12 +4674,12 @@ class ExerciseVideoTableCompanion extends UpdateCompanion