diff --git a/.github/scripts/portrait-avd.sh b/.github/scripts/portrait-avd.sh new file mode 100755 index 000000000..caf84da96 --- /dev/null +++ b/.github/scripts/portrait-avd.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# +# Turns the AVD panel portrait, as the pre-emulator-launch-script of +# android-emulator-runner. The tablet panels are landscape and rely on +# hw.initialOrientation, which the emulator ignores with -no-window. + +set -euo pipefail + +CONFIG="$HOME/.android/avd/${AVD_NAME:-test}.avd/config.ini" + +width=$(grep '^hw.lcd.width=' "$CONFIG" | cut -d= -f2) +height=$(grep '^hw.lcd.height=' "$CONFIG" | cut -d= -f2) + +# config.ini is an unordered list of key=value, so dropping the old line and +# appending the new one is enough, and unlike sed -i that behaves the same +# on a runner and on a Mac +set_property() { + grep -v "^$1=" "$CONFIG" > "$CONFIG.tmp" || true + echo "$1=$2" >> "$CONFIG.tmp" + mv "$CONFIG.tmp" "$CONFIG" +} + +# Short edge first, which leaves an already portrait panel as it is +set_property hw.lcd.width "$((width < height ? width : height))" +set_property hw.lcd.height "$((width < height ? height : width))" + +grep -E '^hw\.lcd\.(width|height)' "$CONFIG" diff --git a/.github/scripts/take-screenshots.sh b/.github/scripts/take-screenshots.sh new file mode 100755 index 000000000..b05009dc4 --- /dev/null +++ b/.github/scripts/take-screenshots.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# +# Drives the screenshot test in blocks of languages, a whole run's screenshots +# do not survive the hand-over to the driver. See integration_test/README.md. + +set -euo pipefail + +blocks=5 +attempts=3 +block_timeout=10m + +# Homebrew installs coreutils under the g-prefixed names only +timeout=$(command -v timeout || command -v gtimeout) || { + echo "Found neither timeout nor gtimeout, install coreutils" + exit 1 +} + +device_type=${1:?Usage: take-screenshots.sh [flutter drive args]} +shift + +# flutter drive gives up within seconds on an offline device, so wait for it +# to come back instead of burning the next attempt on it +wait_for_device() { + adb devices | tail -n +2 | grep -q . || return 0 + adb reconnect offline || true + "$timeout" 120s adb wait-for-device shell \ + 'while [ "$(getprop sys.boot_completed)" != 1 ]; do sleep 2; done' || true +} + +for block in $(seq "$blocks"); do + for attempt in $(seq "$attempts"); do + status=0 + # Both the Xcode build and the driver's connect retry forever on their own. + # timeout signals the whole process group, so no dart child survives. + "$timeout" --kill-after=30s "$block_timeout" flutter drive \ + --driver=test_driver/screenshot_driver.dart \ + --target=integration_test/make_screenshots_test.dart \ + --dart-define=DEVICE_TYPE="$device_type" \ + --dart-define=LANGUAGES="$block/$blocks" \ + "$@" || status=$? + + if [ "$status" -eq 0 ]; then + break + fi + + if [ "$attempt" -eq "$attempts" ]; then + echo "Block $block still fails after $attempts attempts, giving up" + exit 1 + fi + + # Usually the device went offline while handing the images over + echo "Block $block failed with $status, attempt $((attempt + 1)) of $attempts" + wait_for_device + done +done 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/build-linux.yml b/.github/workflows/build-linux.yml index 72c57b692..3124bab7e 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -97,8 +97,8 @@ jobs: - name: Push updated config to flathub repository run: | - git config user.name "github-actions" - git config user.email "github-actions@github.com" + git config user.name git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git checkout -b release-${{ inputs.ref }} git add -A git commit -m "Update to ${{ inputs.ref }}" diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index 5f7205ea9..4daecacaa 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -52,8 +52,8 @@ jobs: - name: Tag release and commit pubspec run: | - git config user.name Github-Actions - git config user.email github-actions@github.com + git config user.name git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add pubspec.yaml flatpak/de.wger.flutter.metainfo.xml git commit -m "Bump version to ${{ inputs.app_version }}" git tag ${{ inputs.app_version }} 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/.github/workflows/linter.yml.bak b/.github/workflows/linter.yml.bak index e3665a58c..34742cb74 100644 --- a/.github/workflows/linter.yml.bak +++ b/.github/workflows/linter.yml.bak @@ -31,8 +31,8 @@ jobs: - name: Push a commit with the changed files continue-on-error: true run: | - git config user.name Github-actions - git config user.email github-actions@github.com + git config user.name git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add . git commit -m "Automatic linting" git push diff --git a/.github/workflows/screenshots.yml b/.github/workflows/screenshots.yml index 2cc55955d..98083c6bc 100644 --- a/.github/workflows/screenshots.yml +++ b/.github/workflows/screenshots.yml @@ -12,8 +12,26 @@ permissions: jobs: screenshots_apple: - name: 'iOS' + name: 'Apple - ${{ matrix.device.name }}' runs-on: macos-latest + # A clean run takes about 30 minutes, the script may retry a block twice + timeout-minutes: 75 + strategy: + # The jobs write into separate folders and a flaky one should not take + # the healthy ones down with it + fail-fast: false + matrix: + device: + # Apple only needs the largest device per family, the smaller ones + # are scaled down by App Store Connect + - name: "iPhone 6.9in" + simulator: iPhone 17 Pro Max + device_type: iOSPhoneBig + folder: iPhone 6.9 + - name: "iPad 13in" + simulator: iPad Pro 13-inch + device_type: iOSTabletBig + folder: iPad 13 steps: - uses: actions/checkout@v7 @@ -23,34 +41,50 @@ jobs: - name: Boot iOS simulator id: boot run: | - SIMULATOR=$(xcrun simctl list devices available | awk -F '[()]' '/iPhone 17/{print $2; exit}') + # Match the UDID explicitly, some device names contain parentheses themselves + SIMULATOR=$(xcrun simctl list devices available \ + | grep -m1 '${{ matrix.device.simulator }}' \ + | grep -oE '[0-9A-F]{8}(-[0-9A-F]{4}){3}-[0-9A-F]{12}') + if [ -z "$SIMULATOR" ]; then + echo "No available simulator matching '${{ matrix.device.simulator }}'" + xcrun simctl list devices available + exit 1 + fi echo "SIMULATOR=$SIMULATOR" >> $GITHUB_ENV xcrun simctl boot "$SIMULATOR" || true open -a Simulator || true n=0; until xcrun simctl bootstatus "$SIMULATOR" -b || [ $n -ge 60 ]; do sleep 1; n=$((n+1)); done + # The image ships no GNU coreutils, take-screenshots.sh needs gtimeout + - name: Install coreutils + run: brew install coreutils + - name: Generate screenshots - run: | - flutter drive --driver=test_driver/screenshot_driver.dart --target=integration_test/make_screenshots_test.dart --dart-define=DEVICE_TYPE=iOSPhoneBig -d "$SIMULATOR" + run: .github/scripts/take-screenshots.sh ${{ matrix.device.device_type }} -d "$SIMULATOR" - name: Upload screenshots uses: actions/upload-artifact@v7 with: - name: screenshots-ios - path: fastlane/metadata/ios/**/images/iPhone 6.9/*.png + name: screenshots-ios-${{ matrix.device.device_type }} + path: fastlane/metadata/ios/**/images/${{ matrix.device.folder }}/*.png screenshots_android: name: 'Android - ${{ matrix.device.name }}' runs-on: ubuntu-latest + # A clean run takes about 25 minutes, the script may retry a block twice + timeout-minutes: 60 strategy: + # The jobs write into separate folders and a flaky one should not take + # the healthy ones down with it + fail-fast: false matrix: device: - name: "Phone" - profile: pixel_7_pro + profile: pixel_10 device_type: androidPhone folder: phoneScreenshots - name: "Tablet 7in" - profile: 7in WSVGA (Tablet) + profile: small_tablet device_type: androidTabletSmall folder: sevenInchScreenshots - name: "Tablet 10in" @@ -70,7 +104,23 @@ jobs: sudo udevadm trigger --name-match=kvm - name: Accept Android SDK licenses - run: yes | sdkmanager --licenses || true + run: yes | "${ANDROID_HOME:?}/cmdline-tools/latest/bin/sdkmanager" --licenses || true + + # The runner image ships command line tools 12.0, which does not have the + # device definitions we need. + - name: Install current SDK command line tools + run: | + SDK="${ANDROID_HOME:?}/cmdline-tools" + yes | "$SDK/latest/bin/sdkmanager" --install "cmdline-tools;22.0" + rm -rf "$SDK/latest" + mv "$SDK/22.0" "$SDK/latest" + "$SDK/latest/bin/sdkmanager" --version + + # Fail here rather than after the system image download + - name: Check the device profile exists + run: | + "${ANDROID_HOME:?}/cmdline-tools/latest/bin/avdmanager" list device --compact \ + | grep -qx '${{ matrix.device.profile }}' # Free up disk space on the runner. List taken from # https://github.com/flathub-infra/vorarbeiter/blob/main/.github/workflows/build.yml @@ -109,15 +159,16 @@ jobs: target: google_apis arch: x86_64 profile: ${{ matrix.device.profile }} - emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + emulator-options: -no-snapshot-save -no-window -no-metrics -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true - script: | - flutter drive --driver=test_driver/screenshot_driver.dart --target=integration_test/make_screenshots_test.dart --dart-define=DEVICE_TYPE=${{ matrix.device.device_type }} + pre-emulator-launch-script: .github/scripts/portrait-avd.sh + # The action runs every line of this as its own sh -c, so no loops here + script: .github/scripts/take-screenshots.sh ${{ matrix.device.device_type }} - name: Upload screenshots uses: actions/upload-artifact@v7 with: - name: screenshots-android-${{ matrix.device.folder }} + name: screenshots-android-${{ matrix.device.device_type }} path: fastlane/metadata/android/**/images/${{ matrix.device.folder }}/*.png commit_screenshots: @@ -141,17 +192,19 @@ jobs: - name: Merge artifacts into fastlane/metadata run: | set -euo pipefail - rsync --archive --checksum --itemize-changes "screenshots/screenshots-android-phoneScreenshots/" fastlane/metadata/android/ - rsync --archive --checksum --itemize-changes "screenshots/screenshots-android-sevenInchScreenshots/" fastlane/metadata/android/ - rsync --archive --checksum --itemize-changes "screenshots/screenshots-android-tenInchScreenshots/" fastlane/metadata/android/ - rsync --archive --checksum --itemize-changes "screenshots/screenshots-ios/" fastlane/metadata/ios/ + for artifact in screenshots/screenshots-android-*/; do + rsync --archive --checksum --itemize-changes "$artifact" fastlane/metadata/android/ + done + for artifact in screenshots/screenshots-ios-*/; do + rsync --archive --checksum --itemize-changes "$artifact" fastlane/metadata/ios/ + done - name: Commit screenshots to a dated branch env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add fastlane/metadata/android fastlane/metadata/ios || true if git diff --staged --quiet; then echo "No new or changed screenshots to commit." 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..e915ca00c 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,58 @@ package de.wger.flutter -import io.flutter.embedding.android.FlutterActivity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import io.flutter.embedding.android.FlutterFragmentActivity -class MainActivity: FlutterActivity() { +class MainActivity: FlutterFragmentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Started only to show the policy, so there is nothing to come back to + if (isPrivacyPolicyRequest(intent) && openPrivacyPolicy()) { + finish() + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + + // The app was already running, it stays open behind the browser + if (isPrivacyPolicyRequest(intent)) { + openPrivacyPolicy() + } + } + + private fun isPrivacyPolicyRequest(intent: Intent?): Boolean { + val action = intent?.action ?: return false + return action in PRIVACY_POLICY_ACTIONS + } + + /** Returns false when no browser took the intent, so the app stays open. */ + private fun openPrivacyPolicy(): Boolean { + return try { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRIVACY_POLICY_URL))) + true + } catch (e: ActivityNotFoundException) { + false + } + } + + companion object { + // Health Connect starts the app with these when the user asks for its + // privacy policy: from the permission dialog, and from the system + // settings on Android 14 and later. Both are declared in the manifest. + private val PRIVACY_POLICY_ACTIONS = setOf( + "androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE", + "android.intent.action.VIEW_PERMISSION_USAGE", + ) + + // The page of PRIVACY_POLICY_URL in lib/core/consts.dart, at the + // section describing the health import + private const val PRIVACY_POLICY_URL = + "https://wger.de/software/terms-of-service#health-data" + } } diff --git a/fastlane/metadata/android/ar/images/phoneScreenshots/06 - weight.png b/fastlane/metadata/android/ar/images/phoneScreenshots/06 - weight.png deleted file mode 100644 index e1326c3f6..000000000 --- a/fastlane/metadata/android/ar/images/phoneScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:01f66cebfa4d565ec94c1146e417a509a211096d1290f5f2db010c326e3ca903 -size 218281 diff --git a/fastlane/metadata/android/ar/images/sevenInchScreenshots/06 - weight.png b/fastlane/metadata/android/ar/images/sevenInchScreenshots/06 - weight.png deleted file mode 100644 index 81ec52a1f..000000000 --- a/fastlane/metadata/android/ar/images/sevenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:39cea85d70313976f170cb5d9b16c4d6e86f9d8e1add6550a1667fcc2d3055bb -size 53482 diff --git a/fastlane/metadata/android/ar/images/tenInchScreenshots/06 - weight.png b/fastlane/metadata/android/ar/images/tenInchScreenshots/06 - weight.png deleted file mode 100644 index e2d0e4928..000000000 --- a/fastlane/metadata/android/ar/images/tenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:61dccd251a0451278a96aa6380b7d0a97c458b00e667cc64e36dcfdd8e3f4712 -size 142935 diff --git a/fastlane/metadata/android/ca/images/phoneScreenshots/06 - weight.png b/fastlane/metadata/android/ca/images/phoneScreenshots/06 - weight.png deleted file mode 100644 index 5377dd533..000000000 --- a/fastlane/metadata/android/ca/images/phoneScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:025f6df558768eb1fc1f6b57c327867da8e00dded7679d1df1f3a31600aa55f3 -size 187951 diff --git a/fastlane/metadata/android/ca/images/sevenInchScreenshots/06 - weight.png b/fastlane/metadata/android/ca/images/sevenInchScreenshots/06 - weight.png deleted file mode 100644 index 9573d0290..000000000 --- a/fastlane/metadata/android/ca/images/sevenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:489c56691e2d2b976f2e0a5861a55e8a9ca37676149a2d745bbb30afdcd602c0 -size 52491 diff --git a/fastlane/metadata/android/ca/images/tenInchScreenshots/06 - weight.png b/fastlane/metadata/android/ca/images/tenInchScreenshots/06 - weight.png deleted file mode 100644 index caf640f9e..000000000 --- a/fastlane/metadata/android/ca/images/tenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:15ed751b0e8ebf81357e51d388973a8ad967c337f2a10bb69a94dfe2cd05dbc3 -size 131743 diff --git a/fastlane/metadata/android/cs-CZ/images/phoneScreenshots/06 - weight.png b/fastlane/metadata/android/cs-CZ/images/phoneScreenshots/06 - weight.png deleted file mode 100644 index 3d9cc5226..000000000 --- a/fastlane/metadata/android/cs-CZ/images/phoneScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:46a66b00f5b2d725ffba8a70b7d495609655e00531e59d4a5e872d7b8e71a973 -size 186422 diff --git a/fastlane/metadata/android/cs-CZ/images/sevenInchScreenshots/06 - weight.png b/fastlane/metadata/android/cs-CZ/images/sevenInchScreenshots/06 - weight.png deleted file mode 100644 index 174fe7faf..000000000 --- a/fastlane/metadata/android/cs-CZ/images/sevenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:79a5935dc81bd05a8945bd84b182e970ead19f9e31fbaa4463b840e86c6613c3 -size 50568 diff --git a/fastlane/metadata/android/cs-CZ/images/tenInchScreenshots/06 - weight.png b/fastlane/metadata/android/cs-CZ/images/tenInchScreenshots/06 - weight.png deleted file mode 100644 index f6809c691..000000000 --- a/fastlane/metadata/android/cs-CZ/images/tenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a802fcc40bdecb47f6c6b65d736ea5d8d741ac9706ef2b41b19e00c99c0fe78a -size 127564 diff --git a/fastlane/metadata/android/de-DE/images/phoneScreenshots/01 - dashboard.png b/fastlane/metadata/android/de-DE/images/phoneScreenshots/01 - dashboard.png index dd5840eb7..1cd6a63bd 100644 --- a/fastlane/metadata/android/de-DE/images/phoneScreenshots/01 - dashboard.png +++ b/fastlane/metadata/android/de-DE/images/phoneScreenshots/01 - dashboard.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7e49fdbcc47934120b3be3233aa5162dd223390f98db6ac8db645d9e607bcf61 -size 298290 +oid sha256:f7d4871d99b1372c33304b4dd7e14c80b696a00523bec7c5397ebae7b388c3e1 +size 494801 diff --git a/fastlane/metadata/android/de-DE/images/phoneScreenshots/02 - workout detail.png b/fastlane/metadata/android/de-DE/images/phoneScreenshots/02 - workout detail.png index 8dd4f003b..13294c1a2 100644 --- a/fastlane/metadata/android/de-DE/images/phoneScreenshots/02 - workout detail.png +++ b/fastlane/metadata/android/de-DE/images/phoneScreenshots/02 - workout detail.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6ed36395f118da38d151d13c124d0bd9ed3b4aea7e6808fd7c2ed4f516834df3 -size 228658 +oid sha256:5bf34d9022280caed29f56e553ac83fc0436f69e11bb01ff42a5c66a27ea7987 +size 504030 diff --git a/fastlane/metadata/android/de-DE/images/phoneScreenshots/03 - gym mode.png b/fastlane/metadata/android/de-DE/images/phoneScreenshots/03 - gym mode.png index 729086279..ef706491c 100644 --- a/fastlane/metadata/android/de-DE/images/phoneScreenshots/03 - gym mode.png +++ b/fastlane/metadata/android/de-DE/images/phoneScreenshots/03 - gym mode.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8d91aaf5ff572c1501762dcbebb4829f75fb6be2a0ec73ea91a2dcb569099d4f -size 174638 +oid sha256:55344c0df5191f29187904e7eacb780da0ff448c5402f0351f3da731509dfd02 +size 383618 diff --git a/fastlane/metadata/android/de-DE/images/phoneScreenshots/04 - measurements.png b/fastlane/metadata/android/de-DE/images/phoneScreenshots/04 - measurements.png index 3eb46cc12..57bc6ce40 100644 --- a/fastlane/metadata/android/de-DE/images/phoneScreenshots/04 - measurements.png +++ b/fastlane/metadata/android/de-DE/images/phoneScreenshots/04 - measurements.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:de2137e8c8e1e50c5deabbd02c0da06446ed21af637e59b966dba5f5070a5fe7 -size 150996 +oid sha256:3060e5f55f1bdef00ee00c897102f43f82b0519d9f797c0d0e777d8e986b39b2 +size 523325 diff --git a/fastlane/metadata/android/de-DE/images/phoneScreenshots/05 - nutritional plan.png b/fastlane/metadata/android/de-DE/images/phoneScreenshots/05 - nutritional plan.png index 1368b9acb..92cef6805 100644 --- a/fastlane/metadata/android/de-DE/images/phoneScreenshots/05 - nutritional plan.png +++ b/fastlane/metadata/android/de-DE/images/phoneScreenshots/05 - nutritional plan.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8b892d4fdc8f0105a4152a52120a42a033e803081582f5a8e553d678a05e4325 -size 202395 +oid sha256:daa3146fc86a547f715c5debf3c99b7f9342dc2bc07a0ff3b944ad4128c1b5f4 +size 409329 diff --git a/fastlane/metadata/android/de-DE/images/phoneScreenshots/06 - weight.png b/fastlane/metadata/android/de-DE/images/phoneScreenshots/06 - weight.png deleted file mode 100644 index 382f8e960..000000000 --- a/fastlane/metadata/android/de-DE/images/phoneScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:33ef6320d892a23b14497fe25cc722a8bdc752774c7d9d21b636cc424f77f306 -size 212124 diff --git a/fastlane/metadata/android/de-DE/images/sevenInchScreenshots/06 - weight.png b/fastlane/metadata/android/de-DE/images/sevenInchScreenshots/06 - weight.png deleted file mode 100644 index 7fb2fbd4c..000000000 --- a/fastlane/metadata/android/de-DE/images/sevenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c37c06998578e2815b1a22a896aa5dabdefc955b240c9043e2d84d86a1687fb1 -size 56609 diff --git a/fastlane/metadata/android/de-DE/images/tenInchScreenshots/06 - weight.png b/fastlane/metadata/android/de-DE/images/tenInchScreenshots/06 - weight.png deleted file mode 100644 index c8608dfd8..000000000 --- a/fastlane/metadata/android/de-DE/images/tenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:24a7c21b8344e6229033425f358e2595d5c8a6fb4c80e551f83581fd89e0e39c -size 139277 diff --git a/fastlane/metadata/android/el-GR/images/phoneScreenshots/06 - weight.png b/fastlane/metadata/android/el-GR/images/phoneScreenshots/06 - weight.png deleted file mode 100644 index 2fd746066..000000000 --- a/fastlane/metadata/android/el-GR/images/phoneScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:65d392b394532c092b60b3e0995d3e129839c4d7fffc019d5a0aeaa4838b5aec -size 202098 diff --git a/fastlane/metadata/android/el-GR/images/sevenInchScreenshots/06 - weight.png b/fastlane/metadata/android/el-GR/images/sevenInchScreenshots/06 - weight.png deleted file mode 100644 index 6d61292e0..000000000 --- a/fastlane/metadata/android/el-GR/images/sevenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8b6196781cebbffd4562ed58dd52d4c99dd537fccc5ca74a9fb437db207375e6 -size 54659 diff --git a/fastlane/metadata/android/el-GR/images/tenInchScreenshots/06 - weight.png b/fastlane/metadata/android/el-GR/images/tenInchScreenshots/06 - weight.png deleted file mode 100644 index 430bbf1ab..000000000 --- a/fastlane/metadata/android/el-GR/images/tenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c2b347cc9942b5be3e8c546bf04e819f862c37b57240b92873174d4c0dd7525f -size 137479 diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/06 - weight.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/06 - weight.png deleted file mode 100644 index 81ecb2ad5..000000000 --- a/fastlane/metadata/android/en-US/images/phoneScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:732746887cbf2e19e8172b42a3501f0acd7e16fa40537a7a0d5380d1cecc7b9e -size 198695 diff --git a/fastlane/metadata/android/en-US/images/sevenInchScreenshots/06 - weight.png b/fastlane/metadata/android/en-US/images/sevenInchScreenshots/06 - weight.png deleted file mode 100644 index 0070ef9a5..000000000 --- a/fastlane/metadata/android/en-US/images/sevenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cd13903252d81ddd70aedebbe110b81f0bb6a79f000ad52a5522ab517fff498e -size 54007 diff --git a/fastlane/metadata/android/en-US/images/tenInchScreenshots/06 - weight.png b/fastlane/metadata/android/en-US/images/tenInchScreenshots/06 - weight.png deleted file mode 100644 index 3318384f1..000000000 --- a/fastlane/metadata/android/en-US/images/tenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:75a9cb986f5f8cc101ab8e6acbf44294c352417fb30627983cc2a122d3384596 -size 136336 diff --git a/fastlane/metadata/android/es-ES/images/phoneScreenshots/06 - weight.png b/fastlane/metadata/android/es-ES/images/phoneScreenshots/06 - weight.png deleted file mode 100644 index df5900ae7..000000000 --- a/fastlane/metadata/android/es-ES/images/phoneScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:55adc8d1c327149107ec5b379fa3e4e0ab39228ee63303fb8b04727ee5a2ee8b -size 196169 diff --git a/fastlane/metadata/android/es-ES/images/sevenInchScreenshots/06 - weight.png b/fastlane/metadata/android/es-ES/images/sevenInchScreenshots/06 - weight.png deleted file mode 100644 index c6f46ec86..000000000 --- a/fastlane/metadata/android/es-ES/images/sevenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:38fc8eacbbfd795003e46150ce9cc4c4438cfc8314a78b47dc77d8f9dc9dd990 -size 54716 diff --git a/fastlane/metadata/android/es-ES/images/tenInchScreenshots/06 - weight.png b/fastlane/metadata/android/es-ES/images/tenInchScreenshots/06 - weight.png deleted file mode 100644 index a3150d170..000000000 --- a/fastlane/metadata/android/es-ES/images/tenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:07319b4326954cdcc509acd0fd9ea384d8b9f984da780b15215acd86f323bbae -size 136169 diff --git a/fastlane/metadata/android/fa-IR/images/phoneScreenshots/06 - weight.png b/fastlane/metadata/android/fa-IR/images/phoneScreenshots/06 - weight.png deleted file mode 100644 index 8f9fa7803..000000000 --- a/fastlane/metadata/android/fa-IR/images/phoneScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:939922ece89f4bdb7f38607a57fe96df2eb04f1f376194011325105cf0c0d1a5 -size 192448 diff --git a/fastlane/metadata/android/fa-IR/images/sevenInchScreenshots/06 - weight.png b/fastlane/metadata/android/fa-IR/images/sevenInchScreenshots/06 - weight.png deleted file mode 100644 index 76cb4fe9e..000000000 --- a/fastlane/metadata/android/fa-IR/images/sevenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:dd35146a3c2b93fd0c56be052d439b2ac7caefc2c686f63011fbffa48a242d21 -size 46967 diff --git a/fastlane/metadata/android/fa-IR/images/tenInchScreenshots/06 - weight.png b/fastlane/metadata/android/fa-IR/images/tenInchScreenshots/06 - weight.png deleted file mode 100644 index fdd150ccc..000000000 --- a/fastlane/metadata/android/fa-IR/images/tenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:22f3e642cf788f2231103e62477c41dd6bf405eebe300024ee48042b0c7a79fc -size 122395 diff --git a/fastlane/metadata/android/fr-FR/images/phoneScreenshots/06 - weight.png b/fastlane/metadata/android/fr-FR/images/phoneScreenshots/06 - weight.png deleted file mode 100644 index 7d875da95..000000000 --- a/fastlane/metadata/android/fr-FR/images/phoneScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5dceb519c8cc2b20c8b3400ce6ad32f730acf9c01bfc93435cb1da744c391e15 -size 205417 diff --git a/fastlane/metadata/android/fr-FR/images/sevenInchScreenshots/06 - weight.png b/fastlane/metadata/android/fr-FR/images/sevenInchScreenshots/06 - weight.png deleted file mode 100644 index 8b59d32e8..000000000 --- a/fastlane/metadata/android/fr-FR/images/sevenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5c2e571d2423a3da1498dfb7e636c290e8f0e0670e87ff9f6440ecc57b45df3f -size 57573 diff --git a/fastlane/metadata/android/fr-FR/images/tenInchScreenshots/06 - weight.png b/fastlane/metadata/android/fr-FR/images/tenInchScreenshots/06 - weight.png deleted file mode 100644 index e9e35e2d3..000000000 --- a/fastlane/metadata/android/fr-FR/images/tenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:bd3616235bcb1301403c113c65ec768c50b1fba3a1784e4940ca44ba6471414c -size 138310 diff --git a/fastlane/metadata/android/hi-IN/images/phoneScreenshots/06 - weight.png b/fastlane/metadata/android/hi-IN/images/phoneScreenshots/06 - weight.png deleted file mode 100644 index 4f07fd4ee..000000000 --- a/fastlane/metadata/android/hi-IN/images/phoneScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8500f2163ecc473850342a93c6b401b35d0a0816c099ade18ae490a280bc5d18 -size 183899 diff --git a/fastlane/metadata/android/hi-IN/images/sevenInchScreenshots/06 - weight.png b/fastlane/metadata/android/hi-IN/images/sevenInchScreenshots/06 - weight.png deleted file mode 100644 index 754319c93..000000000 --- a/fastlane/metadata/android/hi-IN/images/sevenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:eb99c62dc5e03075f5efc1be2b43907fad84c74a66a2ddba74b473b9fdd85561 -size 51141 diff --git a/fastlane/metadata/android/hi-IN/images/tenInchScreenshots/06 - weight.png b/fastlane/metadata/android/hi-IN/images/tenInchScreenshots/06 - weight.png deleted file mode 100644 index 969072bc3..000000000 --- a/fastlane/metadata/android/hi-IN/images/tenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:007bfebad3aa5b527257c45f575f129b2b15a21274dea2d91023dca5aacabbe7 -size 128540 diff --git a/fastlane/metadata/android/hr/images/phoneScreenshots/06 - weight.png b/fastlane/metadata/android/hr/images/phoneScreenshots/06 - weight.png deleted file mode 100644 index ab3cc1c92..000000000 --- a/fastlane/metadata/android/hr/images/phoneScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:16526b4bf3c2f254cea3e9021a6745a657d64f6e3f89bdbcd3d539b16c25d43b -size 206941 diff --git a/fastlane/metadata/android/hr/images/sevenInchScreenshots/06 - weight.png b/fastlane/metadata/android/hr/images/sevenInchScreenshots/06 - weight.png deleted file mode 100644 index dc277a863..000000000 --- a/fastlane/metadata/android/hr/images/sevenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f1c2af25a8136cb30e7e3d94835191a4bb55c37448a5c8717c3f5e6c640771db -size 55580 diff --git a/fastlane/metadata/android/hr/images/tenInchScreenshots/06 - weight.png b/fastlane/metadata/android/hr/images/tenInchScreenshots/06 - weight.png deleted file mode 100644 index 6c5505ea2..000000000 --- a/fastlane/metadata/android/hr/images/tenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:28673131892f33bd6bc77717fcfc9570c565119ec1d3891af328f59dc8aa4f7a -size 137441 diff --git a/fastlane/metadata/android/it-IT/images/phoneScreenshots/06 - weight.png b/fastlane/metadata/android/it-IT/images/phoneScreenshots/06 - weight.png deleted file mode 100644 index 15e6d50ac..000000000 --- a/fastlane/metadata/android/it-IT/images/phoneScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9ae5a1368acb815d2106db84c5d69f9ca5c117ed7febb21e98e81caa5c7ae278 -size 209109 diff --git a/fastlane/metadata/android/it-IT/images/sevenInchScreenshots/06 - weight.png b/fastlane/metadata/android/it-IT/images/sevenInchScreenshots/06 - weight.png deleted file mode 100644 index a70a29c66..000000000 --- a/fastlane/metadata/android/it-IT/images/sevenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:074df039dca8f707d08c87151d01b75b3640b70921c3a81c89bf058f99e0d3ce -size 57600 diff --git a/fastlane/metadata/android/it-IT/images/tenInchScreenshots/06 - weight.png b/fastlane/metadata/android/it-IT/images/tenInchScreenshots/06 - weight.png deleted file mode 100644 index b9b5b6442..000000000 --- a/fastlane/metadata/android/it-IT/images/tenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d7543583444b3862d1b5939109fcd7f62622e2b03b44593aa16945c827c09c4a -size 141404 diff --git a/fastlane/metadata/android/iw-IL/images/phoneScreenshots/06 - weight.png b/fastlane/metadata/android/iw-IL/images/phoneScreenshots/06 - weight.png deleted file mode 100644 index bbc7eb878..000000000 --- a/fastlane/metadata/android/iw-IL/images/phoneScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:334035b5bac04243dbea2e6beebf9f886832b08cd835dbd54f93c16c47f7bee7 -size 180721 diff --git a/fastlane/metadata/android/iw-IL/images/sevenInchScreenshots/06 - weight.png b/fastlane/metadata/android/iw-IL/images/sevenInchScreenshots/06 - weight.png deleted file mode 100644 index 4fddae4bb..000000000 --- a/fastlane/metadata/android/iw-IL/images/sevenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:51d1807ccdb0aa9178363a9e8d497534b036e5a02e9aee3b75ee845614809fbe -size 51723 diff --git a/fastlane/metadata/android/iw-IL/images/tenInchScreenshots/06 - weight.png b/fastlane/metadata/android/iw-IL/images/tenInchScreenshots/06 - weight.png deleted file mode 100644 index 62ac04474..000000000 --- a/fastlane/metadata/android/iw-IL/images/tenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ea0068a97e45ad06854930f07888d821fb7ca0d62f72a53247fbd75588acd8c0 -size 127237 diff --git a/fastlane/metadata/android/ko-KR/images/phoneScreenshots/06 - weight.png b/fastlane/metadata/android/ko-KR/images/phoneScreenshots/06 - weight.png deleted file mode 100644 index 804b9f243..000000000 --- a/fastlane/metadata/android/ko-KR/images/phoneScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8e3d6f099e66721824b478b31631b236fb741aa35e377e844b6c50fc2d34f773 -size 186997 diff --git a/fastlane/metadata/android/ko-KR/images/sevenInchScreenshots/06 - weight.png b/fastlane/metadata/android/ko-KR/images/sevenInchScreenshots/06 - weight.png deleted file mode 100644 index 174f55553..000000000 --- a/fastlane/metadata/android/ko-KR/images/sevenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c4dffe5ec1a0b11cec1f6ff3d97a3cd809a7ac3518de5016b1acd9fb270024dd -size 51707 diff --git a/fastlane/metadata/android/ko-KR/images/tenInchScreenshots/06 - weight.png b/fastlane/metadata/android/ko-KR/images/tenInchScreenshots/06 - weight.png deleted file mode 100644 index d3a2fb410..000000000 --- a/fastlane/metadata/android/ko-KR/images/tenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b139b9f2a68598d6cc0a05f5b02e11494f69c57192e8e896ced086c3a95904ff -size 128244 diff --git a/fastlane/metadata/android/nb-NO/images/phoneScreenshots/06 - weight.png b/fastlane/metadata/android/nb-NO/images/phoneScreenshots/06 - weight.png deleted file mode 100644 index 713338149..000000000 --- a/fastlane/metadata/android/nb-NO/images/phoneScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:63f010e1a4ff997e38422ccfc7a6b5df98b6d353cb58272d57cbd5577dfc0c68 -size 191461 diff --git a/fastlane/metadata/android/nb-NO/images/sevenInchScreenshots/06 - weight.png b/fastlane/metadata/android/nb-NO/images/sevenInchScreenshots/06 - weight.png deleted file mode 100644 index 765226576..000000000 --- a/fastlane/metadata/android/nb-NO/images/sevenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:efea3ebd6981291931570c8aeea225ef0cd552458951c1a3cb1b2538a4a29eeb -size 51657 diff --git a/fastlane/metadata/android/nb-NO/images/tenInchScreenshots/06 - weight.png b/fastlane/metadata/android/nb-NO/images/tenInchScreenshots/06 - weight.png deleted file mode 100644 index 08ab19b61..000000000 --- a/fastlane/metadata/android/nb-NO/images/tenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:576b6086fa2e9abad4cb6fcbbf683f52d7b48643d16630a365397f06d9f6b94c -size 129191 diff --git a/fastlane/metadata/android/pl-PL/images/phoneScreenshots/06 - weight.png b/fastlane/metadata/android/pl-PL/images/phoneScreenshots/06 - weight.png deleted file mode 100644 index 7f96de914..000000000 --- a/fastlane/metadata/android/pl-PL/images/phoneScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4a2ba1b91f1e4440a07ad6c8051f00ddaf5037cce82503f693e92f7442510380 -size 202585 diff --git a/fastlane/metadata/android/pl-PL/images/sevenInchScreenshots/06 - weight.png b/fastlane/metadata/android/pl-PL/images/sevenInchScreenshots/06 - weight.png deleted file mode 100644 index 61527662c..000000000 --- a/fastlane/metadata/android/pl-PL/images/sevenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8fbd68f6dad717ad213f0ba013aac5398918283a3785c76eba8b0c1bf60f48c6 -size 55232 diff --git a/fastlane/metadata/android/pl-PL/images/tenInchScreenshots/06 - weight.png b/fastlane/metadata/android/pl-PL/images/tenInchScreenshots/06 - weight.png deleted file mode 100644 index e4e51c9dc..000000000 --- a/fastlane/metadata/android/pl-PL/images/tenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:77f8970bb4b719a3e3b9191778f8592953480d4512dcc10c9f30539da008af42 -size 139018 diff --git a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/06 - weight.png b/fastlane/metadata/android/pt-BR/images/phoneScreenshots/06 - weight.png deleted file mode 100644 index 52e1cdc3a..000000000 --- a/fastlane/metadata/android/pt-BR/images/phoneScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:44fbdb5fd69f26d85faff297cf3fba61ad1371cc9fe0f70ac1f7a37c28b747e3 -size 199136 diff --git a/fastlane/metadata/android/pt-BR/images/sevenInchScreenshots/06 - weight.png b/fastlane/metadata/android/pt-BR/images/sevenInchScreenshots/06 - weight.png deleted file mode 100644 index f0097aae7..000000000 --- a/fastlane/metadata/android/pt-BR/images/sevenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e6d19d7aa0e671240458958b1a3b8c7400e577d55edd30db73f6acc421ab2287 -size 55652 diff --git a/fastlane/metadata/android/pt-BR/images/tenInchScreenshots/06 - weight.png b/fastlane/metadata/android/pt-BR/images/tenInchScreenshots/06 - weight.png deleted file mode 100644 index 0250fb702..000000000 --- a/fastlane/metadata/android/pt-BR/images/tenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3a52d9daba33236e496501d9afe1dc2e5e535ba92fc0dcbedbd31ae2a86b2007 -size 135142 diff --git a/fastlane/metadata/android/pt-PT/images/phoneScreenshots/06 - weight.png b/fastlane/metadata/android/pt-PT/images/phoneScreenshots/06 - weight.png deleted file mode 100644 index 8e3b60325..000000000 --- a/fastlane/metadata/android/pt-PT/images/phoneScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cb368d185f586a3f86bebaa1b647df54ebd2f6f3fc3bcc1892bee44b2c39f618 -size 202663 diff --git a/fastlane/metadata/android/pt-PT/images/sevenInchScreenshots/06 - weight.png b/fastlane/metadata/android/pt-PT/images/sevenInchScreenshots/06 - weight.png deleted file mode 100644 index 8e3367150..000000000 --- a/fastlane/metadata/android/pt-PT/images/sevenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:79d89e672adb437fea8b40c4dd7721ea5a685bbfa99987e74a063352b9618adb -size 55603 diff --git a/fastlane/metadata/android/pt-PT/images/tenInchScreenshots/06 - weight.png b/fastlane/metadata/android/pt-PT/images/tenInchScreenshots/06 - weight.png deleted file mode 100644 index 668f3c6a8..000000000 --- a/fastlane/metadata/android/pt-PT/images/tenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e1f0b96c0069d47977443b4b8f194a7349be17737e15444f75add17a86e74069 -size 136727 diff --git a/fastlane/metadata/android/ru-RU/images/phoneScreenshots/06 - weight.png b/fastlane/metadata/android/ru-RU/images/phoneScreenshots/06 - weight.png deleted file mode 100644 index 5277b3763..000000000 --- a/fastlane/metadata/android/ru-RU/images/phoneScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:02050cdbb2de2156e978921b48c583bdd72296e84ccc318b0082c8e5c65d8882 -size 187487 diff --git a/fastlane/metadata/android/ru-RU/images/sevenInchScreenshots/06 - weight.png b/fastlane/metadata/android/ru-RU/images/sevenInchScreenshots/06 - weight.png deleted file mode 100644 index 2082e8b7e..000000000 --- a/fastlane/metadata/android/ru-RU/images/sevenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:db81702555f7ae16d06154b6573e9032d6be19267c32c6bdb68035c1d44e4691 -size 52690 diff --git a/fastlane/metadata/android/ru-RU/images/tenInchScreenshots/06 - weight.png b/fastlane/metadata/android/ru-RU/images/tenInchScreenshots/06 - weight.png deleted file mode 100644 index acce92602..000000000 --- a/fastlane/metadata/android/ru-RU/images/tenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1279e1b001c7abfbba4d1ba35ed977a0c639f8641443c95f7bcd4e5bc170a097 -size 130823 diff --git a/fastlane/metadata/android/sr/images/phoneScreenshots/06 - weight.png b/fastlane/metadata/android/sr/images/phoneScreenshots/06 - weight.png deleted file mode 100644 index 81ecb2ad5..000000000 --- a/fastlane/metadata/android/sr/images/phoneScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:732746887cbf2e19e8172b42a3501f0acd7e16fa40537a7a0d5380d1cecc7b9e -size 198695 diff --git a/fastlane/metadata/android/sr/images/sevenInchScreenshots/06 - weight.png b/fastlane/metadata/android/sr/images/sevenInchScreenshots/06 - weight.png deleted file mode 100644 index 0070ef9a5..000000000 --- a/fastlane/metadata/android/sr/images/sevenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cd13903252d81ddd70aedebbe110b81f0bb6a79f000ad52a5522ab517fff498e -size 54007 diff --git a/fastlane/metadata/android/sr/images/tenInchScreenshots/06 - weight.png b/fastlane/metadata/android/sr/images/tenInchScreenshots/06 - weight.png deleted file mode 100644 index 3318384f1..000000000 --- a/fastlane/metadata/android/sr/images/tenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:75a9cb986f5f8cc101ab8e6acbf44294c352417fb30627983cc2a122d3384596 -size 136336 diff --git a/fastlane/metadata/android/ta-IN/images/phoneScreenshots/06 - weight.png b/fastlane/metadata/android/ta-IN/images/phoneScreenshots/06 - weight.png deleted file mode 100644 index d83e7509d..000000000 --- a/fastlane/metadata/android/ta-IN/images/phoneScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6ae2271670790b3e1f1d13301ce00ab2da19d1b09106fb0925d3d68a96be62f7 -size 281776 diff --git a/fastlane/metadata/android/ta-IN/images/sevenInchScreenshots/06 - weight.png b/fastlane/metadata/android/ta-IN/images/sevenInchScreenshots/06 - weight.png deleted file mode 100644 index fa5288598..000000000 --- a/fastlane/metadata/android/ta-IN/images/sevenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:40f04bf5a65ca6236a271ee56af77c33b3df28c16f4c079a11a2b1eb17ed75e0 -size 77457 diff --git a/fastlane/metadata/android/ta-IN/images/tenInchScreenshots/06 - weight.png b/fastlane/metadata/android/ta-IN/images/tenInchScreenshots/06 - weight.png deleted file mode 100644 index 0f591b972..000000000 --- a/fastlane/metadata/android/ta-IN/images/tenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d15374edc8c5ef1bf4095b56797db8b5cee1e8c1fff1a4914a63bb8c0cda6a64 -size 188011 diff --git a/fastlane/metadata/android/tr-TR/images/phoneScreenshots/06 - weight.png b/fastlane/metadata/android/tr-TR/images/phoneScreenshots/06 - weight.png deleted file mode 100644 index 679eee4dc..000000000 --- a/fastlane/metadata/android/tr-TR/images/phoneScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5d3b74558d969216df53cf188bedd6c1b0e26da3d5229ad82278ad386d474747 -size 194824 diff --git a/fastlane/metadata/android/tr-TR/images/sevenInchScreenshots/06 - weight.png b/fastlane/metadata/android/tr-TR/images/sevenInchScreenshots/06 - weight.png deleted file mode 100644 index 87f128080..000000000 --- a/fastlane/metadata/android/tr-TR/images/sevenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:884195c8f097f5014dbe84581a8709b38eb6341b709db22007f63338a141a8a9 -size 53870 diff --git a/fastlane/metadata/android/tr-TR/images/tenInchScreenshots/06 - weight.png b/fastlane/metadata/android/tr-TR/images/tenInchScreenshots/06 - weight.png deleted file mode 100644 index a0d69b8c0..000000000 --- a/fastlane/metadata/android/tr-TR/images/tenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f16b9bcfd5381ac695e629c24ab0b55eeb2fb33281a34a7ac267719d535b7d11 -size 134354 diff --git a/fastlane/metadata/android/uk/images/phoneScreenshots/06 - weight.png b/fastlane/metadata/android/uk/images/phoneScreenshots/06 - weight.png deleted file mode 100644 index 89aade1fc..000000000 --- a/fastlane/metadata/android/uk/images/phoneScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:fd14fbfbeb5081d06909a058c951a75d307410184b1172adbcf1112607bcdcbe -size 185403 diff --git a/fastlane/metadata/android/uk/images/sevenInchScreenshots/06 - weight.png b/fastlane/metadata/android/uk/images/sevenInchScreenshots/06 - weight.png deleted file mode 100644 index 0d2f147bd..000000000 --- a/fastlane/metadata/android/uk/images/sevenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b571a154e0157b31e44a4c84b7c3405dccf10d13ad82d25b829a70a966ec722d -size 51764 diff --git a/fastlane/metadata/android/uk/images/tenInchScreenshots/06 - weight.png b/fastlane/metadata/android/uk/images/tenInchScreenshots/06 - weight.png deleted file mode 100644 index 4aded60f5..000000000 --- a/fastlane/metadata/android/uk/images/tenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c41b90defd0c0ee92d027bb60cd9aacaace7843d7ab59f0ee640864f167c860f -size 128206 diff --git a/fastlane/metadata/android/zh-CN/images/phoneScreenshots/06 - weight.png b/fastlane/metadata/android/zh-CN/images/phoneScreenshots/06 - weight.png deleted file mode 100644 index 096be74fc..000000000 --- a/fastlane/metadata/android/zh-CN/images/phoneScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7132e9605809a38f191e3e9e3307360079eaeb7695e4debd0acdc33daf84194b -size 208602 diff --git a/fastlane/metadata/android/zh-CN/images/sevenInchScreenshots/06 - weight.png b/fastlane/metadata/android/zh-CN/images/sevenInchScreenshots/06 - weight.png deleted file mode 100644 index bce8b3503..000000000 --- a/fastlane/metadata/android/zh-CN/images/sevenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:576564780277d97e2b51923a26bce65027b0b78548318626d30b1b1f455c3455 -size 63477 diff --git a/fastlane/metadata/android/zh-CN/images/tenInchScreenshots/06 - weight.png b/fastlane/metadata/android/zh-CN/images/tenInchScreenshots/06 - weight.png deleted file mode 100644 index 3ab3a6e66..000000000 --- a/fastlane/metadata/android/zh-CN/images/tenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7284cb602de8128200cb04545d3a80f43a43bf7a170d22e24690e19ec2515f43 -size 148293 diff --git a/fastlane/metadata/android/zh-TW/images/phoneScreenshots/06 - weight.png b/fastlane/metadata/android/zh-TW/images/phoneScreenshots/06 - weight.png deleted file mode 100644 index 096be74fc..000000000 --- a/fastlane/metadata/android/zh-TW/images/phoneScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7132e9605809a38f191e3e9e3307360079eaeb7695e4debd0acdc33daf84194b -size 208602 diff --git a/fastlane/metadata/android/zh-TW/images/sevenInchScreenshots/06 - weight.png b/fastlane/metadata/android/zh-TW/images/sevenInchScreenshots/06 - weight.png deleted file mode 100644 index bce8b3503..000000000 --- a/fastlane/metadata/android/zh-TW/images/sevenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:576564780277d97e2b51923a26bce65027b0b78548318626d30b1b1f455c3455 -size 63477 diff --git a/fastlane/metadata/android/zh-TW/images/tenInchScreenshots/06 - weight.png b/fastlane/metadata/android/zh-TW/images/tenInchScreenshots/06 - weight.png deleted file mode 100644 index 3ab3a6e66..000000000 --- a/fastlane/metadata/android/zh-TW/images/tenInchScreenshots/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7284cb602de8128200cb04545d3a80f43a43bf7a170d22e24690e19ec2515f43 -size 148293 diff --git a/fastlane/metadata/ios/ar/images/iPhone 6.9/06 - weight.png b/fastlane/metadata/ios/ar/images/iPhone 6.9/06 - weight.png deleted file mode 100644 index 1d898ded7..000000000 --- a/fastlane/metadata/ios/ar/images/iPhone 6.9/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:13883d31dd1eed19aa936127e77a3321946f73b18aeec910ff7bc6e9474be2c3 -size 386603 diff --git a/fastlane/metadata/ios/ca/images/iPhone 6.9/06 - weight.png b/fastlane/metadata/ios/ca/images/iPhone 6.9/06 - weight.png deleted file mode 100644 index a7368cf99..000000000 --- a/fastlane/metadata/ios/ca/images/iPhone 6.9/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f838857b27866da8f32e11ea3d0af1e3b5e55506c34f2370d5fd54e982a3a631 -size 382774 diff --git a/fastlane/metadata/ios/cs-CZ/images/iPhone 6.9/06 - weight.png b/fastlane/metadata/ios/cs-CZ/images/iPhone 6.9/06 - weight.png deleted file mode 100644 index b200c985e..000000000 --- a/fastlane/metadata/ios/cs-CZ/images/iPhone 6.9/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f145013190730def0b6a56d0d0b63e6233b313384606ed5fdc407dc96acae0fa -size 381961 diff --git a/fastlane/metadata/ios/de-DE/images/iPhone 6.9/06 - weight.png b/fastlane/metadata/ios/de-DE/images/iPhone 6.9/06 - weight.png deleted file mode 100644 index f1bace4b9..000000000 --- a/fastlane/metadata/ios/de-DE/images/iPhone 6.9/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3e93c8111441800c91397426975c80c654114287b47df9a03425ce06a13a72aa -size 405431 diff --git a/fastlane/metadata/ios/el-GR/images/iPhone 6.9/06 - weight.png b/fastlane/metadata/ios/el-GR/images/iPhone 6.9/06 - weight.png deleted file mode 100644 index f3ef8f067..000000000 --- a/fastlane/metadata/ios/el-GR/images/iPhone 6.9/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7ed89195860ac2278cac3f5cca58f34eb8d8d5ba2fa7c3b9bc31ca3ca30d4dff -size 391737 diff --git a/fastlane/metadata/ios/en-US/images/iPhone 6.9/06 - weight.png b/fastlane/metadata/ios/en-US/images/iPhone 6.9/06 - weight.png deleted file mode 100644 index 51a64dd7c..000000000 --- a/fastlane/metadata/ios/en-US/images/iPhone 6.9/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:73562f3b18960f5be3e8503b159e00a38528e9b2f1eb4bcf334c0345c7b8fba0 -size 392374 diff --git a/fastlane/metadata/ios/es-ES/images/iPhone 6.9/06 - weight.png b/fastlane/metadata/ios/es-ES/images/iPhone 6.9/06 - weight.png deleted file mode 100644 index a68a8ff11..000000000 --- a/fastlane/metadata/ios/es-ES/images/iPhone 6.9/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c68a14e64fd7c6e0a6828a01f05459d006a416d3a85084cebd65b38855b43873 -size 392673 diff --git a/fastlane/metadata/ios/fa-IR/images/iPhone 6.9/06 - weight.png b/fastlane/metadata/ios/fa-IR/images/iPhone 6.9/06 - weight.png deleted file mode 100644 index e935cc500..000000000 --- a/fastlane/metadata/ios/fa-IR/images/iPhone 6.9/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0b053350668fc6abe934b79938f25f8d0363d57c9221f159632ec6497b059828 -size 373407 diff --git a/fastlane/metadata/ios/fr-FR/images/iPhone 6.9/06 - weight.png b/fastlane/metadata/ios/fr-FR/images/iPhone 6.9/06 - weight.png deleted file mode 100644 index 21e3c90f1..000000000 --- a/fastlane/metadata/ios/fr-FR/images/iPhone 6.9/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:43f5bd4929898ddc765729fd36598ca970fbb85bb306597924cb133132fc9140 -size 400515 diff --git a/fastlane/metadata/ios/hi-IN/images/iPhone 6.9/06 - weight.png b/fastlane/metadata/ios/hi-IN/images/iPhone 6.9/06 - weight.png deleted file mode 100644 index 287ff11c7..000000000 --- a/fastlane/metadata/ios/hi-IN/images/iPhone 6.9/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:bc4294d5f3a141cf6f4deccc26f0d1b4c1ac1997069cea9a71acec0149a65dca -size 377618 diff --git a/fastlane/metadata/ios/hr/images/iPhone 6.9/06 - weight.png b/fastlane/metadata/ios/hr/images/iPhone 6.9/06 - weight.png deleted file mode 100644 index 6393eb8aa..000000000 --- a/fastlane/metadata/ios/hr/images/iPhone 6.9/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:bbf2ad6382411724f971c90b59640672a283b46c294ca82dc82aa59c7cc0b8fb -size 397780 diff --git a/fastlane/metadata/ios/it-IT/images/iPhone 6.9/06 - weight.png b/fastlane/metadata/ios/it-IT/images/iPhone 6.9/06 - weight.png deleted file mode 100644 index 4ac4332a2..000000000 --- a/fastlane/metadata/ios/it-IT/images/iPhone 6.9/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:dff620d4d737f968cfe3e96d237b26a04ac25e9284498f7ea0d84fbec3d120d2 -size 399219 diff --git a/fastlane/metadata/ios/iw-IL/images/iPhone 6.9/06 - weight.png b/fastlane/metadata/ios/iw-IL/images/iPhone 6.9/06 - weight.png deleted file mode 100644 index 21c01d8c1..000000000 --- a/fastlane/metadata/ios/iw-IL/images/iPhone 6.9/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:85409e24d9c82bcf4c79847f16e6b0afe723b094012aad8327fb410c48eb140d -size 363953 diff --git a/fastlane/metadata/ios/ko-KR/images/iPhone 6.9/06 - weight.png b/fastlane/metadata/ios/ko-KR/images/iPhone 6.9/06 - weight.png deleted file mode 100644 index 7d389d5f7..000000000 --- a/fastlane/metadata/ios/ko-KR/images/iPhone 6.9/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6de19d350fae7fa89769da4213f6f152b721167b3bebd5c6fd418ef7700e7509 -size 373853 diff --git a/fastlane/metadata/ios/nb-NO/images/iPhone 6.9/06 - weight.png b/fastlane/metadata/ios/nb-NO/images/iPhone 6.9/06 - weight.png deleted file mode 100644 index eb762de40..000000000 --- a/fastlane/metadata/ios/nb-NO/images/iPhone 6.9/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3fe7a7e2809f257f7f77e93c4249274a563101b46de6ab8526db0adbbda339bd -size 383521 diff --git a/fastlane/metadata/ios/pl-PL/images/iPhone 6.9/06 - weight.png b/fastlane/metadata/ios/pl-PL/images/iPhone 6.9/06 - weight.png deleted file mode 100644 index c47c880e1..000000000 --- a/fastlane/metadata/ios/pl-PL/images/iPhone 6.9/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b824cb5cb111ee1ae851d2387a109392abd67b2e6df6046edd17262c1c86a9e0 -size 395320 diff --git a/fastlane/metadata/ios/pt-BR/images/iPhone 6.9/06 - weight.png b/fastlane/metadata/ios/pt-BR/images/iPhone 6.9/06 - weight.png deleted file mode 100644 index 93e95315e..000000000 --- a/fastlane/metadata/ios/pt-BR/images/iPhone 6.9/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:91c1ebd0fd28726d21247849056e16acb19a7d81de17f307e0a59364715fdf7f -size 391939 diff --git a/fastlane/metadata/ios/pt-PT/images/iPhone 6.9/06 - weight.png b/fastlane/metadata/ios/pt-PT/images/iPhone 6.9/06 - weight.png deleted file mode 100644 index f5781686a..000000000 --- a/fastlane/metadata/ios/pt-PT/images/iPhone 6.9/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d2ea8e5b1659889773e5cd416502526a6fcd2fd0b9a65fd48011c6628bd77b80 -size 393468 diff --git a/fastlane/metadata/ios/ru-RU/images/iPhone 6.9/06 - weight.png b/fastlane/metadata/ios/ru-RU/images/iPhone 6.9/06 - weight.png deleted file mode 100644 index beaeec550..000000000 --- a/fastlane/metadata/ios/ru-RU/images/iPhone 6.9/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b2fd908b0e8f04a65329844d94061d9a20e8681181b1c99673b921a663f7d74c -size 382428 diff --git a/fastlane/metadata/ios/sr/images/iPhone 6.9/06 - weight.png b/fastlane/metadata/ios/sr/images/iPhone 6.9/06 - weight.png deleted file mode 100644 index 51a64dd7c..000000000 --- a/fastlane/metadata/ios/sr/images/iPhone 6.9/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:73562f3b18960f5be3e8503b159e00a38528e9b2f1eb4bcf334c0345c7b8fba0 -size 392374 diff --git a/fastlane/metadata/ios/ta-IN/images/iPhone 6.9/06 - weight.png b/fastlane/metadata/ios/ta-IN/images/iPhone 6.9/06 - weight.png deleted file mode 100644 index 1618346c3..000000000 --- a/fastlane/metadata/ios/ta-IN/images/iPhone 6.9/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1e7c3d1cee7574f39b432150fedd50d08122a56fa923bebdeeef54cae023d7ed -size 428130 diff --git a/fastlane/metadata/ios/tr-TR/images/iPhone 6.9/06 - weight.png b/fastlane/metadata/ios/tr-TR/images/iPhone 6.9/06 - weight.png deleted file mode 100644 index 662ee9f1d..000000000 --- a/fastlane/metadata/ios/tr-TR/images/iPhone 6.9/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5413876ad19c00b6c95c5d0fac1307e9cc9716a0256bd14f89ec80737e15bb1f -size 391636 diff --git a/fastlane/metadata/ios/uk/images/iPhone 6.9/06 - weight.png b/fastlane/metadata/ios/uk/images/iPhone 6.9/06 - weight.png deleted file mode 100644 index 92a93edb4..000000000 --- a/fastlane/metadata/ios/uk/images/iPhone 6.9/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9587035ca1d52a23387e0dfb9d8aae986deba1c76f4cbb848e9a1f2ed05abd2c -size 379234 diff --git a/fastlane/metadata/ios/zh-CN/images/iPhone 6.9/06 - weight.png b/fastlane/metadata/ios/zh-CN/images/iPhone 6.9/06 - weight.png deleted file mode 100644 index 2999f4d25..000000000 --- a/fastlane/metadata/ios/zh-CN/images/iPhone 6.9/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1c92d38a822ffe46d91b224bb9690112f4d2350d351ad8ad1e03efe82ebd661c -size 399781 diff --git a/fastlane/metadata/ios/zh-TW/images/iPhone 6.9/06 - weight.png b/fastlane/metadata/ios/zh-TW/images/iPhone 6.9/06 - weight.png deleted file mode 100644 index 2999f4d25..000000000 --- a/fastlane/metadata/ios/zh-TW/images/iPhone 6.9/06 - weight.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1c92d38a822ffe46d91b224bb9690112f4d2350d351ad8ad1e03efe82ebd661c -size 399781 diff --git a/flatpak/de.wger.flutter.metainfo.xml b/flatpak/de.wger.flutter.metainfo.xml index 496b12d35..f45cd1773 100755 --- a/flatpak/de.wger.flutter.metainfo.xml +++ b/flatpak/de.wger.flutter.metainfo.xml @@ -79,12 +79,6 @@ https://media.githubusercontent.com/media/wger-project/flutter/refs/heads/master/fastlane/metadata/android/en-US/images/phoneScreenshots/05%20-%20nutritional%20plan.png - - Body weight - - https://media.githubusercontent.com/media/wger-project/flutter/refs/heads/master/fastlane/metadata/android/en-US/images/phoneScreenshots/06%20-%20weight.png - - diff --git a/integration_test/README.md b/integration_test/README.md index 33aff401d..5204c6b53 100644 --- a/integration_test/README.md +++ b/integration_test/README.md @@ -1,17 +1,67 @@ -This will generate some screenshots and save them to the Play Store metadata folder. - -1) Start the correct emulator/simulator for the device size you want. -2) Run, selecting the device size via `--dart-define=DEVICE_TYPE`: - ``` - flutter drive \ - --driver=test_driver/screenshot_driver.dart \ - --target=integration_test/make_screenshots_test.dart \ - --dart-define=DEVICE_TYPE=androidPhone - ``` - For the available device types, consult the `DeviceType` enum in - `make_screenshots_test.dart`. -3) If you get errors or the screenshots are not written to disk, edit the - `languages` list and comment some of the languages out. +This will generate the store screenshots and save them to the fastlane metadata +folders. + +The device types are the values of the `DeviceType` enum in +`make_screenshots_test.dart`. `DEVICE_TYPE` decides which folder the images are +written to, so it has to match the device you booted. An unknown value silently +falls back to `androidPhone`. + +## 1. Create the emulator, once + +Use the same profile the workflow uses, then nothing else needs setting up: the +resolution that comes out is the one the profile has. + +| Device type | Profile | Resolution | +|-----------------------|-------------------|------------| +| `androidPhone` | Pixel 10 | 1080x2424 | +| `androidTabletSmall` | Small Tablet | 1200x1920 | +| `androidTabletBig` | Pixel Tablet | 1600x2560 | +| `iOSPhoneBig` | iPhone 17 Pro Max | 1320x2868 | +| `iOSTabletBig` | iPad Pro 13-inch | 2064x2752 | + +For Android either pick the profile in Android Studio under Device Manager, or +on the command line. Pixel 10 and Small Tablet need reasonably current SDK +command line tools; the ones on the CI runner are too old for them, which is why +the workflow installs its own before creating the AVD. + +For the Apple device types just install the matching simulator in Xcode. + +## 2. Boot one device and run the driver + +Start the emulator or simulator for the device type you want, then: + +```bash +flutter drive \ + --driver=test_driver/screenshot_driver.dart \ + --target=integration_test/make_screenshots_test.dart \ + --dart-define=DEVICE_TYPE=androidPhone \ + --dart-define=LANGUAGES=de-DE +``` + +`LANGUAGES` takes a comma separated subset, or `2/5` for the second of five +equal blocks. Leave it out for all of them. Add `-d ` if more than +one device is attached, `flutter devices` lists them. The images land in +`fastlane/metadata/`, `git status` shows what changed. + +A run keeps every screenshot in memory on the device and hands the lot over to +the driver once the tests are through. Over all 25 languages that is upwards of +70MB of JSON, and when the app loses its VM service under it the run ends with +no images at all, so the workflow walks the list in blocks of five. + +## In CI + +The `Update screenshots` workflow does all of this for every device type and, +when asked for, commits the result to a dated branch. It creates its own AVDs, +so the setup above is only needed locally. + +## Troubleshooting + +If a run ends with `DriverError: ... Service has disappeared` and writes no +images, it lost the app before it could collect them. The tests themselves +usually passed, only the hand-over failed, so running that block again is +enough. A block that instead keeps printing `It is taking an unusually long +time to connect to the VM` never gives up on its own, kill it. In CI the script +puts every block on a ten minute leash and retries it up to three times. See also diff --git a/integration_test/make_screenshots_test.dart b/integration_test/make_screenshots_test.dart index 32ce8d756..30f5235be 100644 --- a/integration_test/make_screenshots_test.dart +++ b/integration_test/make_screenshots_test.dart @@ -30,7 +30,6 @@ import '../test/screenshots/screenshots_02_workout.dart'; import '../test/screenshots/screenshots_03_gym_mode.dart'; import '../test/screenshots/screenshots_04_measurements.dart'; import '../test/screenshots/screenshots_05_nutritional_plan.dart'; -import '../test/screenshots/screenshots_06_weight.dart'; /// Type of device /// @@ -43,8 +42,10 @@ enum DeviceType { androidTv('tvScreenshots'), androidWear('wearScreenshots'), + // Apple only needs the largest size per device family, the smaller ones are + // scaled down automatically iOSPhoneBig('iPhone 6.9', isAndroid: false), - iOSPhoneSmall('iPhone 6.7', isAndroid: false); + iOSTabletBig('iPad 13', isAndroid: false); final String folderName; final bool isAndroid; @@ -87,9 +88,7 @@ Future takeScreenshot( // Available languages in weblate for the fastlane/metadata/android folder (not necessarily // those for which the application is translated) -const languages = [ - // Note: it seems if too many languages are processed at once, sometimes the process - // disappear and no images are written. Doing this in smaller steps works fine +const allLanguages = [ 'ar', 'ca', 'cs-CZ', @@ -119,7 +118,31 @@ const languages = [ 'zh-TW', ]; +/// Languages to generate: `de-DE,en-US`, or `2/5` for the second of five equal +/// blocks. Empty means all of them, which the hand-over rarely survives. +const _languagesArg = String.fromEnvironment('LANGUAGES'); + +List _selectedLanguages() { + if (_languagesArg.isEmpty) { + return allLanguages; + } + if (!_languagesArg.contains('/')) { + return _languagesArg.split(',').map((language) => language.trim()).toList(); + } + + final [index, count] = _languagesArg.split('/').map(int.parse).toList(); + final size = (allLanguages.length / count).ceil(); + return allLanguages.skip((index - 1) * size).take(size).toList(); +} + +final languages = _selectedLanguages(); + void main() { + final unknown = languages.where((language) => !allLanguages.contains(language)).toList(); + if (unknown.isNotEmpty) { + throw ArgumentError('Not in allLanguages: ${unknown.join(', ')}'); + } + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive; @@ -179,12 +202,6 @@ void main() { await tester.pumpAndSettle(); await takeScreenshot(tester, binding, language, '05 - nutritional plan'); }); - - testWidgets('body weight screen - $language', (WidgetTester tester) async { - await tester.pumpWidget(createWeightScreen(locale: locale)); - await tester.pumpAndSettle(); - await takeScreenshot(tester, binding, language, '06 - weight'); - }); } }); } diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 8384323ad..87e8fbca4 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; @@ -494,6 +494,7 @@ MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; PRODUCT_BUNDLE_IDENTIFIER = de.wger.flutter.community; PRODUCT_NAME = "$(TARGET_NAME)"; + STRIP_STYLE = "non-global"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c30b367ec..fa82d5621 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,6 +1,54 @@ import Flutter import UIKit +private final class StorageExclusionPlugin: NSObject, FlutterPlugin { + static let pluginName = "StorageExclusionPlugin" + private static let channelName = "de.wger.flutter/storage" + + static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: channelName, binaryMessenger: registrar.messenger()) + let instance = StorageExclusionPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard call.method == "excludeFromBackup" else { + result(FlutterMethodNotImplemented) + return + } + guard + let arguments = call.arguments as? [String: Any], + let path = arguments["path"] as? String, + !path.isEmpty + else { + result( + FlutterError( + code: "INVALID_ARGUMENT", + message: "Expected a non-empty path", + details: nil + ) + ) + return + } + + do { + var url = URL(fileURLWithPath: path) + var values = URLResourceValues() + values.isExcludedFromBackup = true + try url.setResourceValues(values) + result(nil) + } catch { + result( + FlutterError( + code: "EXCLUDE_FROM_BACKUP_FAILED", + message: "Could not exclude \(path) from backups", + details: error.localizedDescription + ) + ) + } + } +} + @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( @@ -12,5 +60,14 @@ import UIKit func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + guard + let registrar = engineBridge.pluginRegistry.registrar( + forPlugin: StorageExclusionPlugin.pluginName + ) + else { + NSLog("StorageExclusionPlugin registrar unavailable") + return + } + StorageExclusionPlugin.register(with: registrar) } } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 82371b5f1..d0932ec39 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -87,6 +87,8 @@ NSCameraUsageDescription Workout photos + NSHealthShareUsageDescription + wger reads your Apple Health data to import measurements and activity such as weight, body fat, blood pressure, heart rate, blood oxygen, sleep, steps, distance, and active energy into your wger account 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..920c31337 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 @@ -54,6 +54,8 @@ const SUBMIT_BUTTON_KEY_NAME = 'submit-button'; /// Local Preferences keys const PREFS_INGREDIENTS = 'ingredientData'; const PREFS_WORKOUT_UNITS = 'workoutUnits'; + +/// Pre-JWT credential blob. Only still read to delete it on startup. const PREFS_USER = 'userData'; const PREFS_USER_DARK_THEME = 'userDarkMode'; const PREFS_USER_LOCALE = 'userLocale'; @@ -69,15 +71,13 @@ const PREFS_LAST_SERVER = 'lastServer'; const PREFS_USE_DYNAMIC_COLOR = 'useDynamicColor'; const USE_DYNAMIC_COLOR_DEFAULT = false; -/// Headless JWT auth: SharedPreferences keys. -/// -/// Read in parallel with the legacy `PREFS_USER` blob during the migration -/// window; once a user logs in via the headless flow these supersede it. -/// The refresh token is **not** stored here, it lives in secure storage -/// (`SECURE_STORAGE_REFRESH_TOKEN`). +/// The chart range the measurement screens share, held as `ChartRange.name` +const PREFS_CHART_RANGE = 'measurementChartRange'; + +/// Headless JWT auth: SharedPreferences keys. The refresh token is **not** +/// stored here, it lives in secure storage (`SECURE_STORAGE_REFRESH_TOKEN`). const PREFS_ACCESS_TOKEN = 'accessToken'; const PREFS_ACCESS_EXPIRES_AT = 'accessExpiresAt'; -const PREFS_TOKEN_TYPE = 'tokenType'; const PREFS_SERVER_URL = 'serverUrl'; /// JWT `sub` of the user whose data is materialised in the local PowerSync @@ -195,6 +195,11 @@ const WEBLATE_URL = 'https://hosted.weblate.org/engage/wger'; const BUY_ME_A_COFFEE_URL = 'https://buymeacoffee.com/wger'; const LIBERAPAY_URL = 'https://liberapay.com/wger'; +/// Terms of service and privacy policy, served by every instance. What it says +/// is up to whoever runs the server, upstream ships a placeholder. The Health +/// Connect entry points in MainActivity.kt use wger.de's copy of this page. +const TERMS_OF_SERVICE_PATH = 'software/terms-of-service'; + /// Factor to multiply / divide in the charts when converting dates to milliseconds /// from epoch since fl_charts does not support real time series charts and using /// the milliseconds themselves can cause the application to crash since it runs diff --git a/lib/core/date.dart b/lib/core/date.dart index bb9ce3dcd..0a1827e88 100644 --- a/lib/core/date.dart +++ b/lib/core/date.dart @@ -16,6 +16,27 @@ * along with this program. If not, see . */ +import 'package:timezone/timezone.dart' as tz; + +/// The calendar day of [instant] in the IANA zone [zoneName], as the plain +/// year/month/day [DateTime] that [DateTimeExtension.isSameDayAs] compares +/// +/// A null, empty or unknown name falls back to the device zone, mirroring the +/// fallback of the server's UserProfile.zone_info. +DateTime dayIn(DateTime instant, String? zoneName) { + if (zoneName != null && zoneName.isNotEmpty) { + try { + final zoned = tz.TZDateTime.from(instant, tz.getLocation(zoneName)); + return DateTime(zoned.year, zoned.month, zoned.day); + } on tz.LocationNotFoundException { + // An unknown name reads like an unreported zone + } + } + + final local = instant.toLocal(); + return DateTime(local.year, local.month, local.day); +} + /// Returns a list of [DateTime] objects from [first] to [last], inclusive. /// /// Counts calendar days, not elapsed time: the range is measured between the diff --git a/lib/core/form_screen.dart b/lib/core/form_screen.dart index ca63ee54c..faf234f92 100644 --- a/lib/core/form_screen.dart +++ b/lib/core/form_screen.dart @@ -28,9 +28,10 @@ class FormScreenArguments { /// Widget to render, typically a form final Widget widget; - /// Flag indicating whether to render the content has a list view (e.g. larger - /// forms that use an autocompleter, etc) or not (smnall forms, content will - /// get pushed down) + /// Whether [widget] scrolls itself, e.g. because it holds a list or grows + /// while it is filled in. It is then given the screen to fill. A form + /// without it is laid out at its natural height and pushed to the bottom, + /// which only holds as long as it fits. final bool hasListView; /// Padding for the whole content, default 15px on all sides diff --git a/lib/core/formatting/formatting.dart b/lib/core/formatting/formatting.dart index 5294e789e..a4bb6bd3a 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,163 @@ DateFormat localizedDate(BuildContext context) => /// A decimal number format bound to the current locale. NumberFormat localizedNumberFormat(BuildContext context) => NumberFormat.decimalPattern(Localizations.localeOf(context).toString()); + +/// The localized label of the profile's weight unit (kg or lb). +String weightUnit(bool isMetric, BuildContext context) { + return isMetric ? AppLocalizations.of(context).kg : AppLocalizations.of(context).lb; +} + +/// 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. +/// How close the ends of a series have to be for its axis to count as flat: +/// a spread that only shows up in the last digits of a double. +const _FLAT_RELATIVE = 1e-9; + +/// Room left around a flat series, as a share of the value it sits at. +const _FLAT_PADDING = 0.05; + +/// Bounds and tick interval of a value axis, null where fl_chart may pick them +/// itself. +/// +/// Two cases are taken out of its hands. Durations are read in hours, see +/// [durationAxis]. And a series whose values are all the same leaves a range +/// of nothing: fl_chart divides that range into steps and walks the axis one +/// step at a time, so a step below the last value's own precision never +/// advances the walk, and it generates labels until the heap gives out. A flat +/// series is ordinary (a weight that did not move, a calculation that stays +/// put), so the axis gets room around the value instead. +({double min, double max, double? interval})? valueAxis( + String unit, + num min, + num max, +) { + final duration = durationAxis(unit, min, max); + if (duration != null) { + return duration; + } + + if ((max - min).abs() > max.abs() * _FLAT_RELATIVE) { + return null; + } + + // A value of zero has no magnitude to take a share of + final padding = max.abs() * _FLAT_PADDING; + return ( + min: (max - (padding == 0 ? 1 : padding)).toDouble(), + max: (max + (padding == 0 ? 1 : padding)).toDouble(), + interval: null, + ); +} + +({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/helpers.dart b/lib/core/helpers.dart index 9a8122165..6e0129f0c 100644 --- a/lib/core/helpers.dart +++ b/lib/core/helpers.dart @@ -90,6 +90,23 @@ Uri makeUri( return uri; } +/// Builds the URL of a page the server renders itself, e.g. its terms of +/// service. Unlike [makeUri] the path is used as given, without the API prefix +/// and without a trailing slash. +Uri makePageUri(String serverUrl, String path) { + final server = Uri.parse(serverUrl); + final basePath = server.path.endsWith('/') + ? server.path.substring(0, server.path.length - 1) + : server.path; + + return Uri( + scheme: server.scheme, + host: server.host, + port: server.port, + path: '$basePath/$path', + ); +} + /// Builds a URL for the `allauth.headless` `app` client API at /// `/allauth/app/v1/`. Used by the auth notifier for login, /// signup, MFA, refresh, etc. The headless API does not use a trailing diff --git a/lib/core/home_tabs_screen.dart b/lib/core/home_tabs_screen.dart index f6b5f46ac..d52c04f55 100644 --- a/lib/core/home_tabs_screen.dart +++ b/lib/core/home_tabs_screen.dart @@ -21,10 +21,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; 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/account/providers/timezone_sync.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 +43,25 @@ 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. A no-op unless the user enabled it in the settings, and unless the + // last sync is long enough ago. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + ref.read(healthSyncProvider.notifier).syncIfDue(); + + // Report the device timezone to the profile, so the server calculates + // streaks and trophies in the user's own zone. A no-op when unchanged. + ref.read(timezoneSyncProvider).reportIfNeeded(); + }); + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -59,7 +80,7 @@ class _HomeTabsScreenState extends ConsumerState const DashboardScreen(), const RoutineListScreen(), const NutritionalPlansScreen(), - const WeightScreen(), + const MeasurementCategoriesScreen(), const GalleryScreen(), ]; @@ -80,7 +101,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/api_headers.dart b/lib/core/network/api_headers.dart new file mode 100644 index 000000000..e09f98e24 --- /dev/null +++ b/lib/core/network/api_headers.dart @@ -0,0 +1,47 @@ +/* + * 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 'dart:io'; + +import 'package:package_info_plus/package_info_plus.dart'; + +/// User-agent header string identifying the app/version/platform. +String getAppNameHeader(PackageInfo? applicationVersion) { + String out = ''; + if (applicationVersion != null) { + out = + '/${applicationVersion.version} ' + '(${applicationVersion.packageName}; ' + 'build: ${applicationVersion.buildNumber}; ' + 'platform: ${Platform.operatingSystem})' + ' - https://github.com/wger-project'; + } + return 'wger App$out'; +} + +/// Standard JSON-API headers for the auth endpoints. The `Accept` header keeps +/// a bot wall (e.g. Anubis) from serving an HTML challenge to these endpoints +/// instead of JSON. [extra] adds per-request headers (session token, auth, ...). +Map jsonApiHeaders(PackageInfo? appVersion, [Map? extra]) { + return { + HttpHeaders.contentTypeHeader: 'application/json; charset=utf-8', + HttpHeaders.acceptHeader: 'application/json', + HttpHeaders.userAgentHeader: getAppNameHeader(appVersion), + ...?extra, + }; +} diff --git a/lib/core/network/auth_credential.dart b/lib/core/network/auth_credential.dart index 2da61a338..b667adb4b 100644 --- a/lib/core/network/auth_credential.dart +++ b/lib/core/network/auth_credential.dart @@ -21,54 +21,32 @@ import 'package:wger/core/network/jwt.dart'; part 'auth_credential.freezed.dart'; -/// Sealed credential carried inside `AuthState` for every authenticated -/// caller. Branch via `switch` or `is` to access the variant-specific -/// fields; the two getters below cover everything the HTTP layer needs. +/// Credential carried inside `AuthState` for every authenticated caller: a +/// short-lived access token from `allauth.headless`, sent as +/// `Authorization: Bearer `. The refresh token is not part of it, it +/// lives in secure storage. /// -/// The app supports two flavours during the migration to `allauth.headless`: -/// -/// - [LegacyCredential] — permanent DRF token from `/api/v2/login/`, sent -/// as `Authorization: Token `. This is what existing installs are -/// running until they re-authenticate on a build that ships the JWT flow. -/// - [JwtCredential] — short-lived access token from `allauth.headless`, -/// sent as `Authorization: Bearer `. The refresh token lives in -/// secure storage, not on the credential itself. -/// -/// New logins (login, signup, MFA completion, pasted-refresh exchange) -/// always produce a [JwtCredential]. +/// Every entry point (login, signup, MFA completion, pasted-refresh +/// exchange, auto-login from storage) produces one of these. @freezed -sealed class AuthCredential with _$AuthCredential { - const factory AuthCredential.legacy(String token) = LegacyCredential; - - const factory AuthCredential.jwt({ +abstract class JwtCredential with _$JwtCredential { + const factory JwtCredential({ required String accessToken, DateTime? expiresAt, - }) = JwtCredential; + }) = _JwtCredential; - const AuthCredential._(); + const JwtCredential._(); /// `Authorization` header value for outgoing authenticated requests. - String get authHeaderValue => switch (this) { - LegacyCredential(:final token) => 'Token $token', - JwtCredential(:final accessToken) => 'Bearer $accessToken', - }; + String get authHeaderValue => 'Bearer $accessToken'; - /// True when this credential is a JWT whose expiry falls within [leeway] - /// of now (or is already past). Always false for [LegacyCredential]: - /// permanent DRF tokens have no expiry and are never refreshed. - bool needsRefresh(Duration leeway) => switch (this) { - LegacyCredential() => false, - JwtCredential(:final expiresAt) => - expiresAt != null && expiresAt.isBefore(DateTime.now().toUtc().add(leeway)), - }; + /// True when the expiry falls within [leeway] of now, or is already past. + /// False when the token carries no expiry at all. + bool needsRefresh(Duration leeway) => + expiresAt != null && expiresAt!.isBefore(DateTime.now().toUtc().add(leeway)); - /// User identifier carried by the credential. For [JwtCredential] this is - /// the JWT `sub` claim (decoded on every call, so callers should not - /// hammer it in tight loops). Null for [LegacyCredential]: permanent DRF - /// tokens don't expose the user-id and the app discovers it lazily via - /// the user-profile endpoint instead. - String? get userId => switch (this) { - LegacyCredential() => null, - JwtCredential(:final accessToken) => decodeJwtPayload(accessToken)?['sub']?.toString(), - }; + /// User identifier carried by the token: its `sub` claim. Decoded on + /// every call, so callers should not hammer it in tight loops. Null when + /// the token isn't decodable or carries no `sub`. + String? get userId => decodeJwtPayload(accessToken)?['sub']?.toString(); } diff --git a/lib/core/network/auth_credential.freezed.dart b/lib/core/network/auth_credential.freezed.dart index 38306d738..507d8e21a 100644 --- a/lib/core/network/auth_credential.freezed.dart +++ b/lib/core/network/auth_credential.freezed.dart @@ -12,37 +12,69 @@ part of 'auth_credential.dart'; // dart format off T _$identity(T value) => value; /// @nodoc -mixin _$AuthCredential { - +mixin _$JwtCredential { + String get accessToken; DateTime? get expiresAt; +/// Create a copy of JwtCredential +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$JwtCredentialCopyWith get copyWith => _$JwtCredentialCopyWithImpl(this as JwtCredential, _$identity); @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthCredential); + return identical(this, other) || (other.runtimeType == runtimeType&&other is JwtCredential&&(identical(other.accessToken, accessToken) || other.accessToken == accessToken)&&(identical(other.expiresAt, expiresAt) || other.expiresAt == expiresAt)); } @override -int get hashCode => runtimeType.hashCode; +int get hashCode => Object.hash(runtimeType,accessToken,expiresAt); @override String toString() { - return 'AuthCredential()'; + return 'JwtCredential(accessToken: $accessToken, expiresAt: $expiresAt)'; } } /// @nodoc -class $AuthCredentialCopyWith<$Res> { -$AuthCredentialCopyWith(AuthCredential _, $Res Function(AuthCredential) __); +abstract mixin class $JwtCredentialCopyWith<$Res> { + factory $JwtCredentialCopyWith(JwtCredential value, $Res Function(JwtCredential) _then) = _$JwtCredentialCopyWithImpl; +@useResult +$Res call({ + String accessToken, DateTime? expiresAt +}); + + + + } +/// @nodoc +class _$JwtCredentialCopyWithImpl<$Res> + implements $JwtCredentialCopyWith<$Res> { + _$JwtCredentialCopyWithImpl(this._self, this._then); + final JwtCredential _self; + final $Res Function(JwtCredential) _then; -/// Adds pattern-matching-related methods to [AuthCredential]. -extension AuthCredentialPatterns on AuthCredential { +/// Create a copy of JwtCredential +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? accessToken = null,Object? expiresAt = freezed,}) { + return _then(_self.copyWith( +accessToken: null == accessToken ? _self.accessToken : accessToken // ignore: cast_nullable_to_non_nullable +as String,expiresAt: freezed == expiresAt ? _self.expiresAt : expiresAt // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [JwtCredential]. +extension JwtCredentialPatterns on JwtCredential { /// A variant of `map` that fallback to returning `orElse`. /// /// It is equivalent to doing: @@ -55,12 +87,11 @@ extension AuthCredentialPatterns on AuthCredential { /// } /// ``` -@optionalTypeArgs TResult maybeMap({TResult Function( LegacyCredential value)? legacy,TResult Function( JwtCredential value)? jwt,required TResult orElse(),}){ +@optionalTypeArgs TResult maybeMap(TResult Function( _JwtCredential value)? $default,{required TResult orElse(),}){ final _that = this; switch (_that) { -case LegacyCredential() when legacy != null: -return legacy(_that);case JwtCredential() when jwt != null: -return jwt(_that);case _: +case _JwtCredential() when $default != null: +return $default(_that);case _: return orElse(); } @@ -78,12 +109,14 @@ return jwt(_that);case _: /// } /// ``` -@optionalTypeArgs TResult map({required TResult Function( LegacyCredential value) legacy,required TResult Function( JwtCredential value) jwt,}){ +@optionalTypeArgs TResult map(TResult Function( _JwtCredential value) $default,){ final _that = this; switch (_that) { -case LegacyCredential(): -return legacy(_that);case JwtCredential(): -return jwt(_that);} +case _JwtCredential(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} } /// A variant of `map` that fallback to returning `null`. /// @@ -97,12 +130,11 @@ return jwt(_that);} /// } /// ``` -@optionalTypeArgs TResult? mapOrNull({TResult? Function( LegacyCredential value)? legacy,TResult? Function( JwtCredential value)? jwt,}){ +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _JwtCredential value)? $default,){ final _that = this; switch (_that) { -case LegacyCredential() when legacy != null: -return legacy(_that);case JwtCredential() when jwt != null: -return jwt(_that);case _: +case _JwtCredential() when $default != null: +return $default(_that);case _: return null; } @@ -119,11 +151,10 @@ return jwt(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen({TResult Function( String token)? legacy,TResult Function( String accessToken, DateTime? expiresAt)? jwt,required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String accessToken, DateTime? expiresAt)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { -case LegacyCredential() when legacy != null: -return legacy(_that.token);case JwtCredential() when jwt != null: -return jwt(_that.accessToken,_that.expiresAt);case _: +case _JwtCredential() when $default != null: +return $default(_that.accessToken,_that.expiresAt);case _: return orElse(); } @@ -141,11 +172,13 @@ return jwt(_that.accessToken,_that.expiresAt);case _: /// } /// ``` -@optionalTypeArgs TResult when({required TResult Function( String token) legacy,required TResult Function( String accessToken, DateTime? expiresAt) jwt,}) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String accessToken, DateTime? expiresAt) $default,) {final _that = this; switch (_that) { -case LegacyCredential(): -return legacy(_that.token);case JwtCredential(): -return jwt(_that.accessToken,_that.expiresAt);} +case _JwtCredential(): +return $default(_that.accessToken,_that.expiresAt);case _: + throw StateError('Unexpected subclass'); + +} } /// A variant of `when` that fallback to returning `null` /// @@ -159,11 +192,10 @@ return jwt(_that.accessToken,_that.expiresAt);} /// } /// ``` -@optionalTypeArgs TResult? whenOrNull({TResult? Function( String token)? legacy,TResult? Function( String accessToken, DateTime? expiresAt)? jwt,}) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String accessToken, DateTime? expiresAt)? $default,) {final _that = this; switch (_that) { -case LegacyCredential() when legacy != null: -return legacy(_that.token);case JwtCredential() when jwt != null: -return jwt(_that.accessToken,_that.expiresAt);case _: +case _JwtCredential() when $default != null: +return $default(_that.accessToken,_that.expiresAt);case _: return null; } @@ -174,90 +206,24 @@ return jwt(_that.accessToken,_that.expiresAt);case _: /// @nodoc -class LegacyCredential extends AuthCredential { - const LegacyCredential(this.token): super._(); +class _JwtCredential extends JwtCredential { + const _JwtCredential({required this.accessToken, this.expiresAt}): super._(); - final String token; +@override final String accessToken; +@override final DateTime? expiresAt; -/// Create a copy of AuthCredential +/// Create a copy of JwtCredential /// with the given fields replaced by the non-null parameter values. -@JsonKey(includeFromJson: false, includeToJson: false) +@override @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') -$LegacyCredentialCopyWith get copyWith => _$LegacyCredentialCopyWithImpl(this, _$identity); +_$JwtCredentialCopyWith<_JwtCredential> get copyWith => __$JwtCredentialCopyWithImpl<_JwtCredential>(this, _$identity); @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is LegacyCredential&&(identical(other.token, token) || other.token == token)); -} - - -@override -int get hashCode => Object.hash(runtimeType,token); - -@override -String toString() { - return 'AuthCredential.legacy(token: $token)'; -} - - -} - -/// @nodoc -abstract mixin class $LegacyCredentialCopyWith<$Res> implements $AuthCredentialCopyWith<$Res> { - factory $LegacyCredentialCopyWith(LegacyCredential value, $Res Function(LegacyCredential) _then) = _$LegacyCredentialCopyWithImpl; -@useResult -$Res call({ - String token -}); - - - - -} -/// @nodoc -class _$LegacyCredentialCopyWithImpl<$Res> - implements $LegacyCredentialCopyWith<$Res> { - _$LegacyCredentialCopyWithImpl(this._self, this._then); - - final LegacyCredential _self; - final $Res Function(LegacyCredential) _then; - -/// Create a copy of AuthCredential -/// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') $Res call({Object? token = null,}) { - return _then(LegacyCredential( -null == token ? _self.token : token // ignore: cast_nullable_to_non_nullable -as String, - )); -} - - -} - -/// @nodoc - - -class JwtCredential extends AuthCredential { - const JwtCredential({required this.accessToken, this.expiresAt}): super._(); - - - final String accessToken; - final DateTime? expiresAt; - -/// Create a copy of AuthCredential -/// with the given fields replaced by the non-null parameter values. -@JsonKey(includeFromJson: false, includeToJson: false) -@pragma('vm:prefer-inline') -$JwtCredentialCopyWith get copyWith => _$JwtCredentialCopyWithImpl(this, _$identity); - - - -@override -bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is JwtCredential&&(identical(other.accessToken, accessToken) || other.accessToken == accessToken)&&(identical(other.expiresAt, expiresAt) || other.expiresAt == expiresAt)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _JwtCredential&&(identical(other.accessToken, accessToken) || other.accessToken == accessToken)&&(identical(other.expiresAt, expiresAt) || other.expiresAt == expiresAt)); } @@ -266,16 +232,16 @@ int get hashCode => Object.hash(runtimeType,accessToken,expiresAt); @override String toString() { - return 'AuthCredential.jwt(accessToken: $accessToken, expiresAt: $expiresAt)'; + return 'JwtCredential(accessToken: $accessToken, expiresAt: $expiresAt)'; } } /// @nodoc -abstract mixin class $JwtCredentialCopyWith<$Res> implements $AuthCredentialCopyWith<$Res> { - factory $JwtCredentialCopyWith(JwtCredential value, $Res Function(JwtCredential) _then) = _$JwtCredentialCopyWithImpl; -@useResult +abstract mixin class _$JwtCredentialCopyWith<$Res> implements $JwtCredentialCopyWith<$Res> { + factory _$JwtCredentialCopyWith(_JwtCredential value, $Res Function(_JwtCredential) _then) = __$JwtCredentialCopyWithImpl; +@override @useResult $Res call({ String accessToken, DateTime? expiresAt }); @@ -285,17 +251,17 @@ $Res call({ } /// @nodoc -class _$JwtCredentialCopyWithImpl<$Res> - implements $JwtCredentialCopyWith<$Res> { - _$JwtCredentialCopyWithImpl(this._self, this._then); +class __$JwtCredentialCopyWithImpl<$Res> + implements _$JwtCredentialCopyWith<$Res> { + __$JwtCredentialCopyWithImpl(this._self, this._then); - final JwtCredential _self; - final $Res Function(JwtCredential) _then; + final _JwtCredential _self; + final $Res Function(_JwtCredential) _then; -/// Create a copy of AuthCredential +/// Create a copy of JwtCredential /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') $Res call({Object? accessToken = null,Object? expiresAt = freezed,}) { - return _then(JwtCredential( +@override @pragma('vm:prefer-inline') $Res call({Object? accessToken = null,Object? expiresAt = freezed,}) { + return _then(_JwtCredential( accessToken: null == accessToken ? _self.accessToken : accessToken // ignore: cast_nullable_to_non_nullable as String,expiresAt: freezed == expiresAt ? _self.expiresAt : expiresAt // ignore: cast_nullable_to_non_nullable as DateTime?, diff --git a/lib/core/network/auth_credentials_storage.dart b/lib/core/network/auth_credentials_storage.dart index 36307b497..12752fefe 100644 --- a/lib/core/network/auth_credentials_storage.dart +++ b/lib/core/network/auth_credentials_storage.dart @@ -27,19 +27,19 @@ import 'package:wger/core/network/secure_token_storage.dart'; import 'package:wger/core/shared_preferences.dart'; /// Credential + server URL pair restored from on-disk storage. The refresh -/// token (for the JWT path) is intentionally absent: it lives in secure -/// storage and is only read when a refresh actually runs. +/// token is intentionally absent: it lives in secure storage and is only +/// read when a refresh actually runs. class StoredAuth { - final AuthCredential credential; + final JwtCredential credential; final String serverUrl; const StoredAuth({required this.credential, required this.serverUrl}); } /// All persistence for the auth flow in one place. Holds the JWT-keyed -/// shared-preference bundle, the legacy `PREFS_USER` blob, and the -/// secure-storage refresh token. Lifts the storage layout details out of -/// the notifier so callers don't have to know which keys back which fact. +/// shared-preference bundle and the secure-storage refresh token. Lifts the +/// storage layout details out of the notifier so callers don't have to know +/// which keys back which fact. class AuthCredentialsStorage { final SecureTokenStorage _secureStorage; final _logger = Logger('AuthCredentialsStorage'); @@ -48,23 +48,10 @@ class AuthCredentialsStorage { SharedPreferencesAsync get _prefs => PreferenceHelper.asyncPref; - /// Reads the persisted credential bundle. The headless-JWT keys take - /// priority over the legacy `PREFS_USER` blob, so a partial migration - /// state still resolves to the JWT path. Returns null when neither - /// shape is fully present. + /// Reads the persisted credential bundle. Returns null when the access + /// token or the server URL is missing, so a half-written bundle resolves + /// to logged-out rather than to a session that cannot make requests. Future load() async { - final jwt = await _readJwt(); - if (jwt != null) { - return jwt; - } - return _readLegacy(); - } - - Future _readJwt() async { - final tokenType = await _prefs.getString(PREFS_TOKEN_TYPE); - if (tokenType != AuthTokenType.headlessJwt.name) { - return null; - } final accessToken = await _prefs.getString(PREFS_ACCESS_TOKEN); final serverUrl = await _prefs.getString(PREFS_SERVER_URL); if (accessToken == null || accessToken.isEmpty || serverUrl == null || serverUrl.isEmpty) { @@ -80,34 +67,10 @@ class AuthCredentialsStorage { ); } - Future _readLegacy() async { - if (!(await _prefs.containsKey(PREFS_USER))) { - return null; - } - final raw = await _prefs.getString(PREFS_USER); - if (raw == null) { - return null; - } - final Map blob; - try { - blob = json.decode(raw) as Map; - } catch (e, s) { - _logger.warning('Could not decode PREFS_USER blob', e, s); - return null; - } - final token = blob['token'] as String?; - final serverUrl = blob['serverUrl'] as String?; - if (token == null || serverUrl == null) { - return null; - } - return StoredAuth(credential: LegacyCredential(token), serverUrl: serverUrl); - } - /// Persists a fresh JWT bundle. As a side effect this records the - /// server URL as the "last server" for the next login screen and wipes - /// the legacy `PREFS_USER` blob (legacy users transition to JWT on first - /// login through this path). The DB-owner marker is intentionally NOT - /// written here; the login flow sets it after any required DB wipe. + /// server URL as the "last server" for the next login screen. The + /// DB-owner marker is intentionally NOT written here; the login flow sets + /// it after any required DB wipe. Future saveJwt({ required JwtCredential credential, required String serverUrl, @@ -120,7 +83,6 @@ class AuthCredentialsStorage { } else { await _prefs.remove(PREFS_ACCESS_EXPIRES_AT); } - await _prefs.setString(PREFS_TOKEN_TYPE, AuthTokenType.headlessJwt.name); await _prefs.setString(PREFS_SERVER_URL, serverUrl); if (refreshToken != null) { @@ -133,13 +95,11 @@ class AuthCredentialsStorage { _logger.warning('Could not persist refresh token, auto-login disabled', e, s); } } - - await clearLegacy(); } /// Updates the persisted JWT bundle in place after a successful refresh. - /// Identical to [saveJwt] minus the legacy-cleanup and last-server side - /// effects (the original login already wrote those). + /// Identical to [saveJwt] minus the last-server side effect (the original + /// login already wrote it). Future updateJwt({ required JwtCredential credential, String? refreshToken, @@ -162,15 +122,18 @@ class AuthCredentialsStorage { } } - /// Wipes the headless-JWT preference bundle and the secure-storage - /// refresh token. Used both for involuntary session clears and as part - /// of a full [clearAll]. The DB-owner marker is deliberately left intact: - /// it tracks who owns the on-disk data, which clearing credentials does - /// not change. It is reset only when the DB is actually wiped. - Future clearJwt() async { + /// Wipes the credential bundle and the secure-storage refresh token, but + /// keeps the "has ever synced" flag so the next login takes the + /// offline-friendly restored-session path. Used for involuntary session + /// loss (refresh token expired, 401 retries exhausted) where the local + /// PowerSync DB is preserved, and as part of a full [clearAll]. + /// + /// The DB-owner marker is deliberately left intact: it tracks who owns + /// the on-disk data, which clearing credentials does not change. It is + /// reset only when the DB is actually wiped. + Future clearCredentials() async { await _prefs.remove(PREFS_ACCESS_TOKEN); await _prefs.remove(PREFS_ACCESS_EXPIRES_AT); - await _prefs.remove(PREFS_TOKEN_TYPE); await _prefs.remove(PREFS_SERVER_URL); try { await _secureStorage.deleteRefreshToken(); @@ -181,21 +144,10 @@ class AuthCredentialsStorage { } } - /// Wipes only the legacy `PREFS_USER` blob. Used by the JWT-migration - /// path on a 401 from the exchange endpoint (DRF token revoked) and as - /// a side effect of [saveJwt]. - Future clearLegacy() async { - await _prefs.remove(PREFS_USER); - } - - /// Wipes both credential shapes but keeps the "has ever synced" flag, - /// so the next login takes the offline-friendly restored-session path. - /// Used for involuntary session loss (refresh token expired, 401 - /// retries exhausted) where the local PowerSync DB is preserved. - Future clearCredentials() async { - await clearLegacy(); - await clearJwt(); - } + /// Removes the pre-JWT credential blob left behind by installs that + /// upgraded from a build using permanent DRF tokens. Best-effort, runs + /// once per app start; can be dropped a couple of releases from now. + Future clearLegacyDrfToken() => _prefs.remove(PREFS_USER); /// Manual-logout wipe: clears credentials plus the "has ever synced" /// flag, so the next login takes the full first-run gating path diff --git a/lib/core/network/auth_http_client.dart b/lib/core/network/auth_http_client.dart index 2ff13fa24..c329df3fe 100644 --- a/lib/core/network/auth_http_client.dart +++ b/lib/core/network/auth_http_client.dart @@ -24,6 +24,7 @@ import 'package:logging/logging.dart'; import 'package:wger/core/error_dialogs.dart'; import 'package:wger/core/network/auth_notifier.dart'; import 'package:wger/core/network/auth_state.dart'; +import 'package:wger/core/network/network_provider.dart'; /// Pre-emptive refresh leeway: if the access JWT will expire within this /// window we refresh before sending the request. Chosen to absorb mild @@ -34,21 +35,19 @@ const refreshLeeway = Duration(seconds: 30); /// authenticated request to the wger backend. /// /// Responsibilities: -/// - Inject the right `Authorization` value for the current credential -/// ([AuthCredential.authHeaderValue] does the dispatch). -/// - For [JwtCredential], pre-emptively refresh when the stored expiry is -/// within [refreshLeeway] of now. -/// - On a 401 reply for a *replayable* [http.Request] body that was sent -/// with a JWT, refresh once and retry. If the retry also returns 401 -/// the session is treated as genuinely revoked: `onSessionExpired` -/// runs (clear credentials + surface a snackbar) and a synthetic 401 -/// is returned to the caller. Non-replayable bodies (multipart / -/// streamed) are not retried; the pre-emptive refresh in the happy -/// path is the primary safeguard. +/// - Inject the `Authorization` value for the current credential. +/// - Pre-emptively refresh when the stored expiry is within +/// [refreshLeeway] of now. +/// - On a 401 reply for a *replayable* [http.Request] body, refresh once +/// and retry with the renewed credential. If the refresh kept the old +/// one (offline carve-out in `_runRefresh`) the 401 goes back without a +/// retry; a 401 on the retry counts as revoked and runs +/// `onSessionExpired`. Non-replayable bodies (multipart / streamed) are +/// not retried. /// /// Wrapped behind [authenticatedHttpClientProvider] so consumers /// (`WgerBaseProvider`, PowerSync's connector) get the auth handling for -/// free without needing to know about the migration state. +/// free. class AuthHttpClient extends http.BaseClient { final http.Client _inner; final AuthState? Function() _readAuth; @@ -79,17 +78,21 @@ class AuthHttpClient extends http.BaseClient { _applyAuthHeader(request, credential); final response = await _inner.send(request); - final canRetry = - response.statusCode == 401 && credential is JwtCredential && request is http.Request; + final canRetry = response.statusCode == 401 && credential != null && request is http.Request; if (!canRetry) { return response; } - _logger.fine('401 on JWT request, refreshing once and retrying'); + _logger.fine('401 on authenticated request, refreshing once and retrying'); await response.stream.drain(); await _refresh(); final fresh = _readAuth()?.credential; - if (fresh is! JwtCredential) { + if (fresh == null) { + return _syntheticUnauthorized(); + } + if (fresh == credential) { + // Offline carve-out kept the token; the same token would 401 again + _logger.fine('Refresh produced no new credential, passing the 401 through'); return _syntheticUnauthorized(); } @@ -110,14 +113,14 @@ class AuthHttpClient extends http.BaseClient { @override void close() => _inner.close(); - void _applyAuthHeader(http.BaseRequest req, AuthCredential? credential) { + void _applyAuthHeader(http.BaseRequest req, JwtCredential? credential) { if (credential == null) { return; } req.headers[HttpHeaders.authorizationHeader] = credential.authHeaderValue; } - http.Request _cloneRequest(http.Request orig, AuthCredential credential) { + http.Request _cloneRequest(http.Request orig, JwtCredential credential) { final retry = http.Request(orig.method, orig.url) ..bodyBytes = orig.bodyBytes ..encoding = orig.encoding diff --git a/lib/core/network/auth_notifier.dart b/lib/core/network/auth_notifier.dart index 3d7124c73..f6ade9af5 100644 --- a/lib/core/network/auth_notifier.dart +++ b/lib/core/network/auth_notifier.dart @@ -18,28 +18,25 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:flutter/widgets.dart' show AppLifecycleListener; -import 'package:flutter_riverpod/flutter_riverpod.dart' show Provider; import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:wger/core/consts.dart'; import 'package:wger/core/error_dialogs.dart'; -import 'package:wger/core/errors.dart'; -import 'package:wger/core/exceptions/http_exception.dart'; import 'package:wger/core/exceptions/mfa_required_exception.dart'; -import 'package:wger/core/helpers.dart'; import 'package:wger/core/http_overrides.dart'; import 'package:wger/core/network/auth_credentials_storage.dart'; import 'package:wger/core/network/auth_http_client.dart'; import 'package:wger/core/network/auth_state.dart'; +import 'package:wger/core/network/headless_auth_api.dart'; import 'package:wger/core/network/jwt.dart'; import 'package:wger/core/network/network_provider.dart'; +import 'package:wger/core/network/powersync_session.dart'; import 'package:wger/core/network/server_gating.dart'; import 'package:wger/core/shared_preferences.dart'; import 'package:wger/database/powersync/powersync.dart'; @@ -52,36 +49,13 @@ import 'package:wger/features/trophies/providers/trophy_notifier.dart'; part 'auth_notifier.g.dart'; -/// `allauth.headless` `app` client endpoints, relative to the -/// `/allauth/app/v1/` base. -const HEADLESS_TOKENS_REFRESH_PATH = 'tokens/refresh'; -const HEADLESS_AUTH_LOGIN_PATH = 'auth/login'; -const HEADLESS_AUTH_SIGNUP_PATH = 'auth/signup'; -const HEADLESS_AUTH_MFA_AUTHENTICATE_PATH = 'auth/2fa/authenticate'; - -/// `/api/v2/` endpoint that mints a headless-JWT refresh token for the -/// authenticated user. Used by the one-shot legacy-DRF → JWT migration on -/// app start. -const ISSUE_REFRESH_TOKEN_PATH = 'issue-refresh-token'; - -/// Header that carries the short-lived `session_token` returned by -/// `auth/login` when a follow-up step (currently only 2FA) is still pending. -const HEADLESS_SESSION_TOKEN_HEADER = 'X-Session-Token'; - -/// HTTP client used by the auth notifier. Override in tests. -final authHttpClientProvider = Provider( - (ref) => ReachabilityReportingClient( - http.Client(), - () => ref.read(networkStatusProvider.notifier), - ), -); - @Riverpod(keepAlive: true) class AuthNotifier extends _$AuthNotifier { final _logger = Logger('AuthNotifier'); - late http.Client _client; + late HeadlessAuthApi _api; late AuthCredentialsStorage _storage; late ServerGating _gating; + late PowerSyncSession _powerSync; /// Holds the in-flight refresh future so concurrent callers share a single /// network roundtrip. Cleared in `whenComplete` so the next refresh starts @@ -93,23 +67,27 @@ class AuthNotifier extends _$AuthNotifier { @visibleForTesting Future? revalidationDone; + /// Listeners armed by [_scheduleRevalidation], replaced on re-arm: the + /// provider is keepAlive, so stacked ones would live for good. + StreamSubscription>? _connectivitySub; + AppLifecycleListener? _lifecycleListener; + /// Number of times the user-switch DB wipe has fired since the notifier /// was built. Exposed so tests can assert the user-mismatch path ran /// without having to instrument PowerSync or the filesystem. @visibleForTesting int userSwitchWipeCount = 0; - /// Stops the PowerSync sync loop on the keep-data session-reset path. - /// Injectable because `PowerSyncDatabase` is a `base` class and cannot be - /// faked in tests. - @visibleForTesting - Future Function() disconnectPowerSync = _disconnectBuiltPowerSync; - @override Future build() async { - _client = ref.read(authHttpClientProvider); + _api = ref.read(headlessAuthApiProvider); _storage = ref.read(authCredentialsStorageProvider); _gating = ref.read(serverGatingProvider); + _powerSync = ref.read(powerSyncSessionProvider); + ref.onDispose(() { + _connectivitySub?.cancel(); + _lifecycleListener?.dispose(); + }); return _tryAutoLogin(); } @@ -128,17 +106,14 @@ class AuthNotifier extends _$AuthNotifier { if (version.tooOld) { return LoginActions.update; } - final body = {'username': username, 'password': password}; - if (email.isNotEmpty) { - body['email'] = email; - } - - final response = await _client.post( - makeHeadlessUri(serverUrl, HEADLESS_AUTH_SIGNUP_PATH), - headers: jsonApiHeaders(appVersion, {HttpHeaders.acceptLanguageHeader: locale}), - body: json.encode(body), + final creds = await _api.signup( + username: username, + password: password, + email: email, + serverUrl: serverUrl, + appVersion: appVersion, + locale: locale, ); - final creds = _consumeHeadlessAuthResponse(response); return _completeLogin(creds, serverUrl, appVersion, serverVersion: version.version); } @@ -194,12 +169,12 @@ class AuthNotifier extends _$AuthNotifier { if (version.tooOld) { return LoginActions.update; } - final response = await _client.post( - makeHeadlessUri(serverUrl, HEADLESS_AUTH_MFA_AUTHENTICATE_PATH), - headers: jsonApiHeaders(appVersion, {HEADLESS_SESSION_TOKEN_HEADER: sessionToken}), - body: json.encode({'code': code}), + final creds = await _api.authenticateMfa( + sessionToken: sessionToken, + code: code, + serverUrl: serverUrl, + appVersion: appVersion, ); - final creds = _consumeHeadlessAuthResponse(response); return _completeLogin(creds, serverUrl, appVersion, serverVersion: version.version); } @@ -231,103 +206,29 @@ class AuthNotifier extends _$AuthNotifier { return gate; } - Future<_FreshCredentials> _obtainCredentials( + /// The credentials a login attempt yields: the pasted refresh token when + /// there is one (rotation invalidates it as part of the exchange), else + /// username and password. + Future _obtainCredentials( String username, String password, String serverUrl, String? pastedRefreshToken, PackageInfo appVersion, - ) async { + ) { if (pastedRefreshToken != null && pastedRefreshToken.isNotEmpty) { - return _exchangePastedRefreshToken(pastedRefreshToken, serverUrl, appVersion); - } - - final response = await _client.post( - makeHeadlessUri(serverUrl, HEADLESS_AUTH_LOGIN_PATH), - headers: jsonApiHeaders(appVersion), - body: json.encode({'username': username, 'password': password}), - ); - return _consumeHeadlessAuthResponse(response); - } - - /// Exchanges a manually-pasted refresh token for a fresh access + refresh - /// bundle. Rotation is on by default server-side, so the pasted token is - /// invalidated as part of this call and the new refresh token is what - /// ends up persisted in secure storage. Throws [WgerHttpException] when - /// the server rejects the pasted token. - Future<_FreshCredentials> _exchangePastedRefreshToken( - String refreshToken, - String serverUrl, - PackageInfo appVersion, - ) async { - final response = await _client.post( - makeHeadlessUri(serverUrl, HEADLESS_TOKENS_REFRESH_PATH), - headers: jsonApiHeaders(appVersion), - body: json.encode({'refresh_token': refreshToken}), - ); - return _consumeHeadlessAuthResponse(response); - } - - /// Parses the standard `allauth.headless` auth response envelope. - /// - /// Returns a populated [_FreshCredentials] on 200 (tokens carried in - /// `meta`). - /// - /// Throws: - /// - [MfaRequiredException] on a 401 that carries `meta.session_token`, - /// signalling that the user must complete a second factor before tokens - /// are issued. - /// - [WgerHttpException] for any other status, or for malformed / partial - /// bodies on otherwise-successful responses. - _FreshCredentials _consumeHeadlessAuthResponse(http.Response response) { - final Map body; - try { - body = json.decode(response.body) as Map; - } catch (_) { - throw WgerHttpException(response); - } - - if (response.statusCode == 401) { - final meta = body['meta'] as Map?; - final sessionToken = meta?['session_token'] as String?; - if (sessionToken != null && sessionToken.isNotEmpty) { - final flows = (body['data'] as Map?)?['flows'] as List?; - final factors = - flows - ?.whereType>() - .where( - (f) => f['id'] == 'mfa_authenticate' && (f['is_pending'] as bool? ?? false), - ) - .expand((f) => (f['types'] as List?)?.cast() ?? const []) - .toList() ?? - const []; - throw MfaRequiredException(sessionToken: sessionToken, availableFactors: factors); - } - throw WgerHttpException(response); - } - - if (response.statusCode != 200) { - throw WgerHttpException(response); + return _api.exchangeRefreshToken( + refreshToken: pastedRefreshToken, + serverUrl: serverUrl, + appVersion: appVersion, + ); } - // auth/login and auth/signup return the tokens under `meta`, while - // tokens/refresh returns them under `data` (see allauth.headless source: - // base/response.py vs tokens/response.py). Read both so this parser - // works for either response shape. - final meta = body['meta'] as Map?; - final data = body['data'] as Map?; - final accessToken = (meta?['access_token'] ?? data?['access_token']) as String?; - if (accessToken == null || accessToken.isEmpty) { - // 200 without tokens, likely a still-pending flow we don't know how - // to drive. Surface as an HTTP error so the caller renders something. - throw WgerHttpException(response); - } - return ( - credential: JwtCredential( - accessToken: accessToken, - expiresAt: jwtExp(decodeJwtPayload(accessToken)), - ), - refreshToken: (meta?['refresh_token'] ?? data?['refresh_token']) as String?, + return _api.login( + username: username, + password: password, + serverUrl: serverUrl, + appVersion: appVersion, ); } @@ -341,7 +242,7 @@ class AuthNotifier extends _$AuthNotifier { /// be uploaded under the new user's credentials, which is both a leak /// and would corrupt data ownership server-side. Future _completeLogin( - _FreshCredentials creds, + FreshCredentials creds, String serverUrl, PackageInfo appVersion, { String? serverVersion, @@ -399,7 +300,11 @@ class AuthNotifier extends _$AuthNotifier { state = AsyncData(newState); if (newState.status == AuthStatus.loggedIn) { - await _reconnectPowerSyncIfBuilt(serverUrl); + _powerSync.reconnect( + serverUrl, + ref.read(authenticatedHttpClientProvider), + ref.read(syncWatchdogProvider), + ); _invalidatePostLoginProviders(); } @@ -428,12 +333,7 @@ class AuthNotifier extends _$AuthNotifier { } Future _tryAutoLogin() async { - // One-shot migration: if a legacy DRF token is still on disk, swap it - // for a JWT bundle now so the rest of the auto-login can take the - // JWT happy path. On any failure the legacy blob is left alone and the - // user falls back to the still-supported legacy code path; the next - // start will try again. - await _maybeMigrateLegacyToJwt(); + await _storage.clearLegacyDrfToken(); final stored = await _storage.load(); if (stored == null) { _logger.info('autologin failed, no saved session'); @@ -443,111 +343,9 @@ class AuthNotifier extends _$AuthNotifier { return _resolveStoredSession(stored, appVersion); } - /// Exchanges a legacy DRF API token for a headless-JWT bundle and - /// persists the result. No-op when no legacy blob is present. - /// - /// Sequence: - /// 1. POST to [ISSUE_REFRESH_TOKEN_PATH] authenticated with the legacy - /// `Token ` header. The server mints a long-lived refresh token - /// backed by a fresh Django session. - /// 2. Exchange that refresh token at the standard headless - /// `tokens/refresh` endpoint for the full access bundle (reuses - /// [_exchangePastedRefreshToken]). - /// 3. Persist the bundle. [AuthCredentialsStorage.saveJwt] wipes the - /// legacy `PREFS_USER` blob as a side effect, so the next load sees - /// the JWT path. - /// - /// Failure handling — all branches log and return without touching - /// state, so a re-attempt happens on the next app start: - /// - Network error: keep the DRF token, the user continues working - /// against the legacy code path until connectivity returns. - /// - 401 / 403: the DRF token has been revoked server-side. Wipe the - /// legacy blob so the user is routed to login (they have no usable - /// credential left). - /// - 5xx / malformed body / refresh exchange failure: keep the legacy - /// blob and retry on the next start. The server-side session row - /// minted in step 1 stays orphaned but is harmless. - Future _maybeMigrateLegacyToJwt() async { - final stored = await _storage.load(); - if (stored == null || stored.credential is! LegacyCredential) { - return; - } - final legacyCred = stored.credential as LegacyCredential; - final serverUrl = stored.serverUrl; - final appVersion = await PackageInfo.fromPlatform(); - - _logger.info('Legacy DRF token present, attempting JWT migration'); - - final http.Response response; - try { - response = await _client.post( - makeUri(serverUrl, ISSUE_REFRESH_TOKEN_PATH, trailingSlash: false), - headers: jsonApiHeaders(appVersion, { - HttpHeaders.authorizationHeader: legacyCred.authHeaderValue, - }), - ); - } on Exception catch (e, s) { - if (isNetworkError(e)) { - _logger.info('Legacy migration: server unreachable, keeping DRF token'); - return; - } - _logger.warning('Legacy migration: exchange POST threw', e, s); - return; - } - - if (_isAuthRejection(response.statusCode)) { - _logger.warning( - 'Legacy migration: DRF token rejected (${response.statusCode}), wiping legacy blob', - ); - await _storage.clearLegacy(); - return; - } - if (response.statusCode != 200) { - _logger.warning( - 'Legacy migration: unexpected status ${response.statusCode}, will retry next start', - ); - return; - } - - final String refreshToken; - try { - final body = json.decode(response.body) as Map; - refreshToken = body['refresh_token'] as String; - } catch (e, s) { - _logger.warning('Legacy migration: malformed response body', e, s); - return; - } - if (refreshToken.isEmpty) { - _logger.warning('Legacy migration: empty refresh_token in response'); - return; - } - - final _FreshCredentials freshCreds; - try { - freshCreds = await _exchangePastedRefreshToken(refreshToken, serverUrl, appVersion); - } on Exception catch (e, s) { - _logger.warning('Legacy migration: refresh token exchange failed', e, s); - return; - } - - await _storage.saveJwt( - credential: freshCreds.credential, - refreshToken: freshCreds.refreshToken, - serverUrl: serverUrl, - ); - // Claim DB ownership for the migrated user: this path logs in without - // going through _completeLogin, so otherwise the marker would stay null - // and a later different-user login wouldn't wipe. - final migratedUserId = freshCreds.credential.userId; - if (migratedUserId != null) { - await _storage.setDbOwnerUserId(migratedUserId); - } - _logger.info('Legacy migration: successful, DRF token replaced with JWT'); - } - - /// Common path for both the headless-JWT and the legacy auto-login flows: - /// probe the server, then run the full gating chain. Wipes the matching - /// stored credentials on a definitive 4xx so the user is routed to login. + /// Auto-login path for a session that has never synced: probe the server, + /// then run the full gating chain. Wipes the stored credentials on a + /// definitive 4xx so the user is routed to login. Future _autoLoginWith(StoredAuth stored, PackageInfo appVersion) async { final response = await _gating.probe( credential: stored.credential, @@ -561,12 +359,12 @@ class AuthNotifier extends _$AuthNotifier { return _restoredSessionState(stored, appVersion); } - // The server actively rejected our token: wipe the matching credential - // bundle and route to login. Only 401/403 count, a transient 5xx must not - // log the user out. + // The server actively rejected our token: wipe the stored credentials and + // route to login. Only 401/403 count, a transient 5xx must not log the + // user out. if (_isAuthRejection(response.statusCode)) { _logger.info('autologin failed, token rejected: ${response.statusCode}'); - await _clearStoredCredential(stored.credential); + await _storage.clearCredentials(); return AuthState(applicationVersion: appVersion); } @@ -613,8 +411,7 @@ class AuthNotifier extends _$AuthNotifier { return _restoredSessionState(stored, appVersion); } - /// Builds a logged-in [AuthState] for a stored session. Polymorphism in - /// [AuthCredential] keeps this branch-free across credential variants. + /// Builds a logged-in [AuthState] for a stored session. AuthState _restoredSessionState(StoredAuth stored, PackageInfo appVersion) { return AuthState( status: AuthStatus.loggedIn, @@ -628,15 +425,6 @@ class AuthNotifier extends _$AuthNotifier { /// opposed to a transient error that must not invalidate the session. bool _isAuthRejection(int statusCode) => statusCode == 401 || statusCode == 403; - /// Clears only the storage rows that back the given credential. Used on a - /// definitive auth-rejection by the server so the next start routes the - /// user to the login screen without touching the *other* credential - /// shape (which a parallel user on the same device might still rely on). - Future _clearStoredCredential(AuthCredential credential) => switch (credential) { - LegacyCredential() => _storage.clearLegacy(), - JwtCredential() => _storage.clearJwt(), - }; - /// Schedules a non-blocking revalidation of the restored session. /// /// The first run is deferred to a fresh event-loop task so [build] has @@ -649,20 +437,21 @@ class AuthNotifier extends _$AuthNotifier { void _scheduleRevalidation() { revalidationDone = Future(_revalidate); - final sub = Connectivity().onConnectivityChanged.listen((results) { + _connectivitySub?.cancel(); + _connectivitySub = Connectivity().onConnectivityChanged.listen((results) { final online = results.any((r) => r != ConnectivityResult.none); if (online) { revalidationDone = _revalidate(); } }); - ref.onDispose(sub.cancel); // A warm resume must also revalidate: the process can stay alive in the // background for days, so the tokens may have expired without any cold // start noticing. The app is offline-first, so without this a dead // session would only surface once some server-backed action happens to // run. Gated on needsRefresh so quick app switches stay request-free. - final lifecycleListener = AppLifecycleListener( + _lifecycleListener?.dispose(); + _lifecycleListener = AppLifecycleListener( onResume: () { final credential = state.asData?.value.credential; if (credential?.needsRefresh(refreshLeeway) ?? false) { @@ -671,7 +460,6 @@ class AuthNotifier extends _$AuthNotifier { } }, ); - ref.onDispose(lifecycleListener.dispose); } /// Revalidates the restored session against the server. Fire-and-forget: it @@ -688,9 +476,7 @@ class AuthNotifier extends _$AuthNotifier { // If the access token has expired (typical after a longer offline // period) refresh first, so a still-valid refresh token isn't wasted // by a 401 on the probe below. The refresh's own failure paths will - // clear the session if the refresh token is also dead. Legacy - // credentials are no-op here ([AuthCredential.needsRefresh] returns - // false for them). + // clear the session if the refresh token is also dead. if (current.credential?.needsRefresh(refreshLeeway) ?? false) { _logger.fine('revalidation: access token within leeway, refreshing first'); await refreshAccessToken(); @@ -828,10 +614,10 @@ class AuthNotifier extends _$AuthNotifier { final appVersion = current.applicationVersion ?? await PackageInfo.fromPlatform(); final http.Response response; try { - response = await _client.post( - makeHeadlessUri(serverUrl, HEADLESS_TOKENS_REFRESH_PATH), - headers: jsonApiHeaders(appVersion), - body: json.encode({'refresh_token': refreshToken}), + response = await _api.postTokenRefresh( + refreshToken: refreshToken, + serverUrl: serverUrl, + appVersion: appVersion, ); } on Exception catch (e, s) { _logger.warning( @@ -886,6 +672,16 @@ class AuthNotifier extends _$AuthNotifier { 'rotated refresh token: ${newRefresh != null}', ); + // A logout, user switch or server change while the request was in flight + // ended the session this result belongs to; persisting it would replant + // the tokens into the freshly cleared storage and republish the session + final latest = _currentOrBlank(); + if (latest.serverUrl != serverUrl || + latest.credential?.accessToken != current.credential?.accessToken) { + _logger.warning('refreshAccessToken: session changed while refreshing, discarding result'); + return; + } + final newCred = JwtCredential(accessToken: newAccess, expiresAt: newExp); await _storage.updateJwt(credential: newCred, refreshToken: newRefresh); state = AsyncData(current.copyWith(credential: newCred)); @@ -934,7 +730,7 @@ class AuthNotifier extends _$AuthNotifier { var wiped = true; if (wipeLocalData) { try { - await _wipeLocalDb(); + await _powerSync.wipe(); } catch (e, s) { _logger.severe('logout wipe failed, keeping owner marker', e, s); wiped = false; @@ -944,7 +740,7 @@ class AuthNotifier extends _$AuthNotifier { // refresh future, and the disconnect can block on a sync fetch that is // itself awaiting that future (refresh -> disconnect -> sync fetch -> // refresh deadlock). The DB is kept, so there is no wipe to race with. - unawaited(disconnectPowerSync()); + unawaited(_powerSync.disconnect()); } state = AsyncData( @@ -969,54 +765,7 @@ class AuthNotifier extends _$AuthNotifier { /// Wipes the local PowerSync data when a different user logs in. Future _wipeOnUserSwitch() async { userSwitchWipeCount++; - await _wipeLocalDb(); - } - - /// Removes the local PowerSync data whether or not the DB has been built: - /// when the instance exists we use PowerSync's own `disconnectAndClear`, - /// otherwise (cold start, before any data widget has built it) we delete - /// the on-disk files directly so no data survives. - /// - /// Throws if the wipe fails. Callers must abort before advancing the DB - /// owner marker, otherwise the previous user's data stay on disk - Future _wipeLocalDb() async { - final db = builtPowerSyncInstance; - if (db != null) { - try { - await db.disconnectAndClear(); - } catch (e, s) { - _logger.severe('local DB wipe via disconnectAndClear failed', e, s); - rethrow; - } - return; - } - try { - await deletePowerSyncDatabaseFile(); - } catch (e, s) { - _logger.severe('local DB wipe via file delete failed', e, s); - rethrow; - } - } - - /// Reconnects an already-built PowerSync DB with a fresh connector for the - /// given [serverUrl]. No-op when PowerSync hasn't been built yet: the next - /// access will build it with the current (post-login) auth state. - Future _reconnectPowerSyncIfBuilt(String serverUrl) async { - final db = builtPowerSyncInstance; - if (db == null) { - return; - } - try { - connectPowerSync( - db, - serverUrl, - ref.read(authenticatedHttpClientProvider), - ref.read(syncWatchdogProvider), - reason: 'login completed', - ); - } catch (e, s) { - _logger.warning('PowerSync reconnect failed', e, s); - } + await _powerSync.wipe(); } /// Refreshes the server version into the state. @@ -1040,49 +789,3 @@ class AuthNotifier extends _$AuthNotifier { return userData['serverUrl'] as String; } } - -/// In-memory bundle returned by the login / signup flows. Always carries a -/// [JwtCredential] (fresh logins go through `allauth.headless`); the refresh -/// token is the one the caller still needs to write to secure storage. -typedef _FreshCredentials = ({JwtCredential credential, String? refreshToken}); - -/// Default for [AuthNotifier.disconnectPowerSync]: disconnects an -/// already-built PowerSync DB but keeps its data on disk. No-op when -/// PowerSync hasn't been built yet. -Future _disconnectBuiltPowerSync() async { - final db = builtPowerSyncInstance; - if (db == null) { - return; - } - try { - await db.disconnect(); - } catch (e, s) { - Logger('AuthNotifier').warning('PowerSync disconnect failed', e, s); - } -} - -/// User-agent header string identifying the app/version/platform. -String getAppNameHeader(PackageInfo? applicationVersion) { - String out = ''; - if (applicationVersion != null) { - out = - '/${applicationVersion.version} ' - '(${applicationVersion.packageName}; ' - 'build: ${applicationVersion.buildNumber}; ' - 'platform: ${Platform.operatingSystem})' - ' - https://github.com/wger-project'; - } - return 'wger App$out'; -} - -/// Standard JSON-API headers for the auth endpoints. The `Accept` header keeps -/// a bot wall (e.g. Anubis) from serving an HTML challenge to these endpoints -/// instead of JSON. [extra] adds per-request headers (session token, auth, ...). -Map jsonApiHeaders(PackageInfo? appVersion, [Map? extra]) { - return { - HttpHeaders.contentTypeHeader: 'application/json; charset=utf-8', - HttpHeaders.acceptHeader: 'application/json', - HttpHeaders.userAgentHeader: getAppNameHeader(appVersion), - ...?extra, - }; -} diff --git a/lib/core/network/auth_notifier.g.dart b/lib/core/network/auth_notifier.g.dart index 0251cb9cc..1273b1f08 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'4aa4190b882d8e6708ef6bcc75df232ea73d2e43'; +String _$authNotifierHash() => r'20634c92164a774060760f66edbe3357cf219279'; abstract class _$AuthNotifier extends $AsyncNotifier { FutureOr build(); diff --git a/lib/core/network/auth_state.dart b/lib/core/network/auth_state.dart index 6cc5b3d72..56170b5b8 100644 --- a/lib/core/network/auth_state.dart +++ b/lib/core/network/auth_state.dart @@ -45,24 +45,11 @@ enum LoginActions { proceed, } -/// Storage-layer discriminator for the persisted credential bundle. Lives in -/// `PREFS_TOKEN_TYPE` so auto-login can tell which set of preference keys to -/// read on startup. Runtime code should branch on the [AuthCredential] -/// subtype (`LegacyCredential` / `JwtCredential`) instead. -enum AuthTokenType { - /// Permanent DRF token persisted under `PREFS_USER`. - legacyApiToken, - - /// Headless JWT bundle persisted under the `PREFS_ACCESS_TOKEN` family of - /// keys; refresh token lives in secure storage. - headlessJwt, -} - @freezed sealed class AuthState with _$AuthState { const factory AuthState({ @Default(AuthStatus.loggedOut) AuthStatus status, - AuthCredential? credential, + JwtCredential? credential, String? serverUrl, String? serverVersion, PackageInfo? applicationVersion, diff --git a/lib/core/network/auth_state.freezed.dart b/lib/core/network/auth_state.freezed.dart index 56028625b..f2b2cb40d 100644 --- a/lib/core/network/auth_state.freezed.dart +++ b/lib/core/network/auth_state.freezed.dart @@ -14,7 +14,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$AuthState { - AuthStatus get status; AuthCredential? get credential; String? get serverUrl; String? get serverVersion; PackageInfo? get applicationVersion; bool get serverConfigWarning;/// True when the previous session ended involuntarily (expired or + AuthStatus get status; JwtCredential? get credential; String? get serverUrl; String? get serverVersion; PackageInfo? get applicationVersion; bool get serverConfigWarning;/// True when the previous session ended involuntarily (expired or /// revoked tokens). The login screen shows a hint so the user knows /// why they have to log in again. Reset by the next login, which /// builds a fresh state. @@ -49,11 +49,11 @@ abstract mixin class $AuthStateCopyWith<$Res> { factory $AuthStateCopyWith(AuthState value, $Res Function(AuthState) _then) = _$AuthStateCopyWithImpl; @useResult $Res call({ - AuthStatus status, AuthCredential? credential, String? serverUrl, String? serverVersion, PackageInfo? applicationVersion, bool serverConfigWarning, bool sessionExpired + AuthStatus status, JwtCredential? credential, String? serverUrl, String? serverVersion, PackageInfo? applicationVersion, bool serverConfigWarning, bool sessionExpired }); -$AuthCredentialCopyWith<$Res>? get credential; +$JwtCredentialCopyWith<$Res>? get credential; } /// @nodoc @@ -70,7 +70,7 @@ class _$AuthStateCopyWithImpl<$Res> return _then(_self.copyWith( status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable as AuthStatus,credential: freezed == credential ? _self.credential : credential // ignore: cast_nullable_to_non_nullable -as AuthCredential?,serverUrl: freezed == serverUrl ? _self.serverUrl : serverUrl // ignore: cast_nullable_to_non_nullable +as JwtCredential?,serverUrl: freezed == serverUrl ? _self.serverUrl : serverUrl // ignore: cast_nullable_to_non_nullable as String?,serverVersion: freezed == serverVersion ? _self.serverVersion : serverVersion // ignore: cast_nullable_to_non_nullable as String?,applicationVersion: freezed == applicationVersion ? _self.applicationVersion : applicationVersion // ignore: cast_nullable_to_non_nullable as PackageInfo?,serverConfigWarning: null == serverConfigWarning ? _self.serverConfigWarning : serverConfigWarning // ignore: cast_nullable_to_non_nullable @@ -82,12 +82,12 @@ as bool, /// with the given fields replaced by the non-null parameter values. @override @pragma('vm:prefer-inline') -$AuthCredentialCopyWith<$Res>? get credential { +$JwtCredentialCopyWith<$Res>? get credential { if (_self.credential == null) { return null; } - return $AuthCredentialCopyWith<$Res>(_self.credential!, (value) { + return $JwtCredentialCopyWith<$Res>(_self.credential!, (value) { return _then(_self.copyWith(credential: value)); }); } @@ -169,7 +169,7 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( AuthStatus status, AuthCredential? credential, String? serverUrl, String? serverVersion, PackageInfo? applicationVersion, bool serverConfigWarning, bool sessionExpired)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( AuthStatus status, JwtCredential? credential, String? serverUrl, String? serverVersion, PackageInfo? applicationVersion, bool serverConfigWarning, bool sessionExpired)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _AuthState() when $default != null: return $default(_that.status,_that.credential,_that.serverUrl,_that.serverVersion,_that.applicationVersion,_that.serverConfigWarning,_that.sessionExpired);case _: @@ -190,7 +190,7 @@ return $default(_that.status,_that.credential,_that.serverUrl,_that.serverVersio /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( AuthStatus status, AuthCredential? credential, String? serverUrl, String? serverVersion, PackageInfo? applicationVersion, bool serverConfigWarning, bool sessionExpired) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( AuthStatus status, JwtCredential? credential, String? serverUrl, String? serverVersion, PackageInfo? applicationVersion, bool serverConfigWarning, bool sessionExpired) $default,) {final _that = this; switch (_that) { case _AuthState(): return $default(_that.status,_that.credential,_that.serverUrl,_that.serverVersion,_that.applicationVersion,_that.serverConfigWarning,_that.sessionExpired);} @@ -207,7 +207,7 @@ return $default(_that.status,_that.credential,_that.serverUrl,_that.serverVersio /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( AuthStatus status, AuthCredential? credential, String? serverUrl, String? serverVersion, PackageInfo? applicationVersion, bool serverConfigWarning, bool sessionExpired)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( AuthStatus status, JwtCredential? credential, String? serverUrl, String? serverVersion, PackageInfo? applicationVersion, bool serverConfigWarning, bool sessionExpired)? $default,) {final _that = this; switch (_that) { case _AuthState() when $default != null: return $default(_that.status,_that.credential,_that.serverUrl,_that.serverVersion,_that.applicationVersion,_that.serverConfigWarning,_that.sessionExpired);case _: @@ -226,7 +226,7 @@ class _AuthState extends AuthState { @override@JsonKey() final AuthStatus status; -@override final AuthCredential? credential; +@override final JwtCredential? credential; @override final String? serverUrl; @override final String? serverVersion; @override final PackageInfo? applicationVersion; @@ -267,11 +267,11 @@ abstract mixin class _$AuthStateCopyWith<$Res> implements $AuthStateCopyWith<$Re factory _$AuthStateCopyWith(_AuthState value, $Res Function(_AuthState) _then) = __$AuthStateCopyWithImpl; @override @useResult $Res call({ - AuthStatus status, AuthCredential? credential, String? serverUrl, String? serverVersion, PackageInfo? applicationVersion, bool serverConfigWarning, bool sessionExpired + AuthStatus status, JwtCredential? credential, String? serverUrl, String? serverVersion, PackageInfo? applicationVersion, bool serverConfigWarning, bool sessionExpired }); -@override $AuthCredentialCopyWith<$Res>? get credential; +@override $JwtCredentialCopyWith<$Res>? get credential; } /// @nodoc @@ -288,7 +288,7 @@ class __$AuthStateCopyWithImpl<$Res> return _then(_AuthState( status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable as AuthStatus,credential: freezed == credential ? _self.credential : credential // ignore: cast_nullable_to_non_nullable -as AuthCredential?,serverUrl: freezed == serverUrl ? _self.serverUrl : serverUrl // ignore: cast_nullable_to_non_nullable +as JwtCredential?,serverUrl: freezed == serverUrl ? _self.serverUrl : serverUrl // ignore: cast_nullable_to_non_nullable as String?,serverVersion: freezed == serverVersion ? _self.serverVersion : serverVersion // ignore: cast_nullable_to_non_nullable as String?,applicationVersion: freezed == applicationVersion ? _self.applicationVersion : applicationVersion // ignore: cast_nullable_to_non_nullable as PackageInfo?,serverConfigWarning: null == serverConfigWarning ? _self.serverConfigWarning : serverConfigWarning // ignore: cast_nullable_to_non_nullable @@ -301,12 +301,12 @@ as bool, /// with the given fields replaced by the non-null parameter values. @override @pragma('vm:prefer-inline') -$AuthCredentialCopyWith<$Res>? get credential { +$JwtCredentialCopyWith<$Res>? get credential { if (_self.credential == null) { return null; } - return $AuthCredentialCopyWith<$Res>(_self.credential!, (value) { + return $JwtCredentialCopyWith<$Res>(_self.credential!, (value) { return _then(_self.copyWith(credential: value)); }); } diff --git a/lib/core/network/base_provider.dart b/lib/core/network/base_provider.dart index 6e1168845..85a9f437f 100644 --- a/lib/core/network/base_provider.dart +++ b/lib/core/network/base_provider.dart @@ -26,7 +26,7 @@ import 'package:logging/logging.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:wger/core/exceptions/http_exception.dart'; import 'package:wger/core/helpers.dart'; -import 'package:wger/core/network/auth_notifier.dart' show getAppNameHeader; +import 'package:wger/core/network/api_headers.dart' show getAppNameHeader; /// default timeout for GET requests const DEFAULT_TIMEOUT = Duration(seconds: 15); diff --git a/lib/core/network/headless_auth_api.dart b/lib/core/network/headless_auth_api.dart new file mode 100644 index 000000000..0ca5ab645 --- /dev/null +++ b/lib/core/network/headless_auth_api.dart @@ -0,0 +1,215 @@ +/* + * 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 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart' show Provider; +import 'package:http/http.dart' as http; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:wger/core/exceptions/http_exception.dart'; +import 'package:wger/core/exceptions/mfa_required_exception.dart'; +import 'package:wger/core/helpers.dart'; +import 'package:wger/core/network/api_headers.dart'; +import 'package:wger/core/network/auth_credential.dart'; +import 'package:wger/core/network/jwt.dart'; +import 'package:wger/core/network/network_provider.dart'; + +/// `allauth.headless` `app` client endpoints, relative to the +/// `/allauth/app/v1/` base. +const HEADLESS_TOKENS_REFRESH_PATH = 'tokens/refresh'; +const HEADLESS_AUTH_LOGIN_PATH = 'auth/login'; +const HEADLESS_AUTH_SIGNUP_PATH = 'auth/signup'; +const HEADLESS_AUTH_MFA_AUTHENTICATE_PATH = 'auth/2fa/authenticate'; + +/// Header that carries the short-lived `session_token` returned by +/// `auth/login` when a follow-up step (currently only 2FA) is still pending. +const HEADLESS_SESSION_TOKEN_HEADER = 'X-Session-Token'; + +/// In-memory bundle returned by the login / signup flows. Always carries a +/// [JwtCredential] (fresh logins go through `allauth.headless`); the refresh +/// token is the one the caller still needs to write to secure storage. +typedef FreshCredentials = ({JwtCredential credential, String? refreshToken}); + +/// The `allauth.headless` endpoints the app authenticates against: which URL +/// carries which body, and how the response envelope is read. +/// +/// Knows nothing about the session it produces credentials for; what happens +/// with them (persisting, gating, publishing the state) is the notifier's. +class HeadlessAuthApi { + final http.Client _client; + + HeadlessAuthApi(this._client); + + /// Registers a user, who is logged in by the same call: the response + /// already carries the access + refresh tokens. + Future signup({ + required String username, + required String password, + required String email, + required String serverUrl, + required PackageInfo appVersion, + String locale = 'en', + }) async { + final body = {'username': username, 'password': password}; + if (email.isNotEmpty) { + body['email'] = email; + } + + return _post( + serverUrl, + HEADLESS_AUTH_SIGNUP_PATH, + appVersion, + body, + extraHeaders: {HttpHeaders.acceptLanguageHeader: locale}, + ); + } + + /// Logs in with username and password. A pending second factor surfaces as + /// [MfaRequiredException], which the caller answers with [authenticateMfa]. + Future login({ + required String username, + required String password, + required String serverUrl, + required PackageInfo appVersion, + }) => _post(serverUrl, HEADLESS_AUTH_LOGIN_PATH, appVersion, { + 'username': username, + 'password': password, + }); + + /// Answers a pending second-factor challenge with [code] (a TOTP or a + /// recovery code) and the session token the challenge came with. + Future authenticateMfa({ + required String sessionToken, + required String code, + required String serverUrl, + required PackageInfo appVersion, + }) => _post( + serverUrl, + HEADLESS_AUTH_MFA_AUTHENTICATE_PATH, + appVersion, + {'code': code}, + extraHeaders: {HEADLESS_SESSION_TOKEN_HEADER: sessionToken}, + ); + + /// Exchanges a refresh token for a fresh bundle. Rotation is on by default + /// server-side, so the token handed in is invalidated by this call and the + /// new one is what the caller has to persist. + Future exchangeRefreshToken({ + required String refreshToken, + required String serverUrl, + required PackageInfo appVersion, + }) => _post(serverUrl, HEADLESS_TOKENS_REFRESH_PATH, appVersion, { + 'refresh_token': refreshToken, + }); + + /// The raw token refresh, for the background path that tells a network + /// error, a rejection and a malformed body apart, since each means + /// something different for the session it is refreshing. + Future postTokenRefresh({ + required String refreshToken, + required String serverUrl, + required PackageInfo appVersion, + }) => _client.post( + makeHeadlessUri(serverUrl, HEADLESS_TOKENS_REFRESH_PATH), + headers: jsonApiHeaders(appVersion), + body: json.encode({'refresh_token': refreshToken}), + ); + + Future _post( + String serverUrl, + String path, + PackageInfo appVersion, + Map body, { + Map? extraHeaders, + }) async { + final response = await _client.post( + makeHeadlessUri(serverUrl, path), + headers: jsonApiHeaders(appVersion, extraHeaders), + body: json.encode(body), + ); + return consumeAuthResponse(response); + } + + /// Parses the standard `allauth.headless` auth response envelope. + /// + /// Returns a populated [FreshCredentials] on 200 (tokens carried in + /// `meta`). + /// + /// Throws: + /// - [MfaRequiredException] on a 401 that carries `meta.session_token`, + /// signalling that the user must complete a second factor before tokens + /// are issued. + /// - [WgerHttpException] for any other status, or for malformed / partial + /// bodies on otherwise-successful responses. + static FreshCredentials consumeAuthResponse(http.Response response) { + final Map body; + try { + body = json.decode(response.body) as Map; + } catch (_) { + throw WgerHttpException(response); + } + + if (response.statusCode == 401) { + final meta = body['meta'] as Map?; + final sessionToken = meta?['session_token'] as String?; + if (sessionToken != null && sessionToken.isNotEmpty) { + final flows = (body['data'] as Map?)?['flows'] as List?; + final factors = + flows + ?.whereType>() + .where( + (f) => f['id'] == 'mfa_authenticate' && (f['is_pending'] as bool? ?? false), + ) + .expand((f) => (f['types'] as List?)?.cast() ?? const []) + .toList() ?? + const []; + throw MfaRequiredException(sessionToken: sessionToken, availableFactors: factors); + } + throw WgerHttpException(response); + } + + if (response.statusCode != 200) { + throw WgerHttpException(response); + } + + // auth/login and auth/signup return the tokens under `meta`, while + // tokens/refresh returns them under `data` (see allauth.headless source: + // base/response.py vs tokens/response.py). Read both so this parser + // works for either response shape. + final meta = body['meta'] as Map?; + final data = body['data'] as Map?; + final accessToken = (meta?['access_token'] ?? data?['access_token']) as String?; + if (accessToken == null || accessToken.isEmpty) { + // 200 without tokens, likely a still-pending flow we don't know how + // to drive. Surface as an HTTP error so the caller renders something. + throw WgerHttpException(response); + } + return ( + credential: JwtCredential( + accessToken: accessToken, + expiresAt: jwtExp(decodeJwtPayload(accessToken)), + ), + refreshToken: (meta?['refresh_token'] ?? data?['refresh_token']) as String?, + ); + } +} + +final headlessAuthApiProvider = Provider( + (ref) => HeadlessAuthApi(ref.read(authHttpClientProvider)), +); diff --git a/lib/core/network/network_provider.dart b/lib/core/network/network_provider.dart index 5b00041ab..9d7566d6d 100644 --- a/lib/core/network/network_provider.dart +++ b/lib/core/network/network_provider.dart @@ -22,6 +22,7 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart' show Provider; import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -342,6 +343,16 @@ class NetworkStatus extends _$NetworkStatus { } } +/// Raw HTTP client of the auth endpoints: deliberately unauthenticated +/// (login, refresh, version probe), so it does not recurse through the +/// authenticated wrapper. Override in tests. +final authHttpClientProvider = Provider( + (ref) => ReachabilityReportingClient( + http.Client(), + () => ref.read(networkStatusProvider.notifier), + ), +); + /// Feeds every request outcome into [NetworkStatus]: any response means the /// backend was reached (4xx/5xx included), a network error means it was not. /// Wrapped around the raw client, so auth traffic counts as a probe too. diff --git a/lib/core/network/powersync_session.dart b/lib/core/network/powersync_session.dart new file mode 100644 index 000000000..7f6258be5 --- /dev/null +++ b/lib/core/network/powersync_session.dart @@ -0,0 +1,98 @@ +/* + * 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:flutter_riverpod/flutter_riverpod.dart' show Provider; +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; +import 'package:wger/core/shared_preferences.dart'; +import 'package:wger/database/powersync/powersync.dart'; +import 'package:wger/features/account/providers/timezone_sync.dart'; +import 'package:wger/powersync/sync_watchdog.dart'; + +/// What a session change does to the local PowerSync database: disconnect it, +/// reconnect it with new credentials, or remove its data. +/// +/// A seam as much as a grouping: `PowerSyncDatabase` is a `base` class and +/// cannot be faked, so a test that would otherwise touch the real singleton +/// overrides [powerSyncSessionProvider] instead. +class PowerSyncSession { + final _logger = Logger('PowerSyncSession'); + + /// Disconnects an already-built DB but keeps its data on disk. No-op when + /// PowerSync hasn't been built yet. + Future disconnect() async { + final db = builtPowerSyncInstance; + if (db == null) { + return; + } + try { + await db.disconnect(); + } catch (e, s) { + _logger.warning('PowerSync disconnect failed', e, s); + } + } + + /// Reconnects an already-built DB with a fresh connector for [serverUrl]. + /// No-op when PowerSync hasn't been built yet: the next access will build + /// it with the current (post-login) auth state. + void reconnect(String serverUrl, http.Client client, SyncStreamWatchdog watchdog) { + final db = builtPowerSyncInstance; + if (db == null) { + return; + } + try { + connectPowerSync(db, serverUrl, client, watchdog, reason: 'login completed'); + } catch (e, s) { + _logger.warning('PowerSync reconnect failed', e, s); + } + } + + /// Removes the local data whether or not the DB has been built: when the + /// instance exists we use PowerSync's own `disconnectAndClear`, otherwise + /// (cold start, before any data widget has built it) we delete the on-disk + /// files directly so no data survives. + /// + /// Throws if the wipe fails. Callers must abort before advancing the DB + /// owner marker, otherwise the previous user's data stay on disk + Future wipe() async { + final db = builtPowerSyncInstance; + if (db != null) { + try { + await db.disconnectAndClear(); + } catch (e, s) { + _logger.severe('local DB wipe via disconnectAndClear failed', e, s); + rethrow; + } + } else { + try { + await deletePowerSyncDatabaseFile(); + } catch (e, s) { + _logger.severe('local DB wipe via file delete failed', e, s); + rethrow; + } + } + + // The account-scoped preferences leave with the data: the next account + // must not inherit the health-sync opt-in and watermarks, nor a timezone + // marker that would let the sync overwrite its chosen zone + await PreferenceHelper.instance.clearHealthSyncPreferences(); + await PreferenceHelper.asyncPref.remove(reportedTimezonePrefKey); + } +} + +final powerSyncSessionProvider = Provider((ref) => PowerSyncSession()); diff --git a/lib/core/network/server_gating.dart b/lib/core/network/server_gating.dart index 188d6be83..a55ccba13 100644 --- a/lib/core/network/server_gating.dart +++ b/lib/core/network/server_gating.dart @@ -27,9 +27,10 @@ import 'package:version/version.dart'; import 'package:wger/core/consts.dart'; import 'package:wger/core/errors.dart'; import 'package:wger/core/helpers.dart'; +import 'package:wger/core/network/api_headers.dart'; import 'package:wger/core/network/auth_credentials_storage.dart'; -import 'package:wger/core/network/auth_notifier.dart'; import 'package:wger/core/network/auth_state.dart'; +import 'package:wger/core/network/network_provider.dart'; /// `/api/v2/` endpoints used by the gating chain. const _MIN_APP_VERSION_PATH = 'min-app-version'; @@ -49,7 +50,7 @@ class ServerGating { /// separately via [serverVersionGate], so callers check the version once and /// pass it into the auth state themselves. Future resolve({ - required AuthCredential credential, + required JwtCredential credential, required String serverUrl, required PackageInfo appVersion, }) async { @@ -77,7 +78,7 @@ class ServerGating { /// can distinguish "we couldn't reach the server" from "the server said /// no". Only the latter is grounds for logging the user out. Future probe({ - required AuthCredential credential, + required JwtCredential credential, required String serverUrl, required PackageInfo appVersion, }) async { @@ -106,7 +107,7 @@ class ServerGating { /// completed conclusively, so the caller defaults to permissive). Future serverConfigSane({ required String serverUrl, - required AuthCredential credential, + required JwtCredential credential, }) async { try { final baseUri = Uri.parse(serverUrl); @@ -143,7 +144,7 @@ class ServerGating { /// success the flag is set so future starts skip the probe. Future isPowerSyncReachable({ required String serverUrl, - required AuthCredential credential, + required JwtCredential credential, }) async { if (await _storage.hasEverSynced()) { return true; diff --git a/lib/core/shared_preferences.dart b/lib/core/shared_preferences.dart index de3ed3b16..33eaf1cf0 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,88 @@ class PreferenceHelper { orElse: () => fallback.searchMode, ); } + + // --- Health sync --- + + static const _healthSyncEnabledKey = 'healthSyncEnabled'; + static const _healthSyncWatermarksKey = 'healthSyncWatermarks'; + static const _healthSyncReadableTypesKey = 'healthSyncReadableTypes'; + static const _healthSyncEmptyMetricsKey = 'healthSyncEmptyMetrics'; + static const _healthSyncLastRunKey = 'healthSyncLastRun'; + + 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); + } + + /// When the last import finished, as an ISO-8601 timestamp. + /// + /// Persisted rather than kept in memory: the automatic syncs are throttled + /// against it, and an app restart would otherwise always look like the first + /// run of the day. + Future setHealthSyncLastRun(DateTime value) async { + await PreferenceHelper.asyncPref.setString(_healthSyncLastRunKey, value.toIso8601String()); + } + + Future getHealthSyncLastRun() async { + final stored = await PreferenceHelper.asyncPref.getString(_healthSyncLastRunKey); + return stored == null ? null : DateTime.tryParse(stored); + } + + Future clearHealthSyncPreferences() async { + await PreferenceHelper.asyncPref.remove(_healthSyncEnabledKey); + await PreferenceHelper.asyncPref.remove(_healthSyncWatermarksKey); + await PreferenceHelper.asyncPref.remove(_healthSyncReadableTypesKey); + await PreferenceHelper.asyncPref.remove(_healthSyncEmptyMetricsKey); + await PreferenceHelper.asyncPref.remove(_healthSyncLastRunKey); + } } diff --git a/lib/core/widgets/about.dart b/lib/core/widgets/about.dart index 144308fa0..b7ae7cdc8 100644 --- a/lib/core/widgets/about.dart +++ b/lib/core/widgets/about.dart @@ -23,6 +23,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:wger/core/consts.dart'; import 'package:wger/core/errors.dart' show buildGithubIssueUrl; +import 'package:wger/core/helpers.dart' show makePageUri; import 'package:wger/core/logs.dart'; import 'package:wger/core/misc.dart'; import 'package:wger/core/network/auth_notifier.dart'; @@ -60,6 +61,10 @@ class AboutPage extends ConsumerWidget { final i18n = AppLocalizations.of(context); final today = DateTime.now(); + // The terms are the server operator's, not ours, so point at the instance + // the user is actually connected to + final serverUrl = authState?.serverUrl; + return Scaffold( appBar: AppBar(title: Text(i18n.aboutPageTitle)), body: WidescreenWrapper( @@ -257,6 +262,15 @@ class AboutPage extends ConsumerWidget { contentPadding: EdgeInsets.zero, onTap: () => launchURL(READTHEDOCS_URL, context), ), + if (serverUrl != null) + ListTile( + leading: const Icon(Icons.privacy_tip), + trailing: const Icon(Icons.arrow_outward), + title: Text(i18n.aboutPrivacyPolicyTitle), + contentPadding: EdgeInsets.zero, + onTap: () => + launchURL(makePageUri(serverUrl, TERMS_OF_SERVICE_PATH).toString(), context), + ), ], ), ), diff --git a/lib/core/widgets/dashboard/calendar.dart b/lib/core/widgets/dashboard/calendar.dart index 719a2a4ae..68dd3ef3b 100644 --- a/lib/core/widgets/dashboard/calendar.dart +++ b/lib/core/widgets/dashboard/calendar.dart @@ -24,14 +24,16 @@ 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/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,40 +80,56 @@ 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, + required String? ownerZone, }) { - 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'), ); } } for (final session in sessions) { - final date = DateFormatLists.format(session.date); + final date = DateFormatLists.format(session.localDayIn(ownerZone)); events.putIfAbsent(date, () => []); var time = ''; - if (session.timeStart != null && session.timeEnd != null) { - time = '(${timeToString(session.timeStart)} - ${timeToString(session.timeEnd)})'; + if (session.datetimeEnd != null) { + time = + '(${timeToString(TimeOfDay.fromDateTime(session.datetimeStart))} - ${timeToString(TimeOfDay.fromDateTime(session.datetimeEnd!))})'; } events[date]!.add( Event( @@ -187,23 +205,30 @@ 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..0f7c4fa9d 100644 --- a/lib/core/widgets/dashboard/widgets/weight.dart +++ b/lib/core/widgets/dashboard/widgets/weight.dart @@ -19,21 +19,24 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:wger/core/form_screen.dart'; +import 'package:logging/logging.dart'; +import 'package:wger/core/formatting/formatting.dart'; import 'package:wger/core/widgets/dashboard/widgets/nothing_found.dart'; 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/widgets/charts.dart'; +import 'package:wger/features/measurements/charts/range.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/categories_card.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 { + static final _logger = Logger('DashboardWeightWidget'); + const DashboardWeightWidget(); Widget _shell(BuildContext context, Widget body) { @@ -57,115 +60,89 @@ class DashboardWeightWidget extends ConsumerWidget { ); } + /// The error state of one of the providers the card resolves, logged the way + /// `AsyncValueWidget` logs the cards that only resolve a single one. + Widget _errorShell(BuildContext context, AsyncValue value) { + _logger.warning('Async error in DashboardWeightWidget', value.error, value.stackTrace); + + return _shell(context, StreamErrorIndicator(value.error!, stacktrace: value.stackTrace)); + } + @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) { - return _shell( - context, - StreamErrorIndicator(entriesAsync.error!, stacktrace: entriesAsync.stackTrace), - ); + if (categoryAsync.hasError) { + return _errorShell(context, categoryAsync); } if (profileAsync.hasError) { - return _shell( - context, - StreamErrorIndicator(profileAsync.error!, stacktrace: profileAsync.stackTrace), - ); + return _errorShell(context, profileAsync); } final profile = profileAsync.value; if (profile == null) { // The profile stream can legitimately emit null right after login, // before the local `user_profile` PowerSync bucket has finished its - // first sync (see WeightOverview / NutritionalPlansList, which treat - // this the same way). Keep showing the spinner instead of a - // permanent-looking error; the widget rebuilds once the row lands. + // first sync (see NutritionalPlansList, which treats this the same + // way). Keep showing the spinner instead of a permanent-looking error; + // the widget rebuilds once the row lands. return _shell(context, const BoxedProgressIndicator()); } - - return _shell(context, _buildContent(context, entriesAsync.value!, profile)); - } - - Widget _buildContent( - BuildContext context, - List entriesList, - UserProfile profile, - ) { - if (entriesList.isEmpty) { - return NothingFound( - AppLocalizations.of(context).noWeightEntries, - AppLocalizations.of(context).newEntry, - WeightForm(), - ); + 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()); } - final (entriesAll, entries7dAvg) = sensibleRange( - entriesList.map((e) => MeasurementChartEntry(e.weight, e.date)).toList(), + // The same watch the card below runs (one underlying stream), resolved + // here as well so the empty state and errors render dashboard-style + final pointsAsync = chartPointsFor( + ref, + category, + ChartRange.all, + targetUnit: weightDisplayUnit(profile.isMetric), ); - - return Column( - children: [ - SizedBox( - height: 200, - child: MeasurementChartWidgetFl( - entriesAll, - weightUnit(profile.isMetric, context), - avgs: entries7dAvg, - ), - ), - if (entries7dAvg.isNotEmpty) - MeasurementOverallChangeWidget( - entries7dAvg.first, - entries7dAvg.last, - weightUnit(profile.isMetric, context), - ), - LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: ConstrainedBox( - constraints: BoxConstraints(minWidth: constraints.maxWidth), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - TextButton( - child: Text( - AppLocalizations.of(context).goToDetailPage, - overflow: TextOverflow.ellipsis, - ), - onPressed: () { - Navigator.of(context).pushNamed(WeightScreen.routeName); - }, - ), - IconButton( - icon: const Icon(Icons.add), - onPressed: () { - Navigator.pushNamed( - context, - FormScreen.routeName, - arguments: FormScreenArguments( - AppLocalizations.of(context).newEntry, - WeightForm(), - ), - ); - }, - ), - ], - ), - ), - ); - }, + if (pointsAsync.hasError) { + return _errorShell(context, pointsAsync); + } + final points = pointsAsync.value; + if (points == null) { + return _shell(context, const BoxedProgressIndicator()); + } + if (points.isEmpty) { + return _shell( + context, + NothingFound( + AppLocalizations.of(context).noWeightEntries, + AppLocalizations.of(context).newEntry, + WeightForm(category), ), - ], + ); + } + + // The card the body tab shows, over the full history; the shell above + // already titles the widget + return _shell( + context, + CategoriesCard( + category, + elevation: 0, + range: ChartRange.all, + title: '', + displayUnit: weightDisplayUnit(profile.isMetric), + displayUnitLabel: weightUnit(profile.isMetric, context), + newEntryForm: WeightForm(category), + onShowDetails: () => Navigator.pushNamed(context, WeightScreen.routeName), + ), ); } } 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/core/widgets/legend.dart b/lib/core/widgets/legend.dart new file mode 100644 index 000000000..f1a2f1a2c --- /dev/null +++ b/lib/core/widgets/legend.dart @@ -0,0 +1,59 @@ +/* + * 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:flutter/material.dart'; + +/// A legend entry: a colour swatch with its label. +class Indicator extends StatelessWidget { + const Indicator({ + super.key, + required this.color, + required this.text, + required this.isSquare, + this.size = 16, + this.marginRight = 15, + this.textColor, + }); + + final Color color; + final String text; + final bool isSquare; + final double size; + final double marginRight; + final Color? textColor; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: isSquare ? BoxShape.rectangle : BoxShape.circle, + color: color, + ), + ), + const SizedBox(width: 4), + Text(text, style: TextStyle(color: textColor)), + SizedBox(width: marginRight), + ], + ); + } +} diff --git a/lib/database/converters/date_only_text_converter.dart b/lib/database/converters/date_only_text_converter.dart index 7507c216b..7735d8652 100644 --- a/lib/database/converters/date_only_text_converter.dart +++ b/lib/database/converters/date_only_text_converter.dart @@ -42,3 +42,18 @@ class DateOnlyTextConverter extends TypeConverter { '${value.month.toString().padLeft(2, '0')}-' '${value.day.toString().padLeft(2, '0')}'; } + +/// Stores a moment as UTC ISO8601, the same wire format the server speaks. +/// +/// Keeping every row in UTC with an identical layout means a plain string +/// comparison on the column is also a comparison in time, which the session +/// lookup in the log repository relies on. +class DateTimeTextConverter extends TypeConverter { + const DateTimeTextConverter(); + + @override + DateTime fromSql(String fromDb) => DateTime.parse(fromDb).toLocal(); + + @override + String toSql(DateTime value) => value.toUtc().toIso8601String(); +} 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..2a08ffd6b 100644 --- a/lib/database/powersync/database.g.dart +++ b/lib/database/powersync/database.g.dart @@ -418,8 +418,28 @@ class $UserProfileTableTable extends UserProfileTable type: DriftSqlType.string, requiredDuringInsert: true, ); + static const VerificationMeta _heightMeta = const VerificationMeta('height'); + @override + late final GeneratedColumn height = GeneratedColumn( + 'height', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _timeZoneMeta = const VerificationMeta( + 'timeZone', + ); + @override + late final GeneratedColumn timeZone = GeneratedColumn( + 'time_zone', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); @override - List get $columns => [id, weightUnitStr]; + List get $columns => [id, weightUnitStr, height, timeZone]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -448,6 +468,18 @@ class $UserProfileTableTable extends UserProfileTable } else if (isInserting) { context.missing(_weightUnitStrMeta); } + if (data.containsKey('height')) { + context.handle( + _heightMeta, + height.isAcceptableOrUnknown(data['height']!, _heightMeta), + ); + } + if (data.containsKey('time_zone')) { + context.handle( + _timeZoneMeta, + timeZone.isAcceptableOrUnknown(data['time_zone']!, _timeZoneMeta), + ); + } return context; } @@ -465,6 +497,14 @@ class $UserProfileTableTable extends UserProfileTable DriftSqlType.string, data['${effectivePrefix}weight_unit'], )!, + height: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}height'], + ), + timeZone: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}time_zone'], + ), ); } @@ -477,26 +517,36 @@ class $UserProfileTableTable extends UserProfileTable class UserProfileTableCompanion extends UpdateCompanion { final Value id; final Value weightUnitStr; + final Value height; + final Value timeZone; final Value rowid; const UserProfileTableCompanion({ this.id = const Value.absent(), this.weightUnitStr = const Value.absent(), + this.height = const Value.absent(), + this.timeZone = const Value.absent(), this.rowid = const Value.absent(), }); UserProfileTableCompanion.insert({ required int id, required String weightUnitStr, + this.height = const Value.absent(), + this.timeZone = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), weightUnitStr = Value(weightUnitStr); static Insertable custom({ Expression? id, Expression? weightUnitStr, + Expression? height, + Expression? timeZone, Expression? rowid, }) { return RawValuesInsertable({ if (id != null) 'id': id, if (weightUnitStr != null) 'weight_unit': weightUnitStr, + if (height != null) 'height': height, + if (timeZone != null) 'time_zone': timeZone, if (rowid != null) 'rowid': rowid, }); } @@ -504,11 +554,15 @@ class UserProfileTableCompanion extends UpdateCompanion { UserProfileTableCompanion copyWith({ Value? id, Value? weightUnitStr, + Value? height, + Value? timeZone, Value? rowid, }) { return UserProfileTableCompanion( id: id ?? this.id, weightUnitStr: weightUnitStr ?? this.weightUnitStr, + height: height ?? this.height, + timeZone: timeZone ?? this.timeZone, rowid: rowid ?? this.rowid, ); } @@ -522,6 +576,12 @@ class UserProfileTableCompanion extends UpdateCompanion { if (weightUnitStr.present) { map['weight_unit'] = Variable(weightUnitStr.value); } + if (height.present) { + map['height'] = Variable(height.value); + } + if (timeZone.present) { + map['time_zone'] = Variable(timeZone.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -533,6 +593,8 @@ class UserProfileTableCompanion extends UpdateCompanion { return (StringBuffer('UserProfileTableCompanion(') ..write('id: $id, ') ..write('weightUnitStr: $weightUnitStr, ') + ..write('height: $height, ') + ..write('timeZone: $timeZone, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -4674,12 +4736,12 @@ class ExerciseVideoTableCompanion extends UpdateCompanion