Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
150 commits
Select commit Hold shift + click to select a range
a92897b
feat: add automatic weight sync from Apple Health and Google Health C…
JohnWeidner Mar 27, 2026
7bab9b6
feat: add unit label to weight form and convert health sync values
JohnWeidner Mar 27, 2026
bed1416
Merge branch 'master' into feat/health-platform-weight-sync
JohnWeidner Mar 27, 2026
627b8b9
fix: unit conversion on enable, dashboard refresh, and permission check
JohnWeidner Mar 27, 2026
2f10ee9
refactor: simplify health sync code after review
JohnWeidner Mar 27, 2026
86246ac
refactor: use l10n for health sync user-facing strings
JohnWeidner Mar 27, 2026
a4d03bc
feat: import all health history instead of last 30 days
JohnWeidner Mar 27, 2026
e1968d0
fix: request historical data access from Health Connect
JohnWeidner Mar 27, 2026
7001bca
feat(measurements): implement measurement groups, dynamic formula eva…
pankaj-basnet May 16, 2026
38c0a16
adjust code files as per backend measurement entryurl endpoint
pankaj-basnet May 19, 2026
f9b9885
feat(weight): add time-range selector to body weight chart
Bhavit16 Jun 24, 2026
ee084f1
Merge branch 'pr-1156' into feature/health-sync
rolandgeider Jul 2, 2026
18b745f
Start reworking the sync logic
rolandgeider Jul 2, 2026
01c13b1
Add health repository
rolandgeider Jul 2, 2026
84d0de7
feat(measurements): introduce MetricType enum and category properties
pankaj-basnet Jul 2, 2026
5d1f037
feat(measurements): add metric type selection and detail view support
pankaj-basnet Jul 3, 2026
6c3373f
feat(measurements): add metricType to category schema
pankaj-basnet Jul 4, 2026
5ff9d25
feat(charts): add smoothed trendline to measurement charts
pankaj-basnet Jul 4, 2026
bad4993
Merge branch 'feature/health-sync' into feat/healthsync
rolandgeider Jul 9, 2026
4250784
Cleanup, translations and small tests
rolandgeider Jul 9, 2026
a7a31ba
Merge pull request #1251 from pankaj-basnet/feat/healthsync
rolandgeider Jul 9, 2026
23a792f
Merge branch 'feature/health-sync' into feature/dynamic-measurements-…
rolandgeider Jul 9, 2026
8aac3da
Add workaround for swift package manager for the health package
rolandgeider Jul 10, 2026
066d643
Refactor the data model for multi-value categories
rolandgeider Jul 10, 2026
29032de
feat(measurements): add order field to MeasurementCategory model and …
pankaj-basnet Jul 10, 2026
8ae054d
Merge branch 'feature/health-sync' into feat/healthsync--sort-measure…
rolandgeider Jul 10, 2026
cbb960b
Filter out sub-categories, fix off-by-one index
rolandgeider Jul 10, 2026
3fc33e1
Add multi-value measurements to the test data
rolandgeider Jul 10, 2026
55cdd64
Fix trigger for CI
rolandgeider Jul 10, 2026
c088af2
Merge pull request #1261 from pankaj-basnet/feat/healthsync--sort-mea…
rolandgeider Jul 10, 2026
e81bb99
test(measurements): add unit tests for chart rendering, aggregation, …
pankaj-basnet Jul 12, 2026
5c129bb
test(measurements): add GroupMeasurementEntryForm widget tests and ad…
pankaj-basnet Jul 13, 2026
432d9f1
feat(measurements): add category form test , update categories screen…
pankaj-basnet Jul 13, 2026
b6c83ff
Rename files so they match the ones they test
rolandgeider Jul 17, 2026
5a65b84
Merge pull request #1266 from pankaj-basnet/feat/tests--measurements-…
rolandgeider Jul 17, 2026
f0a088e
Round imported health values to match backend and lowercase external ids
rolandgeider Jul 10, 2026
7ebb97c
Fix source default, hide health settings section when unavailable
rolandgeider Jul 10, 2026
3bea05e
Read health data with an overlap window to catch backdated records
rolandgeider Jul 10, 2026
6d08c0a
Notify the user with a snackbar when health permissions are denied
rolandgeider Jul 10, 2026
31153a2
Update translations
rolandgeider Jul 10, 2026
d8408aa
Convert HealthSyncState to freezed
rolandgeider Jul 10, 2026
6cb489a
Fix iOS health usage description
rolandgeider Jul 11, 2026
a9e2950
Add some more tests
rolandgeider Jul 11, 2026
45aeda7
Merge branch 'master' into feature/health-sync
rolandgeider Jul 28, 2026
f13ef5f
Merge branch 'master' into feat/weight-chart-range
rolandgeider Jul 28, 2026
e1a613d
Merge branch 'feat/weight-chart-range' into feature/health-sync
rolandgeider Jul 28, 2026
41623e3
Merge remote-tracking branch 'origin/master' into feature/health-sync
rolandgeider Jul 30, 2026
b86c1e7
Store body weight in the official measurements category
rolandgeider Jul 30, 2026
6d0352a
Track the entry unit in extra_data and convert on display
rolandgeider Jul 30, 2026
488338d
Keep the health sync watermark while a metric is skipped
rolandgeider Jul 30, 2026
cc22ac6
Do not offer the body weight metric type for new categories
rolandgeider Jul 30, 2026
ea8c2a8
Cleanups
rolandgeider Jul 30, 2026
1b87fd2
Show the weight form bounds rounded inwards
rolandgeider Jul 30, 2026
d1c7333
Bump app version and min required server version
rolandgeider Jul 31, 2026
01615fb
Import blood pressure readings into a measurement group
rolandgeider Jul 31, 2026
41ceee9
Import resting heart rate
rolandgeider Jul 31, 2026
3a4d557
Import sleep duration as a daily total
rolandgeider Jul 31, 2026
a44aafa
Bump health bridge commit
rolandgeider Jul 31, 2026
a27ead1
Key daily health aggregates with a UUID
rolandgeider Jul 31, 2026
fc06377
Key health entries with UUIDs the server accepts
rolandgeider Jul 31, 2026
8ff1a7b
Fix x axis labels on measurement bar charts
rolandgeider Jul 31, 2026
5178bb5
Let a measurement chart draw several named series
rolandgeider Jul 31, 2026
a8f610c
Chart the components of a measurement group together
rolandgeider Jul 31, 2026
9561f95
Show the daily range of aggregated measurements as a band
rolandgeider Jul 31, 2026
5cbd71c
Draw a group reading as one bar spanning its components
rolandgeider Jul 31, 2026
7b0f6ee
Size chart marks by density and condense dense series
rolandgeider Jul 31, 2026
abd2959
Compute the moving average in a single pass
rolandgeider Jul 31, 2026
4686a83
Load only the watched category and its components
rolandgeider Jul 31, 2026
3b8c4be
Band only the measured series, not its average
rolandgeider Jul 31, 2026
de9ca4b
Convert aggregate bounds along with their value
rolandgeider Jul 31, 2026
557eaa2
Keep the group card readable when readings are not paired
rolandgeider Jul 31, 2026
d61e833
Dedupe joined entries with a set instead of a scan
rolandgeider Jul 31, 2026
140b810
Draw nutrition plan periods as bands instead of per-plan charts
rolandgeider Jul 31, 2026
458b3d5
Show the weight development on the nutrition plan detail screen
rolandgeider Jul 31, 2026
dbd1987
Slice the 30-day trend from the full history instead of recomputing it
rolandgeider Jul 31, 2026
765a90f
Derive the shared chart series once per build
rolandgeider Jul 31, 2026
f217317
Update test data for screenshot tests
rolandgeider Jul 31, 2026
ed67ff7
Let the user pick the chart range for measurements too
rolandgeider Jul 31, 2026
4a98f61
Improve translations
rolandgeider Jul 31, 2026
d2ad8e9
Remove unused strings
rolandgeider Jul 31, 2026
7a66c21
Keep chart y-axis labels on a single line
rolandgeider Jul 31, 2026
59d8b09
Show the health sync status and allow syncing manually
rolandgeider Jul 31, 2026
4f7fde6
Tell the user when the health platform withholds its data
rolandgeider Jul 31, 2026
aab7646
Offer installing Health Connect instead of hiding the sync
rolandgeider Jul 31, 2026
db2c2c5
Derive the ids of typed measurement categories
rolandgeider Aug 1, 2026
655d3b7
Read the full history when a metric lost its category
rolandgeider Aug 1, 2026
830a03a
Declare every imported type in the Android manifest
rolandgeider Aug 1, 2026
c1c59ed
Give debug builds their own name and launcher colour
rolandgeider Aug 1, 2026
78a0d9f
Ask the health platform for permissions per type
rolandgeider Aug 1, 2026
5f158c7
Give each metric the read window its density affords
rolandgeider Aug 2, 2026
3a26357
Import the sleep stages, and merge their intervals
rolandgeider Aug 2, 2026
e1b5f13
Chart a group as a whole, and let it be opened
rolandgeider Aug 2, 2026
2d74480
Read only the entries the range charts
rolandgeider Aug 2, 2026
8480c37
Mirror the per-metric value limits from the server
rolandgeider Aug 2, 2026
d338c45
Bound the body weight query by the chart range
rolandgeider Aug 2, 2026
6cabbeb
Move the body weight provider next to the measurements
rolandgeider Aug 2, 2026
6d1b779
Dissolve WeightOverview into EntriesList
rolandgeider Aug 2, 2026
78e908d
Show typed categories under their translated name
rolandgeider Aug 2, 2026
2055480
Let the user pick the chart of a measurement category
rolandgeider Aug 2, 2026
b4ec838
Do not offer a chart type where it has no effect
rolandgeider Aug 2, 2026
86f4463
Do not offer a chart type where it has no effect
rolandgeider Aug 2, 2026
d237876
Attribute a night to its wake day by the calendar
rolandgeider Aug 3, 2026
e249812
Stop an empty metric from forcing a full read every time
rolandgeider Aug 3, 2026
53a7baa
Start the read window where the metric's day starts
rolandgeider Aug 3, 2026
878d34c
Read only the body weight category, not every category
rolandgeider Aug 3, 2026
4b356f8
Pick the metric instead of typing it into a form
rolandgeider Aug 3, 2026
d4e28bf
Cap the metric picker's height
rolandgeider Aug 3, 2026
d93794d
Point the weight screen test at the method the screen calls
rolandgeider Aug 3, 2026
ab5331b
Fix tests
rolandgeider Aug 4, 2026
e68bde2
Merge branch 'master' into feature/health-sync
rolandgeider Aug 4, 2026
47f2676
Don't link a symbol the file does not import
rolandgeider Aug 4, 2026
cecdd1c
Read durations in hours and minutes
rolandgeider Aug 4, 2026
ed3d30a
Add a weekly change (delta) chart type
rolandgeider Aug 5, 2026
4aed48d
Read duration deltas in hours and minutes
rolandgeider Aug 5, 2026
cd47975
Add distribution histogram chart type
rolandgeider Aug 5, 2026
82be49c
Add per-category chart settings (trend, average window)
rolandgeider Aug 5, 2026
1fd6b50
Replace the 30-day chart with a one-month range
rolandgeider Aug 5, 2026
449beec
Keep the measurement screens on screen while a range loads
rolandgeider Aug 5, 2026
0d08f41
Updated goldens
rolandgeider Aug 6, 2026
c029894
Keep the measurement screens on screen while a range loads
rolandgeider Aug 6, 2026
c490bdb
Aggregate measurement chart points in SQL
rolandgeider Aug 6, 2026
34f3d1d
Read a group's chart points from one aggregated query
rolandgeider Aug 6, 2026
52eab30
Read the measurement lists a page at a time
rolandgeider Aug 6, 2026
459defa
Drop a health type whole when one of its windows fails
rolandgeider Aug 6, 2026
98ff130
Split the measurement charts into four files
rolandgeider Aug 6, 2026
a0fddd6
Tidy up the measurement and health helpers
rolandgeider Aug 6, 2026
1ee3588
Read the health importer's dedup keys from the database
rolandgeider Aug 6, 2026
473ffbe
Drop entries from the measurement category model
rolandgeider Aug 6, 2026
3c2a800
Move the health import into a service of its own
rolandgeider Aug 6, 2026
13e1197
Give every health metric its own watermark
rolandgeider Aug 6, 2026
3c4cc4e
Move the chart maths out of the widgets
rolandgeider Aug 7, 2026
0329f3f
Give the measurement entry form a draft
rolandgeider Aug 7, 2026
4f22659
Give the distribution chart the height it is drawn in
rolandgeider Aug 7, 2026
3dc9789
Activate the rest of the metric types
rolandgeider Aug 7, 2026
3855dbc
Import blood oxygen from the health platforms
rolandgeider Aug 7, 2026
9cd9aaa
Import lean body mass from the health platforms
rolandgeider Aug 7, 2026
d517e1f
Drop the "count" unit from step categories
rolandgeider Aug 7, 2026
7954377
Drop the chart title where the screen already says it
rolandgeider Aug 7, 2026
d6de435
Make the measurements a tab of their own, led by the weight
rolandgeider Aug 7, 2026
62e06d6
Share the quick steppers and the date-time pair
rolandgeider Aug 7, 2026
2ec683d
Keep the chart tooltips inside the chart
rolandgeider Aug 7, 2026
251689c
Rebuild the measurements overview as a tile grid
rolandgeider Aug 8, 2026
62f929c
Rebuild the screenshot scene for the tile grid
rolandgeider Aug 8, 2026
002ff54
Share the chart range across the measurement screens
rolandgeider Aug 8, 2026
b5d092c
Persist the shared chart range
rolandgeider Aug 9, 2026
d30482a
Default the measurement charts to one month
rolandgeider Aug 9, 2026
bb7ed8f
Say how long ago a tile's value was measured
rolandgeider Aug 9, 2026
df2791a
Keep pre-2.7 measurement rows readable
rolandgeider Aug 9, 2026
520504a
Merge branch 'master' into feature/health-sync
rolandgeider Aug 10, 2026
a428aa1
Updated goldens
rolandgeider Aug 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/analyze.yml
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
10 changes: 9 additions & 1 deletion android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"
}
}
}
Expand Down
6 changes: 6 additions & 0 deletions android/app/src/debug/res/values/colors.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Background of the adaptive launcher icon. Overrides the release blue so
a debug build is recognisable at a glance next to the installed app -->
<color name="ic_launcher_background">#e63946</color>
</resources>
41 changes: 40 additions & 1 deletion android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,34 @@
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.INTERNET" />

<!-- Health Connect permissions for the imported measurement types. Must
list every type in enabledHealthMetrics: Health Connect cannot grant
what is not declared here, so a missing entry silently costs that
metric its import. Guarded by health_manifest_test.dart -->
<uses-permission android:name="android.permission.health.READ_WEIGHT"/>
<uses-permission android:name="android.permission.health.READ_BODY_FAT"/>
<uses-permission android:name="android.permission.health.READ_LEAN_BODY_MASS"/>
<uses-permission android:name="android.permission.health.READ_HEIGHT"/>
<uses-permission android:name="android.permission.health.READ_BLOOD_PRESSURE"/>
<uses-permission android:name="android.permission.health.READ_HEART_RATE"/>
<uses-permission android:name="android.permission.health.READ_RESTING_HEART_RATE"/>
<uses-permission android:name="android.permission.health.READ_OXYGEN_SATURATION"/>
<uses-permission android:name="android.permission.health.READ_SLEEP"/>
<uses-permission android:name="android.permission.health.READ_STEPS"/>
<uses-permission android:name="android.permission.health.READ_DISTANCE"/>
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED"/>
<uses-permission android:name="android.permission.health.READ_HEALTH_DATA_HISTORY"/>

<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
<!-- Health Connect -->
<package android:name="com.google.android.apps.healthdata" />
<intent>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent>
</queries>

<supports-screens
Expand All @@ -24,7 +47,7 @@

<application
android:name="${applicationName}"
android:label="wger"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:enableOnBackInvokedCallback="true"
android:networkSecurityConfig="@xml/network_security_config"
Expand Down Expand Up @@ -66,8 +89,24 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="wger" android:host="app-auth" />
</intent-filter>

<!-- Health Connect permissions rationale -->
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
</activity>

<!-- Required for Health Connect on Google Play -->
<activity-alias
android:name="ViewPermissionUsageActivity"
android:exported="true"
android:targetActivity=".MainActivity"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity-alias>

<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
Expand Down
4 changes: 2 additions & 2 deletions android/app/src/main/kotlin/de/wger/flutter/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -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() {
}
6 changes: 3 additions & 3 deletions ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@
<true/>
<key>NSCameraUsageDescription</key>
<string>Workout photos</string>
<key>NSHealthShareUsageDescription</key>
<string>wger reads your health data to automatically import body measurements such as weight, body fat, blood pressure, heart rate and blood oxygen</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
Expand Down
2 changes: 2 additions & 0 deletions ios/Runner/Runner.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.healthkit</key>
<true/>
</dict>
</plist>
5 changes: 4 additions & 1 deletion lib/core/consts.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
120 changes: 119 additions & 1 deletion lib/core/formatting/formatting.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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
Expand All @@ -16,8 +16,11 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

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.
Expand All @@ -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());
}
Loading